@foldrun/core 9.1.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 (44) 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/browser-pod.js +4 -0
  11. package/dist/src/completions.js +8 -8
  12. package/dist/src/frontmatter-fields.js +2 -0
  13. package/dist/src/frontmatter-schema.js +4 -0
  14. package/dist/src/limits.js +22 -4
  15. package/dist/src/run-container.d.ts +10 -1
  16. package/dist/src/run-container.js +17 -0
  17. package/dist/src/runner.d.ts +10 -0
  18. package/dist/src/runner.js +158 -14
  19. package/dist/src/scan.js +102 -5
  20. package/dist/src/secrets.d.ts +30 -0
  21. package/dist/src/secrets.js +41 -1
  22. package/dist/src/starter.js +5 -1
  23. package/dist/src/store.d.ts +34 -0
  24. package/dist/src/store.js +28 -7
  25. package/dist/src/subagents.d.ts +16 -0
  26. package/dist/src/subagents.js +23 -2
  27. package/package.json +4 -1
  28. package/src/a2a-client.ts +517 -0
  29. package/src/a2a-publish.ts +120 -0
  30. package/src/a2a-remotes.ts +225 -0
  31. package/src/a2a-tools.ts +919 -0
  32. package/src/api-tools.ts +20 -8
  33. package/src/browser-pod.ts +3 -0
  34. package/src/completions.ts +8 -8
  35. package/src/frontmatter-fields.ts +2 -0
  36. package/src/frontmatter-schema.ts +4 -0
  37. package/src/limits.ts +22 -4
  38. package/src/run-container.ts +22 -0
  39. package/src/runner.ts +152 -11
  40. package/src/scan.ts +123 -6
  41. package/src/secrets.ts +43 -1
  42. package/src/starter.ts +5 -1
  43. package/src/store.ts +47 -2
  44. package/src/subagents.ts +29 -2
@@ -35,20 +35,22 @@ const isolatedRun = () => {
35
35
  return mode === "container" || (!!mode && mode in platform.isolation);
36
36
  };
37
37
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
38
- import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
38
+ import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, SUBAGENT_NO_REMOTES } from "./subagents.js";
39
39
  import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel, readCopyBack } from "./delegate-tools.js";
40
40
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
41
41
  import { sendPlainNotification } from "./notify.js";
42
42
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
43
43
  import { refNames } from "./refs.js";
44
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
44
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, stoppedByText, } from "./store.js";
45
45
  import { canReadBack } from "./tool-output.js";
46
46
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
47
47
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
48
48
  import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.js";
49
- import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.js";
49
+ import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, siteCovers, EGRESS_CA_ENV } from "./network.js";
50
50
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
51
51
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
52
+ import { buildA2ATools } from "./a2a-tools.js";
53
+ import { parseRemotes, remoteSecretNames, remoteGrantHost, remoteNetworkHosts, remotesPromptLines, A2A_TOOLS } from "./a2a-remotes.js";
52
54
  import { attachOperations, prefetchOpenApi } from "./openapi.js";
53
55
  import { buildSearchTools, buildHistoryTools, buildDeskTools, digestRuns } from "./context-tools.js";
54
56
  import { startTranslator, translatorSpecFor } from "./translator.js";
@@ -539,6 +541,12 @@ export function buildSubagentSpecs(o) {
539
541
  continue;
540
542
  }
541
543
  const tools = subagentTools(own.allowed, o.parentAllowed);
544
+ // remotes: are the parent's server and credentials; withheld here
545
+ // (subagents.ts isA2aTool), and the sub-agent and the step are told.
546
+ const remotesWithheld = own.remotes.length > 0;
547
+ if (remotesWithheld) {
548
+ o.push("info", `subagents: "${sub.name}" declares remotes: (${own.remotes.map((r) => r.name).join(", ")}), which it does not get inside this step — ${SUBAGENT_NO_REMOTES}`);
549
+ }
542
550
  if (!tools.length) {
543
551
  o.push("error", `subagents: "${sub.name}" holds none of its tools inside this step — it can only answer from its prompt (use agents: for that)`);
544
552
  }
@@ -552,7 +560,7 @@ export function buildSubagentSpecs(o) {
552
560
  parent: o.self,
553
561
  workspace: path.basename(o.workspaceRoot),
554
562
  shared: sharedInstructions(sub.dir, o.tenant),
555
- }),
563
+ }, { remotesWithheld }),
556
564
  tools,
557
565
  disallowedTools: refNames(own.front.disallowedTools),
558
566
  model: own.front.model ? resolveModel(own.front.model) : "inherit",
@@ -995,6 +1003,10 @@ grantsOnly = false) {
995
1003
  // APIs — the agent's own definitions, plus workspace tools it granted by
996
1004
  // name. Library tools are defined once and reused.
997
1005
  const apis = parseApis(front.apis);
1006
+ // remotes: — outside A2A agents (a2a-remotes.ts). The tools are built
1007
+ // where the step runs (runStep, run-container.ts), like ask's: they keep
1008
+ // handles under the run and the step, which only the step knows.
1009
+ const remotes = parseRemotes(front.remotes);
998
1010
  // Scripts declared inline on the agent, plus any script-type tool it
999
1011
  // granted — one list grants both kinds.
1000
1012
  const scriptSpecs = parseScripts(front.scripts);
@@ -1075,6 +1087,10 @@ grantsOnly = false) {
1075
1087
  if (apiTools.promptLines.length) {
1076
1088
  parts.add("apis", `# APIs you can call\n\n${apiTools.promptLines.join("\n")}`);
1077
1089
  }
1090
+ // Read-only (permissionMode: plan) holds no tool that sends; nor the line.
1091
+ if (remotes.length && front.permissionMode !== "plan") {
1092
+ parts.add("remotes", `# Outside agents you can call (A2A)\n\n${remotesPromptLines(remotes).join("\n")}`);
1093
+ }
1078
1094
  // Secrets an agent declared become env vars for its scripts and bash.
1079
1095
  //
1080
1096
  // Plus the credentials its API tools reference. An agent that opted into
@@ -1152,7 +1168,21 @@ grantsOnly = false) {
1152
1168
  // One name once, however many places asked for it.
1153
1169
  declared.splice(0, declared.length, ...new Set(declared));
1154
1170
  const referenced = [...new Set(apis.flatMap(secretsUsedByApi))];
1171
+ // A remote's secret travels with the remote, as an API's does: naming it
1172
+ // under remotes: is enough. One nothing else in the step uses is resolved
1173
+ // apart, into remoteSecretEnv, and reaches the a2a tools alone — never
1174
+ // secretEnv, which every script, Bash, consult and the sandbox's
1175
+ // environment are built from, on either path. On the isolated path it is
1176
+ // then the egress proxy's to fill for the card's origin (runStep). A
1177
+ // secret the agent also declares, or an API also uses, is the agent's own
1178
+ // choice to expose, and stays where it was.
1179
+ const remoteSecrets = remoteSecretNames(remotes);
1180
+ const remoteOnlySecrets = remoteSecrets.filter((n) => !declared.includes(n) && !referenced.includes(n));
1155
1181
  const { env: secretEnv, from: secretScopes, } = resolveSecrets(tenant, [...new Set([...declared, ...referenced])], workspace);
1182
+ const { env: remoteSecretEnv, from: remoteScopes } = resolveSecrets(tenant, remoteOnlySecrets, workspace);
1183
+ // Which store each came from, for the trace's "secret X ← scope" lines and
1184
+ // the proxy's health keys.
1185
+ Object.assign(secretScopes, remoteScopes);
1156
1186
  const { missing: missingDeclared } = resolveSecrets(tenant, declared, workspace);
1157
1187
  // The step's pod size. Three classes, not a dial: a size is a price and a
1158
1188
  // promise, and three of each is a menu while ten is a support queue. Default
@@ -1560,6 +1590,8 @@ grantsOnly = false) {
1560
1590
  }
1561
1591
  }
1562
1592
  allowed.push(...apiTools.toolNames, ...scriptTools.toolNames);
1593
+ if (remotes.length)
1594
+ allowed.push(...A2A_TOOLS);
1563
1595
  // tools: [search] — every place a fact of this agent's could be, in the
1564
1596
  // same nearest-wins order the prompt lists them. tools: [history] — the
1565
1597
  // workspace's finished runs, minus the one being driven. Both are values
@@ -1656,8 +1688,18 @@ grantsOnly = false) {
1656
1688
  // path, which serialises specs across the container boundary rather
1657
1689
  // than using the servers built here.
1658
1690
  apiSpecs: apis,
1691
+ // remotes: — the specs, and the secrets only they use (runStep binds
1692
+ // those to each card's origin).
1693
+ remotes,
1694
+ remoteOnlySecrets,
1695
+ // Never spread into an environment: the a2a tools' alone.
1696
+ remoteSecretEnv,
1659
1697
  scriptSpecs,
1660
- outward: outward || scriptSpecs.some((sc) => sc.outward),
1698
+ // A message to an outside agent acts outside the workspace as surely as
1699
+ // an outward tool: a retry after a failed check could have the same work
1700
+ // done twice, reworded past a2a_send's same-request guard. Granted only
1701
+ // (permissionMode: plan withholds the tools), so only then.
1702
+ outward: outward || scriptSpecs.some((sc) => sc.outward) || (remotes.length > 0 && finalAllowed.some((t) => A2A_TOOLS.includes(t))),
1661
1703
  searchRoots,
1662
1704
  historyDigest,
1663
1705
  deskDigest,
@@ -1704,7 +1746,7 @@ grantsOnly = false) {
1704
1746
  // missing ones so the step can tell "you never made this" apart from
1705
1747
  // "it's right there with a typo in it".
1706
1748
  brokenTools: brokenToolReport(tenant, workspace),
1707
- missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets])],
1749
+ missingSecrets: [...new Set([...missingDeclared, ...apiTools.missingSecrets, ...remoteSecrets.filter((n) => !(n in secretEnv) && !(n in remoteSecretEnv))])],
1708
1750
  };
1709
1751
  }
1710
1752
  /**
@@ -2113,7 +2155,7 @@ mayDefer) {
2113
2155
  // step: the generic HTTP tool still works without the typed ones.
2114
2156
  for (const w of await prefetchOpenApi(tenant, openApiSources(agentDir, tenant)))
2115
2157
  push("error", `openapi: ${w}`);
2116
- const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
2158
+ const { front, clockEnv, clock, systemPrompt, promptLayers, allowed, disabled, apiTools, scriptTools, secretEnv, secretScopes, missingSecrets, missingTools, runtime, unknownTools, shadowed, legacyUse, retiredTools, mcpServers, mcpNames, apiSpecs, scriptSpecs, brokenTools, size: agentSize, remotes, remoteOnlySecrets, remoteSecretEnv, providerEnv, providerLabel, providerSecrets, providerWarnings, formatWarning, searchChoice, fetchChoice, actionChoices, web, language, region, fallbackEnv, apiWarnings, searchRoots, historyDigest, deskDigest, searchTools, historyTools, deskTools, ask, peers, translator, fallbackTranslator, outward, } = agentContext(agentDir, tenant, tags, { runId, agent: step.agent }, flowTimezone);
2117
2159
  disposeRuntime = runtime.dispose;
2118
2160
  canActOutward = outward;
2119
2161
  // Sent again on every turn; with turns and the tools' result sizes, what
@@ -2150,7 +2192,52 @@ mayDefer) {
2150
2192
  // Populate before the first push: everything after this point may quote a
2151
2193
  // credential. Short values are skipped — a two-character secret would
2152
2194
  // redact half the English language, and anything that short is not one.
2153
- redactions = redactionsFor({ ...liveSecrets, ...providerSecrets, ...providerEnv });
2195
+ // remotes: — the credentials only they use, made live apart and never
2196
+ // merged into liveSecrets, which the scripts' and the sandbox's
2197
+ // environments are built from.
2198
+ const remoteLive = await materializeSecrets(remoteSecretEnv, { tenant, workspace: path.basename(workspaceRootOf(agentDir)) });
2199
+ const remoteValue = (name) => remoteLive[name] ?? liveSecrets[name];
2200
+ redactions = redactionsFor({ ...liveSecrets, ...remoteLive, ...providerSecrets, ...providerEnv });
2201
+ // remotes: a secret the vault binds to sites of its own is sent only to
2202
+ // them (egress.ts). A remote whose card is on none of them is not sent
2203
+ // it — not called at all — and the trace says why.
2204
+ const remoteBindings = remotes.length
2205
+ ? secretSites(tenant, remotes.flatMap((r) => (r.secret ? [r.secret] : [])), path.basename(workspaceRootOf(agentDir)))
2206
+ : {};
2207
+ // And the install's rule for a URL a tenant chose, decided here on the
2208
+ // host, which knows the install (a sandbox does not): https only where
2209
+ // untrusted fetches are guarded (platform.ts checkUntrustedUrl). A card a
2210
+ // hosted install would not call is not called from its sandbox either.
2211
+ const cardProblems = new Map();
2212
+ for (const r of remotes) {
2213
+ const why = await platform.checkUntrustedUrl(r.card);
2214
+ if (why)
2215
+ cardProblems.set(r.name, why);
2216
+ }
2217
+ const stepRemotes = remotes.map((r) => {
2218
+ const refused = cardProblems.get(r.name);
2219
+ if (refused) {
2220
+ const why = `${r.name} cannot be called: ${refused}`;
2221
+ push("error", `remotes: ${why}`);
2222
+ return { ...r, unavailable: why };
2223
+ }
2224
+ const sites = r.secret ? remoteBindings[r.secret] : undefined;
2225
+ let hostname = "";
2226
+ try {
2227
+ hostname = new URL(r.card).hostname;
2228
+ }
2229
+ catch {
2230
+ // parseRemotes kept only readable URLs
2231
+ }
2232
+ if (!sites?.length || sites.some((site) => siteCovers(site, hostname)))
2233
+ return r;
2234
+ const why = `${r.name} cannot be called: its secret ${r.secret} is bound in the vault to ${sites.join(", ")}, not to ${hostname}. Bind it there too (\`foldrun secrets sites ${r.secret} …\`), or give the remote its own secret`;
2235
+ push("error", `remotes: ${why}`);
2236
+ return { ...r, unavailable: why };
2237
+ });
2238
+ if (stepRemotes.length) {
2239
+ push("info", `remotes: ${stepRemotes.map((r) => `${r.name} (${r.origin})`).join(", ")} — A2A 1.0 over JSON-RPC; only these origins, and a card naming another is refused`);
2240
+ }
2154
2241
  // One line for the clock: which zone this step works to, and which level
2155
2242
  // set it. "Why is this dated yesterday" should be answerable from the run.
2156
2243
  push("info", `clock: ${clock.timezone} (${clock.source}) — today is ${clockEnv.FOLDRUN_DATE}`);
@@ -2581,6 +2668,17 @@ mayDefer) {
2581
2668
  if (c.provider && c.secret in liveSecrets)
2582
2669
  addGrant(grant, c.secret, liveSecrets[c.secret], c.host);
2583
2670
  }
2671
+ // remotes: each secret granted to its card's host and no other — the
2672
+ // origin the owner wrote. A card cannot move the call elsewhere
2673
+ // (a2a-client.ts validateCard), so the token can only go there.
2674
+ for (const r of stepRemotes) {
2675
+ const host = remoteGrantHost(r);
2676
+ const value = r.secret ? remoteValue(r.secret) : undefined;
2677
+ if (!host || !r.secret || r.unavailable || value === undefined)
2678
+ continue;
2679
+ addGrant(grant, r.secret, value, host);
2680
+ (grant.healthKeys ??= {})[r.secret] = healthKey(r.secret, secretScopes[r.secret] === "workspace" ? "workspace" : "account", path.basename(path.resolve(agentDir, "..", "..")));
2681
+ }
2584
2682
  for (const api of apiSpecs) {
2585
2683
  const host = hostOf(api.base);
2586
2684
  if (!host)
@@ -2615,6 +2713,8 @@ mayDefer) {
2615
2713
  ...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
2616
2714
  ...Object.values(grant.secrets).flatMap((g) => g.hosts),
2617
2715
  ...Object.values(boundSites).flat(),
2716
+ // remotes: each card's own host — never one its card names.
2717
+ ...remoteNetworkHosts(stepRemotes),
2618
2718
  ],
2619
2719
  });
2620
2720
  grant.network = { level: network.level, hosts: network.hosts };
@@ -2701,7 +2801,10 @@ mayDefer) {
2701
2801
  ];
2702
2802
  const declared = Object.keys(liveSecrets);
2703
2803
  // A bound secret is never materialised, whatever the step grants.
2704
- const unbound = declared.filter((name) => !bound.includes(name));
2804
+ // A secret only remotes: use is the proxy's to fill for the card's
2805
+ // origin, or crosses inside the remote's spec when there is no proxy —
2806
+ // never as an environment variable every program in the sandbox reads.
2807
+ const unbound = declared.filter((name) => !bound.includes(name) && !remoteOnlySecrets.includes(name));
2705
2808
  const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
2706
2809
  if (lease) {
2707
2810
  const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
@@ -2741,6 +2844,20 @@ mayDefer) {
2741
2844
  const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]));
2742
2845
  for (const name of identityDeclared)
2743
2846
  delete sandboxSecrets[name];
2847
+ for (const name of remoteOnlySecrets)
2848
+ delete sandboxSecrets[name];
2849
+ // The remotes as they cross: with a lease the token is its `${NAME}`,
2850
+ // which the proxy fills for the card's host alone; without one (no
2851
+ // proxy configured) the value rides in the spec, as an API header's
2852
+ // does. A test run with no proxy carries none: it sends nothing.
2853
+ const remoteSpecs = stepRemotes.map((r) => {
2854
+ const value = r.secret ? remoteValue(r.secret) : undefined;
2855
+ if (!r.secret || r.unavailable || value === undefined)
2856
+ return r;
2857
+ if (lease)
2858
+ return { ...r, token: `\${${r.secret}}` };
2859
+ return testRun ? r : { ...r, token: value };
2860
+ });
2744
2861
  for (const name of identitiesHeld) {
2745
2862
  push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2746
2863
  }
@@ -2837,6 +2954,7 @@ mayDefer) {
2837
2954
  // a bound one goes back to its ${NAME} for the proxy to fill.
2838
2955
  mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
2839
2956
  apis: substitutedApis,
2957
+ ...(remoteSpecs.length && allowed.some((t) => A2A_TOOLS.includes(t)) ? { a2a: { remotes: remoteSpecs, runId: runId ?? null, step: stepIndex ?? null } } : {}),
2840
2958
  scripts: scriptSpecs,
2841
2959
  // The merged spec, not `front.runtime` — see the plan's `runtime`
2842
2960
  // field. A tool's own `runtime:` is part of what this step needs.
@@ -3286,6 +3404,18 @@ mayDefer) {
3286
3404
  }
3287
3405
  // @file secrets become 0600 paths here (host run), cleaned up in the
3288
3406
  // finally — once, however many attempts follow.
3407
+ // remotes: the four tools, here in this process. No proxy: the token
3408
+ // goes straight to the card's origin, made live per request.
3409
+ const a2aTools = stepRemotes.length && allowed.some((t) => A2A_TOOLS.includes(t))
3410
+ ? buildA2ATools(tenant, stepRemotes, path.basename(workspaceRoot), { env: { ...secretEnv, ...remoteSecretEnv }, missing: [] }, {
3411
+ stateDir: path.join(workspaceRoot, "state", "a2a", step.agent),
3412
+ runId: runId ?? null,
3413
+ step: stepIndex ?? null,
3414
+ test: testRun,
3415
+ keepIn: { dir: agentDir, readable: () => canReadBack(allowed) },
3416
+ emit: (type, text) => push(type, text),
3417
+ })
3418
+ : null;
3289
3419
  const mat = materializeFileSecrets(agentDir, stepSecrets);
3290
3420
  fileDir = mat.dir;
3291
3421
  // install: pip and npm land in a directory of this step's own, gone in
@@ -3329,6 +3459,7 @@ mayDefer) {
3329
3459
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3330
3460
  ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
3331
3461
  ...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
3462
+ ...(a2aTools?.server ? { foldrun_a2a: a2aTools.server } : {}),
3332
3463
  ...(installTools ? { foldrun_install: installTools.server } : {}),
3333
3464
  ...mcpServers,
3334
3465
  },
@@ -3445,6 +3576,8 @@ mayDefer) {
3445
3576
  push("info", `api: ${line}`);
3446
3577
  for (const line of scriptTools.drainLog())
3447
3578
  push("info", `script: ${line}`);
3579
+ for (const line of a2aTools?.drainLog() ?? [])
3580
+ push("info", `a2a: ${line}`);
3448
3581
  }
3449
3582
  }
3450
3583
  catch (err) {
@@ -3978,7 +4111,10 @@ export function readSchemaFile(workspaceRoot, rel) {
3978
4111
  }
3979
4112
  return null;
3980
4113
  }
3981
- export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {}) {
4114
+ export function createFlowRun(tenant, workspace, steps, flowName, status, tags = [], startedBy = null, opts = {},
4115
+ /** An A2A task's correlation (foldrun-platform flow-admission.ts) — its
4116
+ * own trailing argument, so the signature only widened. */
4117
+ a2a) {
3982
4118
  const run = {
3983
4119
  id: `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
3984
4120
  flow: flowName,
@@ -3986,6 +4122,8 @@ export function createFlowRun(tenant, workspace, steps, flowName, status, tags =
3986
4122
  status,
3987
4123
  ...(startedBy ? { startedBy: startedBy.trim().toLowerCase() } : {}),
3988
4124
  ...(opts.test ? { test: true } : {}),
4125
+ // An outside agent asked, over A2A: the task it serves, and whose key.
4126
+ ...(a2a ? { trigger: "a2a", a2a: { task: a2a.task, key: a2a.key } } : {}),
3989
4127
  startedAt: new Date().toISOString(),
3990
4128
  finishedAt: null,
3991
4129
  steps: steps.map((s) => ({
@@ -4205,8 +4343,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4205
4343
  */
4206
4344
  const save = () => {
4207
4345
  const disk = run.stopRequested ? null : readRun(tenant, workspace, run.id);
4208
- if (disk?.stopRequested)
4346
+ if (disk?.stopRequested) {
4209
4347
  run.stopRequested = true;
4348
+ // Who stopped it (an A2A caller's cancel, say) travels with the flag.
4349
+ if (disk.stoppedBy)
4350
+ run.stoppedBy ??= disk.stoppedBy;
4351
+ }
4210
4352
  if (run.stopRequested) {
4211
4353
  if (run.status !== "completed" && run.status !== "failed") {
4212
4354
  run.status = "failed";
@@ -4659,6 +4801,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4659
4801
  // this is what keeps the *next* group from starting.
4660
4802
  const stopCheck = readRun(tenant, workspace, run.id);
4661
4803
  if (stopCheck?.stopRequested) {
4804
+ if (stopCheck.stoppedBy)
4805
+ run.stoppedBy ??= stopCheck.stoppedBy;
4662
4806
  let skipped = 0;
4663
4807
  for (const s of run.steps) {
4664
4808
  if (s.status === "pending" || s.status === "running") {
@@ -4676,7 +4820,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4676
4820
  witness?.events.push({
4677
4821
  t: new Date().toISOString(),
4678
4822
  type: "info",
4679
- text: `run stopped by a person between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
4823
+ text: `run stopped by ${stoppedByText(run)} between groups — ${skipped} remaining step${skipped === 1 ? "" : "s"} skipped`,
4680
4824
  });
4681
4825
  run.status = "failed";
4682
4826
  run.stopRequested = true;
@@ -5136,7 +5280,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5136
5280
  if (run.stopRequested) {
5137
5281
  step.status = "skipped";
5138
5282
  step.skipReason = "run stopped";
5139
- step.events.push({ t: new Date().toISOString(), type: "info", text: "run stopped by a person while this step waited to start — skipped" });
5283
+ step.events.push({ t: new Date().toISOString(), type: "info", text: `run stopped by ${stoppedByText(run)} while this step waited to start — skipped` });
5140
5284
  save();
5141
5285
  gate.release(step);
5142
5286
  return;
@@ -5232,7 +5376,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5232
5376
  step.events.push({
5233
5377
  t: new Date().toISOString(),
5234
5378
  type: "info",
5235
- text: "stopped by a person mid-step — its sandbox was destroyed, which is what ended this attempt; not retried",
5379
+ text: `stopped by ${stoppedByText(run)} mid-step — its sandbox was destroyed, which is what ended this attempt; not retried`,
5236
5380
  });
5237
5381
  save();
5238
5382
  break;
package/dist/src/scan.js CHANGED
@@ -30,6 +30,9 @@ import matter, { frontmatterErrorLine, frontmatterErrorText, parseLenient, confi
30
30
  import { refNames } from "./refs.js";
31
31
  import { resolveNetwork } from "./network.js";
32
32
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
33
+ import { parseRemotes } from "./a2a-remotes.js";
34
+ import { isA2aPublished } from "./a2a-publish.js";
35
+ import { parseFlow } from "./store.js";
33
36
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
34
37
  export const SCAN_RULES = [
35
38
  {
@@ -81,6 +84,29 @@ export const SCAN_RULES = [
81
84
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
82
85
  owasp: ["ASI01", "ASI02", "ASI03"],
83
86
  },
87
+ {
88
+ // 1 Nov 2026, with A2A: a published flow's first input is whatever an
89
+ // outside caller sends. Shipped as a warning, like every new rule.
90
+ id: "security/published-flow-trifecta",
91
+ category: "security",
92
+ level: "warn",
93
+ title: "A flow published over A2A runs an agent that holds private data and can send it out",
94
+ help: "An outside agent's message is untrusted input to every step of a published flow. A step whose agent also holds a secret, " +
95
+ "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 " +
96
+ "where to send what it holds. Take a leg away from that agent (network: { level: trusted, allow: [...] }, split it, a hooks: gate), " +
97
+ "or do not publish the flow.",
98
+ owasp: ["ASI01", "ASI02", "ASI03"],
99
+ },
100
+ {
101
+ id: "security/published-flow-ungated",
102
+ category: "security",
103
+ level: "warn",
104
+ title: "A flow published over A2A acts outside the workspace with no person in the way",
105
+ help: "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 " +
106
+ "another outside agent, with no approval (`!`) or `ask:` before it or on it, does what any caller asked. Put a gate before the " +
107
+ "first such step — an approval is a person reading what it will do.",
108
+ owasp: ["ASI02", "ASI09"],
109
+ },
84
110
  {
85
111
  id: "security/subagent-wider",
86
112
  category: "security",
@@ -649,6 +675,7 @@ function docKind(p) {
649
675
  const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
650
676
  function scanAgents(agents, outward, toolDefs, push, networks) {
651
677
  const byName = new Map(agents.map((a) => [a.name, a]));
678
+ const legs = new Map();
652
679
  for (const a of agents) {
653
680
  const d = a.data;
654
681
  const tools = refNames(d.tools);
@@ -657,6 +684,11 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
657
684
  // Untrusted: it reads the open web. (A webhook body is the other source;
658
685
  // that is a flow's trigger, and the flow-level rule is a later one.)
659
686
  const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
687
+ // An outside agent's reply is untrusted too, and a message to one is a
688
+ // way out (remotes:). Its own token is not private data the agent holds:
689
+ // the proxy fills it, for that remote's origin alone.
690
+ const remotes = parseRemotes(d.remotes);
691
+ 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 ? ", …" : ""})`] : [])];
660
692
  // Private: a secret it spends itself, an API or MCP server it holds.
661
693
  const secrets = Array.isArray(d.secrets) ? d.secrets.map(String) : [];
662
694
  const apis = Array.isArray(d.apis) ? d.apis : [];
@@ -669,11 +701,13 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
669
701
  const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
670
702
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
671
703
  const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
672
- const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
704
+ const writes = [...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`), ...remotes.map((r) => `outside agent ${r.name}`)];
705
+ const outLeg = [...(openNet ? ["an open network"] : []), ...writes];
673
706
  const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
674
- if (readsWeb && privateLeg.length && outLeg.length && !gated) {
675
- push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
676
- `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`);
707
+ legs.set(a.name, { privateLeg, outLeg, writes, gated });
708
+ if (untrusted.length && privateLeg.length && outLeg.length && !gated) {
709
+ push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} ${untrusted.join(" and ")}, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
710
+ `${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`);
677
711
  }
678
712
  // Sub-agents declaring tools the parent lacks.
679
713
  for (const s of refNames(d.subagents)) {
@@ -705,6 +739,63 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
705
739
  push("supply-chain/unpinned-mcp", file, keyLine(lines, "command"), `mcp ${pkg}`, `command runs \`${pkg}\` — whatever was published last; pin a version`);
706
740
  runtimePackages(def.runtime, file, lines, push);
707
741
  }
742
+ return legs;
743
+ }
744
+ /** The agents a step may run: its own, a rescuer, the ones it delegates to. */
745
+ function stepAgents(s) {
746
+ if (s.subflow)
747
+ return [];
748
+ return [...new Set([s.agent, ...(s.onFail ? [s.onFail] : []), ...(s.delegate ?? [])].filter(Boolean))];
749
+ }
750
+ /**
751
+ * A flow with `a2a: {publish: true}` takes its first input from an outside
752
+ * caller. Two findings: a step agent the caller could steer into sending
753
+ * what it holds (the trifecta, with the caller as the untrusted leg), and a
754
+ * step that acts outside the workspace with no person before it.
755
+ */
756
+ function scanPublishedFlows(flows, legs, push) {
757
+ for (const fl of flows) {
758
+ let info;
759
+ try {
760
+ info = parseFlow(fl.file, fl.content);
761
+ }
762
+ catch {
763
+ continue; // a flow that does not parse is check's finding
764
+ }
765
+ if (!isA2aPublished(info.a2a))
766
+ continue;
767
+ const at = keyLine(fl.lines, "a2a");
768
+ const said = new Set();
769
+ for (const step of info.steps) {
770
+ for (const name of stepAgents(step)) {
771
+ const l = legs.get(name);
772
+ if (!l || said.has(name) || l.gated || !l.privateLeg.length || !l.outLeg.length)
773
+ continue;
774
+ said.add(name);
775
+ push("security/published-flow-trifecta", fl.file, step.line ?? at, `published trifecta ${name}`, `${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 ? ", …" : ""} ` +
776
+ `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`);
777
+ }
778
+ }
779
+ // Groups in order. A gate (an approval, or ask:, which is one with a
780
+ // question) on any step of a group parks the run before ANY step of that
781
+ // group launches (runner.ts driveRunInner), so it holds its parallel
782
+ // siblings as well as every group after.
783
+ let gated = false;
784
+ for (const g of [...new Set(info.steps.map((s) => s.group))].sort((a, b) => a - b)) {
785
+ const group = info.steps.filter((s) => s.group === g);
786
+ if (group.some((s) => s.approve || s.ask))
787
+ gated = true;
788
+ for (const s of group) {
789
+ if (gated)
790
+ continue;
791
+ const writes = [...new Set(stepAgents(s).flatMap((n) => legs.get(n)?.writes ?? []))];
792
+ if (!writes.length)
793
+ continue;
794
+ push("security/published-flow-ungated", fl.file, s.line ?? at, `published ungated ${g} ${s.agent}`, `step ${g} ([[${s.agent}]]) can act outside the workspace (${writes.slice(0, 2).join(", ")}${writes.length > 2 ? ", …" : ""}) and ${info.name} is published over A2A, ` +
795
+ `with no approval or ask: before it — any caller with a key starts it. Put a gate (\`${g}!.\`) on it or before it`);
796
+ }
797
+ }
798
+ }
708
799
  }
709
800
  function runtimePackages(runtime, file, lines, push) {
710
801
  if (!runtime || typeof runtime !== "object")
@@ -819,6 +910,7 @@ export function scanFiles(files, options = {}) {
819
910
  };
820
911
  const outward = new Set(options.outwardTools ?? []);
821
912
  const agents = [];
913
+ const flows = [];
822
914
  const toolDefs = new Map();
823
915
  for (const f of files.filter(isText)) {
824
916
  const lines = f.content.split(/\r?\n/);
@@ -876,6 +968,10 @@ export function scanFiles(files, options = {}) {
876
968
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
877
969
  agents.push({ name, file: f.path, line: 1, data, lines });
878
970
  }
971
+ else if (kind === "flow") {
972
+ if (!f.path.startsWith("library/"))
973
+ flows.push({ file: f.path, content: f.content, lines });
974
+ }
879
975
  else if (kind === "tool") {
880
976
  const name = typeof data.name === "string" ? data.name : f.path.replace(/^(?:library\/)?tools\//, "").replace(/(?:\/tool)?\.md$/, "");
881
977
  if (data.outward === true)
@@ -885,7 +981,8 @@ export function scanFiles(files, options = {}) {
885
981
  }
886
982
  // project.md is the older name the runtime still reads (Codex review, FOL-58).
887
983
  const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
888
- scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
984
+ const legs = scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content, "AGENTS.md")?.network : undefined });
985
+ scanPublishedFlows(flows, legs, push);
889
986
  for (const m of more.values()) {
890
987
  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 });
891
988
  }
@@ -44,6 +44,14 @@ interface StoredSecret {
44
44
  }
45
45
  type SecretsFile = Record<string, StoredSecret>;
46
46
  declare const writeBound: () => boolean;
47
+ /**
48
+ * An exclusive lock on `file` for a read-change-write, across processes and
49
+ * pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
50
+ * when older than LOCK_STALE_MS (a holder that died), waited for up to
51
+ * LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
52
+ * and any other small state file several runs write (a2a-tools' handles).
53
+ */
54
+ export declare function lockFileExclusive(file: string, busy?: string): () => void;
47
55
  /**
48
56
  * Change one vault file safely: `change` gets what is stored now and returns
49
57
  * the new contents, or null to leave the file untouched. Returns what is
@@ -275,6 +283,28 @@ ctx?: {
275
283
  * the stored value with the new refresh token. */
276
284
  onRotate?: (name: string, usedValue: string, next: string) => unknown;
277
285
  }): Promise<Record<string, string>>;
286
+ /**
287
+ * materializeSecrets for a tool that holds secrets for a whole step and
288
+ * makes them live per call (the API tools, the a2a tools), on a host with
289
+ * the vault.
290
+ *
291
+ * Two things the step's copy of a recipe cannot do alone. A provider that
292
+ * rotates refresh tokens has spent the refresh token the step started with —
293
+ * the runner materialised it at step start, and any refresh since spent its
294
+ * successor — so an oauth2 recipe is read again from the vault as it is now
295
+ * (and `env` updated in place, for the next call). And it is made live in
296
+ * the account's scope, as the runner and the egress proxy do: the cached
297
+ * access token is keyed by account, and a rotation is written back to the
298
+ * vault by the compare-and-set above. Refreshing the starting recipe outside
299
+ * that scope missed the cache and sent a spent refresh token: invalid_grant.
300
+ *
301
+ * No `scope` (inside a sandbox, where there is no vault and the values
302
+ * crossed already made live): materializeSecrets as it was.
303
+ */
304
+ export declare function materializeCurrent(env: Record<string, string>, scope?: {
305
+ tenant: string;
306
+ workspace?: string;
307
+ } | null): Promise<Record<string, string>>;
278
308
  export interface ServiceAccountConfig {
279
309
  token_url: string;
280
310
  issuer: string;
@@ -221,6 +221,16 @@ function readFile(file) {
221
221
  }
222
222
  }
223
223
  function lockVault(file) {
224
+ return lockFileExclusive(file, "the vault is busy — another change is being saved; try again");
225
+ }
226
+ /**
227
+ * An exclusive lock on `file` for a read-change-write, across processes and
228
+ * pods: `<file>.lock` opened O_EXCL (NFS 4.1 and EFS honour it), taken over
229
+ * when older than LOCK_STALE_MS (a holder that died), waited for up to
230
+ * LOCK_WAIT_MS, then `busy` is thrown. Returns the release. The vault's,
231
+ * and any other small state file several runs write (a2a-tools' handles).
232
+ */
233
+ export function lockFileExclusive(file, busy = `${path.basename(file)} is busy — another writer holds it; try again`) {
224
234
  const lock = `${file}.lock`;
225
235
  const deadline = Date.now() + LOCK_WAIT_MS;
226
236
  for (;;) {
@@ -243,7 +253,7 @@ function lockVault(file) {
243
253
  continue; // released between the open and the stat
244
254
  }
245
255
  if (Date.now() > deadline)
246
- throw new Error("the vault is busy — another change is being saved; try again");
256
+ throw new Error(busy);
247
257
  sleep(25);
248
258
  }
249
259
  }
@@ -1019,6 +1029,36 @@ ctx) {
1019
1029
  }
1020
1030
  return out;
1021
1031
  }
1032
+ /**
1033
+ * materializeSecrets for a tool that holds secrets for a whole step and
1034
+ * makes them live per call (the API tools, the a2a tools), on a host with
1035
+ * the vault.
1036
+ *
1037
+ * Two things the step's copy of a recipe cannot do alone. A provider that
1038
+ * rotates refresh tokens has spent the refresh token the step started with —
1039
+ * the runner materialised it at step start, and any refresh since spent its
1040
+ * successor — so an oauth2 recipe is read again from the vault as it is now
1041
+ * (and `env` updated in place, for the next call). And it is made live in
1042
+ * the account's scope, as the runner and the egress proxy do: the cached
1043
+ * access token is keyed by account, and a rotation is written back to the
1044
+ * vault by the compare-and-set above. Refreshing the starting recipe outside
1045
+ * that scope missed the cache and sent a spent refresh token: invalid_grant.
1046
+ *
1047
+ * No `scope` (inside a sandbox, where there is no vault and the values
1048
+ * crossed already made live): materializeSecrets as it was.
1049
+ */
1050
+ export async function materializeCurrent(env, scope) {
1051
+ if (!scope?.tenant)
1052
+ return materializeSecrets(env);
1053
+ for (const [name, value] of Object.entries(env)) {
1054
+ if (typeof value !== "string" || !isOAuth2Value(value))
1055
+ continue;
1056
+ const current = getSecret(scope.tenant, name, scope.workspace)?.value;
1057
+ if (current && isOAuth2Value(current))
1058
+ env[name] = current;
1059
+ }
1060
+ return materializeSecrets(env, { tenant: scope.tenant, ...(scope.workspace ? { workspace: scope.workspace } : {}) });
1061
+ }
1022
1062
  // ---------------------------------------------------------------- service account
1023
1063
  // Signed-key credentials: a Google service account, or anything that mints a
1024
1064
  // token by self-signing a JWT with a private key and exchanging it. No user,