From 65a06f07399b0173e0f12f47ea753aa3e450338e Mon Sep 17 00:00:00 2001 From: Blob Date: Fri, 15 May 2026 13:39:39 +0000 Subject: [PATCH] Add security vulnerability monitor agent --- .../agents/security-vulnerability-monitor.ts | 106 ++++++++++++++++++ .../cli/src/lib/build-plugin-cloudflare.ts | 46 +++++++- packages/cli/src/lib/build.ts | 21 +++- .../cli/src/lib/cloudflare-wrangler-merge.ts | 20 ++++ packages/cli/src/lib/types.ts | 4 +- 5 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 examples/assistant/.flue/agents/security-vulnerability-monitor.ts diff --git a/examples/assistant/.flue/agents/security-vulnerability-monitor.ts b/examples/assistant/.flue/agents/security-vulnerability-monitor.ts new file mode 100644 index 00000000..eb81021e --- /dev/null +++ b/examples/assistant/.flue/agents/security-vulnerability-monitor.ts @@ -0,0 +1,106 @@ +import { getSandbox } from '@cloudflare/sandbox'; +import { Type, type FlueContext, type ToolDef } from '@flue/sdk/client'; + +export const triggers = { webhook: true, cron: '0 */6 * * *' }; + +const DEFAULT_FEEDS = [ + 'https://github.blog/security/vulnerability-research/feed/', + 'https://github.blog/changelog/label/security/feed/', + 'https://nodejs.org/en/feed/blog.xml', + 'https://react.dev/rss.xml', + 'https://snyk.io/blog/feed/', + 'https://www.cisa.gov/cybersecurity-advisories/all.xml', + 'https://github.com/advisories?query=type%3Areviewed&output=atom', +]; + +function asStringArray(value: unknown, fallback: string[]) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : fallback; +} + +/** + * Security vulnerability monitor. + * + * Watches security RSS/Atom feeds, evaluates whether any vulnerability applies + * to the configured repositories/packages, alerts the user, and when a repo is + * provided asks the coding agent to create a branch with the remediation and PR. + * + * Payload examples: + * { "repos": ["https://github.com/acme/app"], "packages": ["react", "next"], "notify": "post a concise alert" } + * { "feeds": ["https://react.dev/rss.xml"], "repo": "https://github.com/acme/app" } + */ +export default async function ({ init, id, env, payload }: FlueContext) { + const sandbox = getSandbox(env.Sandbox, id); + const agent = await init({ sandbox, model: 'anthropic/claude-sonnet-4-6' }); + const session = await agent.session(); + + const feeds = asStringArray(payload.feeds, DEFAULT_FEEDS); + const repos = asStringArray(payload.repos, payload.repo ? [String(payload.repo)] : []); + const packages = asStringArray(payload.packages, []); + const lookback = typeof payload.lookback === 'string' ? payload.lookback : '14 days'; + const instructions = typeof payload.instructions === 'string' ? payload.instructions : ''; + + const fetchFeed: ToolDef = { + name: 'fetch_security_feed', + description: 'Fetch one security RSS or Atom feed and return raw XML/text for vulnerability triage.', + parameters: Type.Object({ + url: Type.String({ description: 'RSS/Atom feed URL to fetch' }), + }), + execute: async (args) => { + const url = String(args.url ?? ''); + if (!/^https:\/\//.test(url)) throw new Error('Only https:// feed URLs are allowed'); + const response = await fetch(url, { + headers: { 'user-agent': 'flue-security-vulnerability-monitor/1.0' }, + }); + if (!response.ok) throw new Error(`Feed fetch failed: ${response.status} ${response.statusText}`); + return (await response.text()).slice(0, 120_000); + }, + }; + + const alertWebhookUrl = + typeof env.SECURITY_ALERT_WEBHOOK_URL === 'string' + ? env.SECURITY_ALERT_WEBHOOK_URL + : typeof env.ALERT_WEBHOOK_URL === 'string' + ? env.ALERT_WEBHOOK_URL + : undefined; + + const response = await session.prompt( + `You are a security vulnerability monitoring agent. Your only job is to find newly disclosed security vulnerabilities that may affect the configured code, alert the user, and propose safe code changes by PR when a repository is in scope. + +Feeds to check: +${feeds.map((feed) => `- ${feed}`).join('\n')} + +Repositories in scope: +${repos.length ? repos.map((repo) => `- ${repo}`).join('\n') : '- None provided; alert only, do not create a PR.'} + +Packages/technologies of interest: +${packages.length ? packages.map((pkg) => `- ${pkg}`).join('\n') : '- Infer from any repository manifests you inspect.'} + +Look back: ${lookback} +Extra user instructions: ${instructions || '(none)'} + +Process: +1. Use fetch_security_feed for each feed. Identify recent vulnerability/security advisories, including critical framework notices such as the React Server Components disclosure. +2. Decide whether each advisory likely affects the configured repositories/packages. If a repo is provided, clone it or inspect the existing checkout, read dependency manifests and lockfiles, and verify exposure before changing files. +3. If affected, create a remediation branch, update vulnerable dependencies or code/config as recommended by the advisory, run the relevant tests/audits, commit the change, and open or prepare a pull request with gh if credentials are available. If gh credentials are unavailable, leave a branch/patch and exact PR title/body. +4. Return a concise alert report with: advisory title, source URL, severity, affected package/version, affected repos, action taken, PR URL or branch/patch path, tests run, and remaining manual steps. +5. If no relevant vulnerabilities are found, say so and include the feeds checked.`, + { tools: [fetchFeed] }, + ); + + if (alertWebhookUrl) { + await fetch(alertWebhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agent: 'security-vulnerability-monitor', + id, + text: response.text, + feeds, + repos, + packages, + }), + }); + } + + return { reply: response.text, alerted: Boolean(alertWebhookUrl), feeds, repos, packages }; +} diff --git a/packages/cli/src/lib/build-plugin-cloudflare.ts b/packages/cli/src/lib/build-plugin-cloudflare.ts index ba65bf5d..eacfc8f2 100644 --- a/packages/cli/src/lib/build-plugin-cloudflare.ts +++ b/packages/cli/src/lib/build-plugin-cloudflare.ts @@ -54,6 +54,7 @@ export class CloudflarePlugin implements BuildPlugin { validateCloudflareAgentNames(ctx); const webhookAgents = agents.filter((a) => a.triggers.webhook); + const cronAgents = agents.filter((a) => a.triggers.cron); const agentImports = agents .map((a, index) => { @@ -63,7 +64,18 @@ export class CloudflarePlugin implements BuildPlugin { }) .join('\n'); - const agentClasses = webhookAgents + const cronRoutes = JSON.stringify( + cronAgents.map((a) => ({ + name: a.name, + cron: a.triggers.cron, + className: agentClassName(a.name), + })), + null, + 2, + ); + + const agentClasses = agents + .filter((a) => a.triggers.webhook || a.triggers.cron) .map((a) => { const className = agentClassName(a.name); const handlerVar = agentVarName(a.name, agents.indexOf(a)); @@ -297,6 +309,26 @@ async function handleFlueFiberRecovered(ctx, _doInstance, agentName) { console.warn('[flue] Cloudflare fiber interrupted:', agentName, ctx.name, ctx.snapshot ?? null); } +const cronRoutes = ${cronRoutes}; + +async function runCronAgents(controller, env, ctx) { + const matchingRoutes = cronRoutes.filter((route) => !controller?.cron || route.cron === controller.cron); + await Promise.all(matchingRoutes.map(async (route) => { + const namespace = env[route.className]; + if (!namespace) { + throw new Error('[flue] Missing Durable Object binding for cron agent ' + route.name + ': ' + route.className); + } + const stub = await getAgentByName(namespace, 'cron'); + const request = new Request('https://flue.local/agents/' + route.name + '/cron', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-webhook': 'true' }, + body: JSON.stringify({ trigger: 'cron', cron: controller?.cron, scheduledTime: controller?.scheduledTime }), + }); + const response = await stub.fetch(request); + if (!response.ok) throw new Error('[flue] Cron agent failed: ' + route.name + ' HTTP ' + response.status); + })); +} + // ─── Per-DO Dispatch ─────────────────────────────────────────────────────── async function dispatchAgent(request, doInstance, agentName, handler) { @@ -414,6 +446,10 @@ const app = createDefaultFlueApp();` } export default { + scheduled(controller, env, ctx) { + ctx.waitUntil(runCronAgents(controller, env, ctx)); + }, + fetch(request, env, ctx) { return app.fetch(request, env, ctx); }, @@ -423,9 +459,12 @@ export default { async additionalOutputs(ctx: BuildContext): Promise> { const outputs: Record = {}; - const webhookAgents = ctx.agents.filter((a) => a.triggers.webhook); + const webhookAgents = ctx.agents.filter((a) => a.triggers.webhook || a.triggers.cron); + const cronSchedules = Array.from( + new Set(ctx.agents.map((a) => a.triggers.cron).filter((cron): cron is string => Boolean(cron))), + ); - // Per-agent DO bindings: one per webhook agent. Flue no longer forces a + // Per-agent DO bindings: one per webhook/cron agent. Flue no longer forces a // `Sandbox` binding, container entry, or Dockerfile — users who want // container sandboxes declare those themselves in their own // wrangler.jsonc (preserved via the merge below). Flue only automates @@ -473,6 +512,7 @@ export default { // `wrangler dev` / `wrangler deploy` time. We don't pre-bundle. main: '_entry.ts', doBindings: flueBindings, + crons: cronSchedules, migrations: flueMigrations, }; diff --git a/packages/cli/src/lib/build.ts b/packages/cli/src/lib/build.ts index 49a62926..da88fa49 100644 --- a/packages/cli/src/lib/build.ts +++ b/packages/cli/src/lib/build.ts @@ -17,6 +17,7 @@ import type { interface ParsedAgentFile { triggers: { webhook?: boolean; + cron?: string; }; } @@ -86,14 +87,14 @@ function parseTriggersInitializer( } if (ts.isShorthandPropertyAssignment(property)) { const name = property.name.text; - if (name === 'webhook') { + if (name === 'webhook' || name === 'cron') { throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`); } continue; } if (!ts.isPropertyAssignment(property)) { const name = propertyNameText(filePath, property.name); - if (name === 'webhook') { + if (name === 'webhook' || name === 'cron') { throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`); } continue; @@ -106,6 +107,14 @@ function parseTriggersInitializer( else if (value.kind === ts.SyntaxKind.FalseKeyword) delete result.webhook; else throwUnsupportedTriggers(filePath, '"webhook" must be true or false'); } + if (name === 'cron') { + const value = unwrapExpression(property.initializer); + if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) { + result.cron = value.text; + } else { + throwUnsupportedTriggers(filePath, '"cron" must be a static string'); + } + } } return result; @@ -232,7 +241,8 @@ export async function build(options: BuildOptions): Promise { // locally (see FLUE_MODE=local in the Node plugin). This supports the // "CI-only agent" pattern documented in the README. const webhookAgents = agents.filter((a) => a.triggers.webhook); - const triggerlessAgents = agents.filter((a) => !a.triggers.webhook); + const cronAgents = agents.filter((a) => a.triggers.cron); + const triggerlessAgents = agents.filter((a) => !a.triggers.webhook && !a.triggers.cron); console.log( `[flue] Found ${Object.keys(roles).length} role(s): ${Object.keys(roles).join(', ') || '(none)'}`, @@ -241,6 +251,11 @@ export async function build(options: BuildOptions): Promise { if (webhookAgents.length > 0) { console.log(`[flue] Webhook agents: ${webhookAgents.map((a) => a.name).join(', ')}`); } + if (cronAgents.length > 0) { + console.log( + `[flue] Cron agents: ${cronAgents.map((a) => `${a.name} (${a.triggers.cron})`).join(', ')}`, + ); + } if (triggerlessAgents.length > 0) { console.log( `[flue] CLI-only agents (no HTTP route in deployed build): ${triggerlessAgents.map((a) => a.name).join(', ')}`, diff --git a/packages/cli/src/lib/cloudflare-wrangler-merge.ts b/packages/cli/src/lib/cloudflare-wrangler-merge.ts index 2a65bb58..c8ce4b09 100644 --- a/packages/cli/src/lib/cloudflare-wrangler-merge.ts +++ b/packages/cli/src/lib/cloudflare-wrangler-merge.ts @@ -78,6 +78,8 @@ export interface FlueAdditions { main: string; /** Flue's per-agent DO bindings. Merged into durable_objects.bindings by `name`. */ doBindings: DoBinding[]; + /** Cron schedules needed by agents with `triggers.cron`. Merged into triggers.crons. */ + crons?: string[]; /** * Migrations Flue wants to add for net-new agent classes. Each entry is * appended to the merged migrations array iff a migration with the same @@ -422,6 +424,24 @@ export function mergeFlueAdditions( } merged.migrations = migrationsOut; + // triggers.crons: union user-owned schedules with schedules required by + // agents declaring `triggers.cron`. User schedules are preserved; Flue only + // appends missing cron strings. + const existingTriggers = + typeof merged.triggers === 'object' && merged.triggers !== null + ? (merged.triggers as Record) + : {}; + const existingCrons = Array.isArray(existingTriggers.crons) + ? (existingTriggers.crons as unknown[]).filter((cron): cron is string => typeof cron === 'string') + : []; + const cronsOut = [...existingCrons]; + for (const cron of additions.crons ?? []) { + if (!cronsOut.includes(cron)) cronsOut.push(cron); + } + if (cronsOut.length > 0) { + merged.triggers = { ...existingTriggers, crons: cronsOut }; + } + // containers: user owns the `containers` array entirely. Flue contributes // nothing here — any entries the user declared pass through untouched via // the shallow `{ ...userConfig }` clone above. Nothing to merge. diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 48740e17..c8b7bf9b 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -12,12 +12,12 @@ import type { Role } from '@flue/runtime'; export interface AgentInfo { name: string; filePath: string; - triggers: { webhook?: boolean }; + triggers: { webhook?: boolean; cron?: string }; } export interface BuildContext { agents: AgentInfo[]; - manifest: { agents: Array<{ name: string; triggers: { webhook?: boolean } }> }; + manifest: { agents: Array<{ name: string; triggers: { webhook?: boolean; cron?: string } }> }; roles: Record; /** * The project root — typically the user's cwd. Source files