Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions examples/assistant/.flue/agents/security-vulnerability-monitor.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
46 changes: 43 additions & 3 deletions packages/cli/src/lib/build-plugin-cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
},
Expand All @@ -423,9 +459,12 @@ export default {

async additionalOutputs(ctx: BuildContext): Promise<Record<string, string>> {
const outputs: Record<string, string> = {};
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
Expand Down Expand Up @@ -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,
};

Expand Down
21 changes: 18 additions & 3 deletions packages/cli/src/lib/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
interface ParsedAgentFile {
triggers: {
webhook?: boolean;
cron?: string;
};
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -232,7 +241,8 @@ export async function build(options: BuildOptions): Promise<BuildResult> {
// 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)'}`,
Expand All @@ -241,6 +251,11 @@ export async function build(options: BuildOptions): Promise<BuildResult> {
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(', ')}`,
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/lib/cloudflare-wrangler-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>)
: {};
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.
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Role>;
/**
* The project root — typically the user's cwd. Source files
Expand Down