@foldrun/core 0.7.0 → 1.1.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 (49) hide show
  1. package/README.md +14 -1
  2. package/dist/src/completions.js +1 -0
  3. package/dist/src/deploy.js +5 -0
  4. package/dist/src/egress.d.ts +21 -0
  5. package/dist/src/egress.js +8 -0
  6. package/dist/src/hooks.d.ts +114 -0
  7. package/dist/src/hooks.js +370 -0
  8. package/dist/src/kinds.d.ts +1 -1
  9. package/dist/src/kinds.js +26 -0
  10. package/dist/src/library.d.ts +1 -1
  11. package/dist/src/library.js +17 -1
  12. package/dist/src/mail.js +1 -1
  13. package/dist/src/network.d.ts +120 -0
  14. package/dist/src/network.js +360 -0
  15. package/dist/src/notify.d.ts +8 -0
  16. package/dist/src/notify.js +10 -1
  17. package/dist/src/providers.d.ts +5 -0
  18. package/dist/src/providers.js +21 -1
  19. package/dist/src/run-container.d.ts +13 -0
  20. package/dist/src/run-container.js +8 -1
  21. package/dist/src/runner.d.ts +1 -0
  22. package/dist/src/runner.js +144 -16
  23. package/dist/src/secrets.d.ts +20 -0
  24. package/dist/src/secrets.js +65 -1
  25. package/dist/src/starter.js +9 -1
  26. package/dist/src/step-exec.d.ts +10 -0
  27. package/dist/src/step-exec.js +113 -2
  28. package/dist/src/store.d.ts +23 -1
  29. package/dist/src/store.js +33 -1
  30. package/dist/src/tool-loop.d.ts +45 -0
  31. package/dist/src/tool-loop.js +133 -0
  32. package/package.json +19 -1
  33. package/src/completions.ts +1 -0
  34. package/src/deploy.ts +4 -0
  35. package/src/egress.ts +26 -0
  36. package/src/hooks.ts +406 -0
  37. package/src/kinds.ts +28 -0
  38. package/src/library.ts +18 -2
  39. package/src/mail.ts +1 -1
  40. package/src/network.ts +390 -0
  41. package/src/notify.ts +11 -1
  42. package/src/providers.ts +27 -1
  43. package/src/run-container.ts +16 -2
  44. package/src/runner.ts +145 -14
  45. package/src/secrets.ts +69 -1
  46. package/src/starter.ts +9 -1
  47. package/src/step-exec.ts +115 -3
  48. package/src/store.ts +39 -1
  49. package/src/tool-loop.ts +176 -0
package/src/runner.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
5
 
6
+ import { hookNames } from "./hooks.ts";
6
7
  import fs from "node:fs";
7
8
  import os from "node:os";
8
9
  import path from "node:path";
@@ -80,7 +81,8 @@ import {
80
81
  runCost,
81
82
  } from "./store.ts";
82
83
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
83
- import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.ts";
84
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
85
+ import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
84
86
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
85
87
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
86
88
  import { attachOperations, prefetchOpenApi } from "./openapi.ts";
@@ -108,6 +110,30 @@ export { localDate } from "./clock.ts";
108
110
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
109
111
  // in env and headers resolve server-side, so a credential reaches the server
110
112
  // process without ever entering the model's context.
113
+ /** Remote MCP servers with each bound secret's value put back as its
114
+ * `${NAME}`, granted to that server's host — the sandbox then holds the
115
+ * name and the proxy fills it, as for an http tool. */
116
+ function unbindMcpHeaders(
117
+ servers: Record<string, McpServerConfig>,
118
+ bound: string[],
119
+ values: Record<string, string>,
120
+ grant: EgressGrant,
121
+ ): Record<string, McpServerConfig> {
122
+ const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
123
+ const out: Record<string, McpServerConfig> = {};
124
+ for (const [name, server] of Object.entries(servers)) {
125
+ if (server.type !== "http" || !server.headers) {
126
+ out[name] = server;
127
+ continue;
128
+ }
129
+ const host = hostOf(server.url);
130
+ out[name] = host
131
+ ? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
132
+ : server;
133
+ }
134
+ return out;
135
+ }
136
+
111
137
  function mcpConfig(spec: McpSpec, tenant: string, workspace: string): McpServerConfig {
112
138
  const fill = (values: Record<string, string>) => {
113
139
  const out: Record<string, string> = {};
@@ -1341,6 +1367,7 @@ export function agentContext(
1341
1367
  ...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
1342
1368
  ...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
1343
1369
  ...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
1370
+ ...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
1344
1371
  ...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
1345
1372
  ...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
1346
1373
  };
@@ -2355,6 +2382,17 @@ async function runStep(
2355
2382
  const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
2356
2383
  const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
2357
2384
  if (limitOpts.limits) push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
2385
+ // `hooks:` — the account's, the workspace's, then the agent's own, each
2386
+ // once; resolved and run inside the step (hooks.ts, step-exec.ts).
2387
+ const stepHookNames = [...new Set([
2388
+ ...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
2389
+ ...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
2390
+ ...hookNames(front.hooks),
2391
+ ])];
2392
+ const hookOpts = stepHookNames.length
2393
+ ? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
2394
+ : {};
2395
+ if (stepHookNames.length) push("info", `hooks: ${stepHookNames.join(", ")}`);
2358
2396
 
2359
2397
  const isolation = process.env.FOLDRUN_RUN_ISOLATION;
2360
2398
  if (isolatedRun()) {
@@ -2407,7 +2445,45 @@ async function runStep(
2407
2445
  }
2408
2446
  }
2409
2447
  }
2448
+ // `network:` — which sites the sandbox may reach (network.ts). Resolved
2449
+ // before the lease, because the proxy holds the list per request. A
2450
+ // step that may fetch or browse the open web defaults to open; every
2451
+ // other to the trusted list plus what it declares.
2452
+ const workspaceName = path.basename(workspaceRootOf(agentDir));
2453
+ const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
2454
+ // Secrets bound to sites in the vault: filled by the proxy for those
2455
+ // sites alone, never put in the sandbox (egress.ts).
2456
+ const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
2457
+ const network = resolveNetwork({
2458
+ account: readAgentsMd(accountDir(tenant))?.data?.network,
2459
+ workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
2460
+ agent: front.network,
2461
+ readsWeb,
2462
+ implied: [
2463
+ ...apiSpecs.map((a) => hostOf(a.base)).filter((h): h is string => !!h),
2464
+ ...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h): h is string => !!h),
2465
+ ...Object.values(grant.secrets).flatMap((g) => g.hosts),
2466
+ ...Object.values(boundSites).flat(),
2467
+ ],
2468
+ });
2469
+ grant.network = { level: network.level, hosts: network.hosts };
2410
2470
  const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2471
+ // The forward door: every program in the sandbox sends through the
2472
+ // proxy, which holds the site list and fills bound secrets. A proxy
2473
+ // without the door (an older platform) leaves programs going direct.
2474
+ // A step that may browse gets no proxy variables: the browser in it
2475
+ // reads HTTPS_PROXY too, and Chromium cannot send credentials written
2476
+ // into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
2477
+ // (14 Oct 2026, the reel posters). Such a step is open by default,
2478
+ // and its bound secrets materialise as they did before the door.
2479
+ const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
2480
+ const forward = lease?.proxy && !browses ? lease : null;
2481
+ push("info", networkLine(network, forward ? forward.enforce === true : null));
2482
+ const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
2483
+ for (const name of bound) {
2484
+ for (const site of boundSites[name]) addGrant(grant, name, liveSecrets[name], site);
2485
+ (grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
2486
+ }
2411
2487
  // Which secrets must still be real inside the sandbox: a script tool
2412
2488
  // reads them from its environment, and unless it says `secrets:
2413
2489
  // proxied` it needs the value, not the name. bash is a script by
@@ -2419,22 +2495,27 @@ async function runStep(
2419
2495
  ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
2420
2496
  ...(allowed.includes("Bash") ? ["bash"] : []),
2421
2497
  ];
2422
- const materialise = !lease || materialisers.length > 0;
2423
2498
  const declared = Object.keys(liveSecrets);
2499
+ // A bound secret is never materialised, whatever the step grants.
2500
+ const unbound = declared.filter((name) => !bound.includes(name));
2501
+ const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
2424
2502
  if (lease) {
2503
+ const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
2425
2504
  push(
2426
2505
  "info",
2427
2506
  declared.length
2428
2507
  ? materialise
2429
- ? `secrets: ${declared.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}; api tools and the model key go through the egress proxy`
2430
- : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
2508
+ ? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
2509
+ : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
2431
2510
  : "secrets: none declared; the model key goes through the egress proxy",
2432
2511
  );
2433
2512
  }
2434
2513
  // Sandbox env for the declared secrets: the value, or its own name as
2435
2514
  // a placeholder that the proxy fills on the way out.
2436
2515
  const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
2437
- let sandboxSecrets: Record<string, string> = materialise ? liveSecrets : placeholders;
2516
+ let sandboxSecrets: Record<string, string> = materialise
2517
+ ? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
2518
+ : placeholders;
2438
2519
  // A test run hands the sandbox no send-capable value. The env var is
2439
2520
  // still there, reading TEST_MODE_WITHHELD, so a script that never
2440
2521
  // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
@@ -2442,11 +2523,13 @@ async function runStep(
2442
2523
  // has a declared sender (everything withheld) or a declared reader
2443
2524
  // (nothing withheld); see test-mode.ts for the order.
2444
2525
  if (testRun && materialise) {
2445
- const held = withholdSecrets(liveSecrets, {
2526
+ const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
2446
2527
  outward: scriptSpecs.some((sc) => sc.outward),
2447
2528
  allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2448
2529
  });
2449
- sandboxSecrets = held.env;
2530
+ // Bound secrets stay placeholders: the proxy, which holds the test
2531
+ // policy, is the only party that ever fills them.
2532
+ sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
2450
2533
  for (const name of held.withheld) {
2451
2534
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2452
2535
  }
@@ -2504,7 +2587,9 @@ async function runStep(
2504
2587
  systemPrompt,
2505
2588
  allowed,
2506
2589
  mcpNames: consultNames,
2507
- mcpServers,
2590
+ // A remote MCP server's headers had their secrets filled host-side;
2591
+ // a bound one goes back to its ${NAME} for the proxy to fill.
2592
+ mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
2508
2593
  apis: substitutedApis,
2509
2594
  scripts: scriptSpecs,
2510
2595
  // The merged spec, not `front.runtime` — see the plan's `runtime`
@@ -2521,6 +2606,7 @@ async function runStep(
2521
2606
  schema: resolvedSchema ?? undefined,
2522
2607
  maxTurns: step.maxTurns,
2523
2608
  ...limitOpts,
2609
+ ...hookOpts,
2524
2610
  translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
2525
2611
  // Outward steps keep a browser of their own: an executor that
2526
2612
  // could browse from the slim image through the account's pod
@@ -2547,6 +2633,14 @@ async function runStep(
2547
2633
  // after and carries the agent's chosen endpoint + token.
2548
2634
  ...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
2549
2635
  ...(lease ? { [EGRESS_ENV]: lease.url } : {}),
2636
+ // Every program's HTTP(S) through the proxy's forward door, and
2637
+ // the CA it signs bound sites with, for the sandbox to trust.
2638
+ ...(forward ? proxyEnv(forward.proxy!) : {}),
2639
+ // Only a step with a bound secret has a site the proxy
2640
+ // intercepts, so only that step's TLS clients are pointed at a
2641
+ // bundle with the egress CA in it; every other step's are as
2642
+ // they always were.
2643
+ ...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
2550
2644
  ...clockEnv,
2551
2645
  // Who is running: the in-process path stamps scripts with these
2552
2646
  // host-side, but a container rebuilds its tools from its own
@@ -2564,6 +2658,9 @@ async function runStep(
2564
2658
  // Stamps the sandbox, so stopping this run can destroy it.
2565
2659
  runId,
2566
2660
  size,
2661
+ // The pod's network, for the executor's own wall (the k8s tier's
2662
+ // network policy): a filtered pod reaches the proxy and DNS only.
2663
+ network: { level: network.level, enforced: forward?.enforce === true },
2567
2664
  // A test run's state/ and storage/ changes come out under the run.
2568
2665
  divert: testRun && runId ? divertFor(runId) : undefined,
2569
2666
  test: testRun,
@@ -2885,6 +2982,7 @@ async function runStep(
2885
2982
  schema: resolvedSchema ?? undefined,
2886
2983
  maxTurns: step.maxTurns,
2887
2984
  ...limitOpts,
2985
+ ...hookOpts,
2888
2986
  stopRequested,
2889
2987
  // What the scripts saw, the verify sees: a flow can then check that
2890
2988
  // the proof a step left names THIS run, not one that came before —
@@ -4543,9 +4641,12 @@ function driveRunInner(
4543
4641
  // Not after a stop: the failure a stop leaves behind is not one a
4544
4642
  // rescuer should take over. The merge in save() surfaces the flag.
4545
4643
  save();
4546
- for (const step of freshGroup) {
4547
- if (run.stopRequested) break;
4548
- if (step.status !== "failed" || !step.onFail) continue;
4644
+ // One rescue of one failed step. Also used when an evaluator loop
4645
+ // runs out of cycles below, which used to fail the run without ever
4646
+ // reaching the step's on-fail (medium-desk's claim-trimmer was
4647
+ // written for exactly that case and could never run).
4648
+ const rescueStep = async (step: StepRecord): Promise<void> => {
4649
+ if (!step.onFail) return;
4549
4650
  const rescuerDir = path.join(pDir, "agents", step.onFail);
4550
4651
  if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
4551
4652
  step.events.push({
@@ -4553,7 +4654,7 @@ function driveRunInner(
4553
4654
  type: "error",
4554
4655
  text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
4555
4656
  });
4556
- continue;
4657
+ return;
4557
4658
  }
4558
4659
  step.events.push({
4559
4660
  t: new Date().toISOString(),
@@ -4561,11 +4662,22 @@ function driveRunInner(
4561
4662
  text: `failed — handing to [[${step.onFail}]] (on-fail)`,
4562
4663
  });
4563
4664
  const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
4665
+ // A gated step's approval travels with it: the rescuer acts inside
4666
+ // the same answer the failed agent had, and must be told what it
4667
+ // was. Fixers used to dig it out of the run record, and stop when
4668
+ // they could not (15 Oct 2026).
4669
+ const approved = step.approvedAt
4670
+ ? `\nA person approved this step before it ran${step.approvalNote ? " — their answer is below" : ", with no note: a plain yes"}. Act within that approval and nothing wider.`
4671
+ : "";
4672
+ const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4564
4673
  const rescue: StepRecord = {
4565
4674
  agent: step.onFail,
4566
4675
  instruction:
4567
4676
  `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4568
- `Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
4677
+ `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4678
+ ...(step.ask ? { ask: step.ask } : {}),
4679
+ ...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
4680
+ ...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
4569
4681
  group: step.group,
4570
4682
  optional: false,
4571
4683
  model: step.model,
@@ -4611,6 +4723,11 @@ function driveRunInner(
4611
4723
  });
4612
4724
  }
4613
4725
  save();
4726
+ };
4727
+ for (const step of freshGroup) {
4728
+ if (run.stopRequested) break;
4729
+ if (step.status !== "failed" || !step.onFail) continue;
4730
+ await rescueStep(step);
4614
4731
  }
4615
4732
 
4616
4733
  // A step carried into a re-run failed in the run it came from, not
@@ -4711,7 +4828,21 @@ function driveRunInner(
4711
4828
  text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
4712
4829
  });
4713
4830
  save();
4714
- if (!looper.optional) {
4831
+ if (looper.onFail && !run.stopRequested) {
4832
+ await rescueStep(looper);
4833
+ // The rescuer owes what the loop owed: its marker.
4834
+ // rescueStep mutates the status; widen past TS's "failed" narrowing.
4835
+ if ((looper.status as string) === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
4836
+ looper.status = "failed";
4837
+ looper.events.push({
4838
+ t: new Date().toISOString(),
4839
+ type: "error",
4840
+ text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
4841
+ });
4842
+ save();
4843
+ }
4844
+ }
4845
+ if ((looper.status as string) === "failed" && !looper.optional) {
4715
4846
  run.status = "failed";
4716
4847
  break;
4717
4848
  }
package/src/secrets.ts CHANGED
@@ -42,6 +42,7 @@ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
42
42
  import crypto from "node:crypto";
43
43
  import { assertSafeName } from "./store.ts";
44
44
  import { trimSlashes } from "./paths.ts";
45
+ import { SITE } from "./network.ts";
45
46
 
46
47
  const keyFile = () => path.join(dataRoot(), ".secret-key");
47
48
 
@@ -99,6 +100,11 @@ interface StoredSecret {
99
100
  * record written before per-account keys existed is encrypted with — so an
100
101
  * old vault stays readable without a migration having to run first. */
101
102
  k?: "tenant";
103
+ /** The sites this secret is bound to (network.ts SITE shape). A bound
104
+ * secret never enters a sandbox: the egress proxy fills its `${NAME}` for
105
+ * these sites alone. Metadata, not a value, so it is stored in the clear
106
+ * beside the ciphertext and survives the value being set again. */
107
+ sites?: string[];
102
108
  }
103
109
 
104
110
  type SecretsFile = Record<string, StoredSecret>;
@@ -195,15 +201,67 @@ export function setSecret(
195
201
  if (value.length > 8192) throw new Error("secret too long");
196
202
  const sealed = encryptValue(tenant, value);
197
203
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
204
+ // A new value keeps the sites the old one was bound to: binding is a
205
+ // property of the credential's use, not of its current value, and a
206
+ // rotation that silently unbound it would put the new value in pods.
207
+ const sites = all[name]?.sites;
198
208
  all[name] = {
199
209
  ...sealed,
200
210
  updatedAt: new Date().toISOString(),
201
211
  ...(kind ? { kind } : {}),
212
+ ...(sites?.length ? { sites } : {}),
202
213
  };
203
214
  return all;
204
215
  });
205
216
  }
206
217
 
218
+ /**
219
+ * Bind a secret to sites, or unbind it (an empty list). The secret must
220
+ * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
221
+ * scheme, path or port. Returns the list as stored.
222
+ */
223
+ export function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[] {
224
+ const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
225
+ for (const site of clean) {
226
+ if (!SITE.test(site)) throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
227
+ }
228
+ let found = false;
229
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
230
+ const rec = all[name];
231
+ if (!rec) return null;
232
+ found = true;
233
+ // A file, an ssh key or an @api header set is not a value that travels
234
+ // in a header — it is unpacked inside the sandbox, so there is nothing
235
+ // for the proxy to fill.
236
+ if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
237
+ throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
238
+ }
239
+ if (clean.length) rec.sites = clean;
240
+ else delete rec.sites;
241
+ return all;
242
+ });
243
+ if (!found) throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
244
+ return clean;
245
+ }
246
+
247
+ /**
248
+ * The sites each named secret is bound to, nearest scope first — the same
249
+ * record getSecret would read the value from. A name with no binding (or
250
+ * no secret) is absent from the answer.
251
+ */
252
+ export function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]> {
253
+ const out: Record<string, string[]> = {};
254
+ const own = workspace ? read(tenant, workspace) : {};
255
+ const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
256
+ const inherited = source ? read(tenant, source) : {};
257
+ const account = read(tenant);
258
+ for (const name of names) {
259
+ const rec = own[name] ?? inherited[name] ?? account[name];
260
+ if (rec?.sites?.length) out[name] = [...rec.sites];
261
+ }
262
+ return out;
263
+ }
264
+
207
265
  /**
208
266
  * Which key writes this account's records.
209
267
  *
@@ -321,6 +379,9 @@ export interface SecretEntry {
321
379
  /** How this credential works: auto-refreshing oauth2, a signed service
322
380
  * account, a file/ssh/api materialised into runs — absent for static values. */
323
381
  kind?: SecretKind;
382
+ /** The sites it is bound to — the proxy fills it for these and no other,
383
+ * and it never enters a sandbox. Absent when unbound. */
384
+ sites?: string[];
324
385
  }
325
386
 
326
387
  // Metadata only — never values. Without a workspace this lists the account
@@ -332,6 +393,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
332
393
  updatedAt: rec.updatedAt,
333
394
  scope: "account" as const,
334
395
  ...(rec.kind ? { kind: rec.kind } : {}),
396
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
335
397
  }));
336
398
  if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
337
399
 
@@ -341,6 +403,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
341
403
  scope: "workspace" as const,
342
404
  shadowed: account.some((a) => a.name === name),
343
405
  ...(rec.kind ? { kind: rec.kind } : {}),
406
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
344
407
  }));
345
408
  const ownNames = new Set(own.map((s) => s.name));
346
409
  return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) =>
@@ -913,7 +976,12 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
913
976
  if (plain.size === 0 || unreadableHere.length) return null;
914
977
  const next: SecretsFile = { ...all };
915
978
  for (const [name, value] of plain) {
916
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
979
+ next[name] = {
980
+ ...encryptValue(tenant, value),
981
+ updatedAt: all[name].updatedAt,
982
+ ...(all[name].kind ? { kind: all[name].kind } : {}),
983
+ ...(all[name].sites?.length ? { sites: all[name].sites } : {}),
984
+ };
917
985
  }
918
986
  moved += plain.size;
919
987
  return next;
package/src/starter.ts CHANGED
@@ -279,6 +279,7 @@ only when they asked for exactly that deletion.
279
279
  | \`memory/\` | what past runs learned — agents write here themselves |
280
280
  | \`evals/\` | tests for agents, same idea as unit tests |
281
281
  | \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
282
+ | \`hooks/\` | your code at every tool call, step start or step end (\`hook.md\` + \`run.mjs\`), turned on with \`hooks:\` — refuse, rewrite, add context; same on every model |
282
283
  | \`tools/\` | a folder tool: its definition and its code together |
283
284
  | \`scripts/\` | code the agents call, referenced by \`scripts:\` |
284
285
  | \`state/\` | what runs accumulate. Yours to read, never deployed over |
@@ -310,7 +311,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
310
311
  reverted what an agent learned would make every run a little dumber.
311
312
 
312
313
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
313
- agents reference them by name.
314
+ agents reference them by name. Bind one to the sites it is for
315
+ (\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
316
+ the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
317
+ a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
318
+ the trusted list plus what it declares for every other (\`foldrun agent network\`).
314
319
 
315
320
  ## Talking to a deployed workspace
316
321
 
@@ -356,6 +361,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
356
361
  | \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
357
362
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
358
363
  | \`/vocabulary\` | GET | what this workspace's documents may say |
364
+ | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
359
365
 
360
366
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
361
367
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
@@ -455,6 +461,7 @@ size: large # small | large | heavy — the sandbox it rents
455
461
  tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
456
462
  disallowedTools: [Bash] # subtract from what it would otherwise have
457
463
  skills: [house-style] # from skills/
464
+ hooks: [no-force-push] # from hooks/ or the library: code at every tool call
458
465
  scripts: [summarise.py] # from scripts/, each becomes a callable tool
459
466
  apis: [{ name: crm }] # an HTTP API, as a tool
460
467
  mcpServers: {} # an MCP server, as a tool
@@ -462,6 +469,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
462
469
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
463
470
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
464
471
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
472
+ network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
465
473
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
466
474
  provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
467
475
  permissionMode: plan # plan first, act once approved
package/src/step-exec.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
10
 
11
+ import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
11
12
  import fs from "node:fs";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
@@ -57,6 +58,14 @@ export interface ExecOutcome {
57
58
  }
58
59
 
59
60
  export interface ExecOptions {
61
+ /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
62
+ * inside its sandbox. `hookBase` is what every hook event carries. */
63
+ hooks?: HookDef[];
64
+ /** Or the names (`hooks:` from the account, workspace and agent), resolved
65
+ * here against this step's own roots — in a sandbox those are not the
66
+ * host's paths. A name that resolves to nothing refuses the step. */
67
+ hookNames?: string[];
68
+ hookBase?: Record<string, unknown>;
60
69
  agentDir: string;
61
70
  workspaceRoot: string;
62
71
  libraryRoot: string;
@@ -158,7 +167,7 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
158
167
  export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
159
168
 
160
169
  /** The pairing fields on a tool event — see RunEvent in store.ts. */
161
- export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
170
+ export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
162
171
 
163
172
  /** The tag on every event a failed check writes — `verify:`, `output: json`,
164
173
  * a schema — headline and detail alike. The runner's no-retry rule for
@@ -310,8 +319,51 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
310
319
  // fan-out instance each start from zero.
311
320
  const counter = new CallCounter(opts.limits);
312
321
 
322
+ // Hooks (hooks.ts): the person's own code at this dispatch, same on every
323
+ // model. Each runs in the agent's directory with a bare environment.
324
+ let hooks = opts.hooks ?? [];
325
+ if (opts.hookNames?.length) {
326
+ const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
327
+ const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
328
+ if (bad.length) {
329
+ emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
330
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
331
+ }
332
+ hooks = [...hooks, ...r.hooks];
333
+ }
334
+ const hookBase = { ...(opts.hookBase ?? {}) };
335
+ const callHook = (hook: HookDef, event: Record<string, unknown>) => runHook(hook, event, { cwd: agentDir, env: opts.env });
336
+ const record = (r: HookRecord, via?: string) =>
337
+ emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
338
+
339
+ // step.start — a gate: context it returns goes in front of the task; a
340
+ // hook that fails refuses the step rather than run it unguarded.
341
+ let prompt = opts.prompt;
342
+ for (const hook of hooks.filter((h) => h.on === "step.start")) {
343
+ const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
344
+ if (!a.ok) {
345
+ record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
346
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
347
+ }
348
+ // Exit 2 is a deny on every event (runHook). On a gate that means the
349
+ // step does not run — it used to come back ok with no context, and the
350
+ // step went ahead as if the hook had agreed.
351
+ if (a.decision === "deny") {
352
+ record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
353
+ return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
354
+ }
355
+ if (a.context) {
356
+ prompt = `${prompt}\n\n${a.context}`;
357
+ record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
358
+ } else if (a.note) {
359
+ record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
360
+ }
361
+ }
362
+ const toolHooks = hooks.filter((h) => h.on === "tool.call");
363
+ const resultHooks = hooks.filter((h) => h.on === "tool.result");
364
+
313
365
  const q = runQuery({
314
- prompt: opts.prompt,
366
+ prompt,
315
367
  options: {
316
368
  cwd: agentDir,
317
369
  abortController: abort,
@@ -411,6 +463,44 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
411
463
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
412
464
  }
413
465
  }
466
+ // tool.call hooks — after foldrun's own checks, so a hook never
467
+ // sees a call that was going to be refused anyway, and handed the
468
+ // input as the tool would get it (the workspace/ prefix expanded).
469
+ if (toolHooks.length) {
470
+ const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {}) as Record<string, unknown>;
471
+ // A sub-agent's call says so: a hook that guards the step's own
472
+ // model differently from its delegates can tell them apart.
473
+ const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
474
+ for (const r of v.records) record(r, via);
475
+ const refundCall = () => { if (counter.active) counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
476
+ if (v.decision === "deny") {
477
+ refundCall();
478
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
479
+ }
480
+ if (v.input) {
481
+ // A rewritten call is answered "allow", and an allow skips
482
+ // canUseTool — where the shell and path confinement live. So
483
+ // the rewrite goes through the same checks here: a hook can
484
+ // narrow what the agent does, never widen it.
485
+ const again = hookInput.tool_name === "Bash"
486
+ ? checkBash(String(v.input.command ?? ""))
487
+ : isFilesystemTool(hookInput.tool_name)
488
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
489
+ : { ok: true as const };
490
+ const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
491
+ // The sub-agent guard read the ORIGINAL input: a rewrite of a
492
+ // delegation could otherwise swap subagent_type for one this
493
+ // step never declared (or the SDK's general-purpose).
494
+ const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
495
+ if (!again.ok || !granted || delegation) {
496
+ refundCall();
497
+ const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
498
+ emit("error", why, via ? { subagent: via } : undefined);
499
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
500
+ }
501
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: again.updatedInput ?? v.input } };
502
+ }
503
+ }
414
504
  if (!verdict?.updatedInput) return {};
415
505
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
416
506
  }],
@@ -419,11 +509,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
419
509
  // after its next tool call, as context — never as a change to what it
420
510
  // may do. A model that is only writing, calling no tools, hears it at
421
511
  // the next call it makes.
422
- ...(opts.inbox
512
+ ...(opts.inbox || resultHooks.length
423
513
  ? {
424
514
  PostToolUse: [{
425
515
  hooks: [async (hookInput: HookInput) => {
426
516
  if (hookInput.hook_event_name !== "PostToolUse") return {};
517
+ // tool.result hooks watch: the call has happened, so one
518
+ // that fails is recorded and the step goes on.
519
+ for (const hook of resultHooks) {
520
+ if (!matchesTool(hook.tool, hookInput.tool_name)) continue;
521
+ const response = (hookInput as { tool_response?: unknown }).tool_response;
522
+ const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
523
+ if (!a.ok) record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
524
+ else if (a.note) record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
525
+ }
526
+ if (!opts.inbox) return {};
427
527
  // A sub-agent's call: the message is for the step's own
428
528
  // model, and draining it here would hand it to a context
429
529
  // that ends with the delegation — and lose it.
@@ -730,6 +830,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
730
830
  "the model stream ended without a result — the model process likely died (out of memory?)",
731
831
  );
732
832
  }
833
+ // step.end hooks watch the reply, and may rewrite it. One that fails is
834
+ // recorded and the reply stands: the work it describes is done.
835
+ if (status === "completed") {
836
+ for (const hook of hooks.filter((h) => h.on === "step.end")) {
837
+ const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
838
+ if (!a.ok) record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
839
+ else if (a.reply !== undefined) {
840
+ texts.splice(0, texts.length, a.reply);
841
+ record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
842
+ } else if (a.note) record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
843
+ }
844
+ }
733
845
  const result = texts.join("\n").trim() || null;
734
846
  // A step's reply is not one message: the model narrates ("Now let me read
735
847
  // the outputs…"), calls tools, and answers last. Joining those and reading