@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.
Files changed (65) hide show
  1. package/dist/src/a2a-client.d.ts +165 -0
  2. package/dist/src/a2a-client.js +440 -0
  3. package/dist/src/a2a-publish.d.ts +37 -0
  4. package/dist/src/a2a-publish.js +116 -0
  5. package/dist/src/a2a-remotes.d.ts +73 -0
  6. package/dist/src/a2a-remotes.js +192 -0
  7. package/dist/src/a2a-tools.d.ts +148 -0
  8. package/dist/src/a2a-tools.js +833 -0
  9. package/dist/src/api-tools.js +15 -10
  10. package/dist/src/approvals.d.ts +18 -10
  11. package/dist/src/approvals.js +51 -1
  12. package/dist/src/browser-pod.js +4 -0
  13. package/dist/src/catalog.d.ts +14 -0
  14. package/dist/src/catalog.js +28 -0
  15. package/dist/src/completions.js +8 -8
  16. package/dist/src/cron.d.ts +9 -0
  17. package/dist/src/cron.js +20 -0
  18. package/dist/src/frontmatter-fields.js +3 -1
  19. package/dist/src/frontmatter-schema.d.ts +5 -1
  20. package/dist/src/frontmatter-schema.js +44 -1
  21. package/dist/src/limits.js +22 -4
  22. package/dist/src/notify.d.ts +6 -3
  23. package/dist/src/notify.js +25 -12
  24. package/dist/src/package.d.ts +5 -1
  25. package/dist/src/package.js +5 -3
  26. package/dist/src/run-container.d.ts +10 -1
  27. package/dist/src/run-container.js +17 -0
  28. package/dist/src/runner.d.ts +10 -0
  29. package/dist/src/runner.js +163 -16
  30. package/dist/src/scan.js +123 -5
  31. package/dist/src/secrets.d.ts +122 -10
  32. package/dist/src/secrets.js +291 -27
  33. package/dist/src/starter.js +5 -1
  34. package/dist/src/store.d.ts +34 -0
  35. package/dist/src/store.js +28 -7
  36. package/dist/src/subagents.d.ts +16 -0
  37. package/dist/src/subagents.js +23 -2
  38. package/dist/src/triggers.js +2 -2
  39. package/dist/src/webhook.d.ts +19 -3
  40. package/dist/src/webhook.js +14 -5
  41. package/package.json +4 -1
  42. package/src/a2a-client.ts +517 -0
  43. package/src/a2a-publish.ts +120 -0
  44. package/src/a2a-remotes.ts +225 -0
  45. package/src/a2a-tools.ts +919 -0
  46. package/src/api-tools.ts +20 -8
  47. package/src/approvals.ts +55 -1
  48. package/src/browser-pod.ts +3 -0
  49. package/src/catalog.ts +27 -0
  50. package/src/completions.ts +8 -8
  51. package/src/cron.ts +19 -0
  52. package/src/frontmatter-fields.ts +3 -1
  53. package/src/frontmatter-schema.ts +42 -2
  54. package/src/limits.ts +22 -4
  55. package/src/notify.ts +23 -12
  56. package/src/package.ts +5 -3
  57. package/src/run-container.ts +22 -0
  58. package/src/runner.ts +157 -13
  59. package/src/scan.ts +146 -6
  60. package/src/secrets.ts +319 -33
  61. package/src/starter.ts +5 -1
  62. package/src/store.ts +47 -2
  63. package/src/subagents.ts +29 -2
  64. package/src/triggers.ts +2 -2
  65. 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
- function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }) {
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 outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`)];
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 (readsWeb && privateLeg.length && outLeg.length && !gated) {
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} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
730
- `a page 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`,
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
  }