@foldrun/core 9.0.0 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/a2a-client.d.ts +165 -0
- package/dist/src/a2a-client.js +440 -0
- package/dist/src/a2a-publish.d.ts +37 -0
- package/dist/src/a2a-publish.js +116 -0
- package/dist/src/a2a-remotes.d.ts +73 -0
- package/dist/src/a2a-remotes.js +192 -0
- package/dist/src/a2a-tools.d.ts +148 -0
- package/dist/src/a2a-tools.js +833 -0
- package/dist/src/api-tools.js +15 -10
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +3 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +44 -1
- package/dist/src/limits.js +22 -4
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/run-container.d.ts +10 -1
- package/dist/src/run-container.js +17 -0
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +163 -16
- package/dist/src/scan.js +123 -5
- package/dist/src/secrets.d.ts +122 -10
- package/dist/src/secrets.js +291 -27
- package/dist/src/starter.js +5 -1
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +28 -7
- package/dist/src/subagents.d.ts +16 -0
- package/dist/src/subagents.js +23 -2
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +4 -1
- package/src/a2a-client.ts +517 -0
- package/src/a2a-publish.ts +120 -0
- package/src/a2a-remotes.ts +225 -0
- package/src/a2a-tools.ts +919 -0
- package/src/api-tools.ts +20 -8
- package/src/approvals.ts +55 -1
- package/src/browser-pod.ts +3 -0
- package/src/catalog.ts +27 -0
- package/src/completions.ts +8 -8
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +3 -1
- package/src/frontmatter-schema.ts +42 -2
- package/src/limits.ts +22 -4
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/run-container.ts +22 -0
- package/src/runner.ts +157 -13
- package/src/scan.ts +146 -6
- package/src/secrets.ts +319 -33
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- package/src/subagents.ts +29 -2
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/src/scan.ts
CHANGED
|
@@ -31,6 +31,9 @@ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, confi
|
|
|
31
31
|
import { refNames } from "./refs.ts";
|
|
32
32
|
import { resolveNetwork } from "./network.ts";
|
|
33
33
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
34
|
+
import { parseRemotes } from "./a2a-remotes.ts";
|
|
35
|
+
import { isA2aPublished } from "./a2a-publish.ts";
|
|
36
|
+
import { parseFlow, type FlowStep } from "./store.ts";
|
|
34
37
|
|
|
35
38
|
export type ScanLevel = "error" | "warn" | "info";
|
|
36
39
|
export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
|
|
@@ -137,6 +140,31 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
137
140
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
138
141
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
139
142
|
},
|
|
143
|
+
{
|
|
144
|
+
// 1 Nov 2026, with A2A: a published flow's first input is whatever an
|
|
145
|
+
// outside caller sends. Shipped as a warning, like every new rule.
|
|
146
|
+
id: "security/published-flow-trifecta",
|
|
147
|
+
category: "security",
|
|
148
|
+
level: "warn",
|
|
149
|
+
title: "A flow published over A2A runs an agent that holds private data and can send it out",
|
|
150
|
+
help:
|
|
151
|
+
"An outside agent's message is untrusted input to every step of a published flow. A step whose agent also holds a secret, " +
|
|
152
|
+
"an API or an MCP server, and can reach an open network, an outward tool or an API it may write to, can be told by the caller " +
|
|
153
|
+
"where to send what it holds. Take a leg away from that agent (network: { level: trusted, allow: [...] }, split it, a hooks: gate), " +
|
|
154
|
+
"or do not publish the flow.",
|
|
155
|
+
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "security/published-flow-ungated",
|
|
159
|
+
category: "security",
|
|
160
|
+
level: "warn",
|
|
161
|
+
title: "A flow published over A2A acts outside the workspace with no person in the way",
|
|
162
|
+
help:
|
|
163
|
+
"An outside caller can start a published flow as often as its key allows. A step that posts, sends, writes to an API or calls " +
|
|
164
|
+
"another outside agent, with no approval (`!`) or `ask:` before it or on it, does what any caller asked. Put a gate before the " +
|
|
165
|
+
"first such step — an approval is a person reading what it will do.",
|
|
166
|
+
owasp: ["ASI02", "ASI09"],
|
|
167
|
+
},
|
|
140
168
|
{
|
|
141
169
|
id: "security/subagent-wider",
|
|
142
170
|
category: "security",
|
|
@@ -270,6 +298,29 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
270
298
|
title: "A required frontmatter field is missing",
|
|
271
299
|
help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
|
|
272
300
|
},
|
|
301
|
+
{
|
|
302
|
+
// 30 Oct 2026: check and the scheduler read a schedule with the same
|
|
303
|
+
// cronCanFire (core cron.ts), so check says what the scheduler does.
|
|
304
|
+
id: "config/cron-never-fires",
|
|
305
|
+
category: "config",
|
|
306
|
+
level: "warn",
|
|
307
|
+
title: "A schedule that names a day no month has",
|
|
308
|
+
help:
|
|
309
|
+
"`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
|
|
310
|
+
"scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
// 30 Oct 2026: a signing secret is looked up in the vault by name.
|
|
314
|
+
id: "secret/signing-secret-value",
|
|
315
|
+
category: "secret",
|
|
316
|
+
level: "warn",
|
|
317
|
+
title: "A signing_secret: that holds a value, not a secret's name",
|
|
318
|
+
help:
|
|
319
|
+
"A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
|
|
320
|
+
"Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
|
|
321
|
+
"and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
|
|
322
|
+
owasp: ["ASI03"],
|
|
323
|
+
},
|
|
273
324
|
{
|
|
274
325
|
id: "check/ignore-needs-reason",
|
|
275
326
|
category: "config",
|
|
@@ -697,8 +748,20 @@ interface ToolFacts {
|
|
|
697
748
|
lines: string[];
|
|
698
749
|
}
|
|
699
750
|
|
|
700
|
-
|
|
751
|
+
/** What one agent can reach, as the trifecta and the published-flow rules
|
|
752
|
+
* read it: each leg's items, for a message. */
|
|
753
|
+
interface AgentLegs {
|
|
754
|
+
privateLeg: string[];
|
|
755
|
+
outLeg: string[];
|
|
756
|
+
/** What it can do outside the workspace: outward tools, API writes, sends
|
|
757
|
+
* to outside agents. */
|
|
758
|
+
writes: string[];
|
|
759
|
+
gated: boolean;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }): Map<string, AgentLegs> {
|
|
701
763
|
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
764
|
+
const legs = new Map<string, AgentLegs>();
|
|
702
765
|
for (const a of agents) {
|
|
703
766
|
const d = a.data;
|
|
704
767
|
const tools = refNames(d.tools);
|
|
@@ -708,6 +771,11 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
708
771
|
// Untrusted: it reads the open web. (A webhook body is the other source;
|
|
709
772
|
// that is a flow's trigger, and the flow-level rule is a later one.)
|
|
710
773
|
const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
|
|
774
|
+
// An outside agent's reply is untrusted too, and a message to one is a
|
|
775
|
+
// way out (remotes:). Its own token is not private data the agent holds:
|
|
776
|
+
// the proxy fills it, for that remote's origin alone.
|
|
777
|
+
const remotes = parseRemotes(d.remotes);
|
|
778
|
+
const untrusted = [...(readsWeb ? ["reads the web"] : []), ...(remotes.length ? [`reads replies from outside agents (${remotes.slice(0, 3).map((r) => r.name).join(", ")}${remotes.length > 3 ? ", …" : ""})`] : [])];
|
|
711
779
|
// Private: a secret it spends itself, an API or MCP server it holds.
|
|
712
780
|
const secrets = Array.isArray(d.secrets) ? d.secrets.map(String) : [];
|
|
713
781
|
const apis = Array.isArray(d.apis) ? d.apis : [];
|
|
@@ -720,14 +788,16 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
720
788
|
const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
|
|
721
789
|
const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
|
|
722
790
|
const writeApis = apis.filter((x: any) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
|
|
723
|
-
const
|
|
791
|
+
const writes = [...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`), ...remotes.map((r) => `outside agent ${r.name}`)];
|
|
792
|
+
const outLeg = [...(openNet ? ["an open network"] : []), ...writes];
|
|
724
793
|
const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
|
|
794
|
+
legs.set(a.name, { privateLeg, outLeg, writes, gated });
|
|
725
795
|
|
|
726
|
-
if (
|
|
796
|
+
if (untrusted.length && privateLeg.length && outLeg.length && !gated) {
|
|
727
797
|
push(
|
|
728
798
|
"security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`,
|
|
729
|
-
`${a.name}
|
|
730
|
-
|
|
799
|
+
`${a.name} ${untrusted.join(" and ")}, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
|
|
800
|
+
`${readsWeb ? "a page it reads" : "a reply it reads"} can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`,
|
|
731
801
|
);
|
|
732
802
|
}
|
|
733
803
|
|
|
@@ -761,6 +831,72 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
761
831
|
if (pkg) push("supply-chain/unpinned-mcp", file, keyLine(lines, "command"), `mcp ${pkg}`, `command runs \`${pkg}\` — whatever was published last; pin a version`);
|
|
762
832
|
runtimePackages(def.runtime, file, lines, push);
|
|
763
833
|
}
|
|
834
|
+
return legs;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── flows published over A2A ───────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
interface FlowFacts {
|
|
840
|
+
file: string;
|
|
841
|
+
content: string;
|
|
842
|
+
lines: string[];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** The agents a step may run: its own, a rescuer, the ones it delegates to. */
|
|
846
|
+
function stepAgents(s: FlowStep): string[] {
|
|
847
|
+
if (s.subflow) return [];
|
|
848
|
+
return [...new Set([s.agent, ...(s.onFail ? [s.onFail] : []), ...(s.delegate ?? [])].filter(Boolean))];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* A flow with `a2a: {publish: true}` takes its first input from an outside
|
|
853
|
+
* caller. Two findings: a step agent the caller could steer into sending
|
|
854
|
+
* what it holds (the trifecta, with the caller as the untrusted leg), and a
|
|
855
|
+
* step that acts outside the workspace with no person before it.
|
|
856
|
+
*/
|
|
857
|
+
function scanPublishedFlows(flows: FlowFacts[], legs: Map<string, AgentLegs>, push: Push) {
|
|
858
|
+
for (const fl of flows) {
|
|
859
|
+
let info: ReturnType<typeof parseFlow>;
|
|
860
|
+
try {
|
|
861
|
+
info = parseFlow(fl.file, fl.content);
|
|
862
|
+
} catch {
|
|
863
|
+
continue; // a flow that does not parse is check's finding
|
|
864
|
+
}
|
|
865
|
+
if (!isA2aPublished(info.a2a)) continue;
|
|
866
|
+
const at = keyLine(fl.lines, "a2a");
|
|
867
|
+
const said = new Set<string>();
|
|
868
|
+
for (const step of info.steps) {
|
|
869
|
+
for (const name of stepAgents(step)) {
|
|
870
|
+
const l = legs.get(name);
|
|
871
|
+
if (!l || said.has(name) || l.gated || !l.privateLeg.length || !l.outLeg.length) continue;
|
|
872
|
+
said.add(name);
|
|
873
|
+
push(
|
|
874
|
+
"security/published-flow-trifecta", fl.file, step.line ?? at, `published trifecta ${name}`,
|
|
875
|
+
`${info.name} is published over A2A, so what an outside caller sends reaches ${name}, which holds ${l.privateLeg.slice(0, 3).join(", ")}${l.privateLeg.length > 3 ? ", …" : ""} ` +
|
|
876
|
+
`and can reach ${l.outLeg.slice(0, 2).join(" and ")} — a caller can tell it where to send what it holds. Take a leg away from ${name}, or do not publish the flow`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// Groups in order. A gate (an approval, or ask:, which is one with a
|
|
881
|
+
// question) on any step of a group parks the run before ANY step of that
|
|
882
|
+
// group launches (runner.ts driveRunInner), so it holds its parallel
|
|
883
|
+
// siblings as well as every group after.
|
|
884
|
+
let gated = false;
|
|
885
|
+
for (const g of [...new Set(info.steps.map((s) => s.group))].sort((a, b) => a - b)) {
|
|
886
|
+
const group = info.steps.filter((s) => s.group === g);
|
|
887
|
+
if (group.some((s) => s.approve || s.ask)) gated = true;
|
|
888
|
+
for (const s of group) {
|
|
889
|
+
if (gated) continue;
|
|
890
|
+
const writes = [...new Set(stepAgents(s).flatMap((n) => legs.get(n)?.writes ?? []))];
|
|
891
|
+
if (!writes.length) continue;
|
|
892
|
+
push(
|
|
893
|
+
"security/published-flow-ungated", fl.file, s.line ?? at, `published ungated ${g} ${s.agent}`,
|
|
894
|
+
`step ${g} ([[${s.agent}]]) can act outside the workspace (${writes.slice(0, 2).join(", ")}${writes.length > 2 ? ", …" : ""}) and ${info.name} is published over A2A, ` +
|
|
895
|
+
`with no approval or ask: before it — any caller with a key starts it. Put a gate (\`${g}!.\`) on it or before it`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
764
900
|
}
|
|
765
901
|
|
|
766
902
|
function runtimePackages(runtime: unknown, file: string, lines: string[], push: Push) {
|
|
@@ -893,6 +1029,7 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
893
1029
|
|
|
894
1030
|
const outward = new Set(options.outwardTools ?? []);
|
|
895
1031
|
const agents: AgentFacts[] = [];
|
|
1032
|
+
const flows: FlowFacts[] = [];
|
|
896
1033
|
const toolDefs = new Map<string, ToolFacts>();
|
|
897
1034
|
|
|
898
1035
|
for (const f of files.filter(isText)) {
|
|
@@ -947,6 +1084,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
947
1084
|
const rel = f.path.replace(/^library\//, "");
|
|
948
1085
|
const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
|
|
949
1086
|
agents.push({ name, file: f.path, line: 1, data, lines });
|
|
1087
|
+
} else if (kind === "flow") {
|
|
1088
|
+
if (!f.path.startsWith("library/")) flows.push({ file: f.path, content: f.content, lines });
|
|
950
1089
|
} else if (kind === "tool") {
|
|
951
1090
|
const name = typeof data.name === "string" ? data.name : f.path.replace(/^(?:library\/)?tools\//, "").replace(/(?:\/tool)?\.md$/, "");
|
|
952
1091
|
if (data.outward === true) outward.add(name);
|
|
@@ -955,7 +1094,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
955
1094
|
}
|
|
956
1095
|
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
957
1096
|
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
958
|
-
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
1097
|
+
const legs = scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
|
|
1098
|
+
scanPublishedFlows(flows, legs, push);
|
|
959
1099
|
for (const m of more.values()) {
|
|
960
1100
|
findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
|
|
961
1101
|
}
|