@foldrun/core 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts CHANGED
@@ -81,7 +81,8 @@ import {
81
81
  runCost,
82
82
  } from "./store.ts";
83
83
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
84
- 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";
85
86
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
86
87
  import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
87
88
  import { attachOperations, prefetchOpenApi } from "./openapi.ts";
@@ -109,6 +110,30 @@ export { localDate } from "./clock.ts";
109
110
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
110
111
  // in env and headers resolve server-side, so a credential reaches the server
111
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
+
112
137
  function mcpConfig(spec: McpSpec, tenant: string, workspace: string): McpServerConfig {
113
138
  const fill = (values: Record<string, string>) => {
114
139
  const out: Record<string, string> = {};
@@ -2420,7 +2445,45 @@ async function runStep(
2420
2445
  }
2421
2446
  }
2422
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 };
2423
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
+ }
2424
2487
  // Which secrets must still be real inside the sandbox: a script tool
2425
2488
  // reads them from its environment, and unless it says `secrets:
2426
2489
  // proxied` it needs the value, not the name. bash is a script by
@@ -2432,22 +2495,27 @@ async function runStep(
2432
2495
  ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
2433
2496
  ...(allowed.includes("Bash") ? ["bash"] : []),
2434
2497
  ];
2435
- const materialise = !lease || materialisers.length > 0;
2436
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;
2437
2502
  if (lease) {
2503
+ const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
2438
2504
  push(
2439
2505
  "info",
2440
2506
  declared.length
2441
2507
  ? materialise
2442
- ? `secrets: ${declared.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}; api tools and the model key go through the egress proxy`
2443
- : `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})` : ""}`
2444
2510
  : "secrets: none declared; the model key goes through the egress proxy",
2445
2511
  );
2446
2512
  }
2447
2513
  // Sandbox env for the declared secrets: the value, or its own name as
2448
2514
  // a placeholder that the proxy fills on the way out.
2449
2515
  const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
2450
- 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;
2451
2519
  // A test run hands the sandbox no send-capable value. The env var is
2452
2520
  // still there, reading TEST_MODE_WITHHELD, so a script that never
2453
2521
  // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
@@ -2455,11 +2523,13 @@ async function runStep(
2455
2523
  // has a declared sender (everything withheld) or a declared reader
2456
2524
  // (nothing withheld); see test-mode.ts for the order.
2457
2525
  if (testRun && materialise) {
2458
- const held = withholdSecrets(liveSecrets, {
2526
+ const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
2459
2527
  outward: scriptSpecs.some((sc) => sc.outward),
2460
2528
  allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2461
2529
  });
2462
- 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]])) };
2463
2533
  for (const name of held.withheld) {
2464
2534
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2465
2535
  }
@@ -2517,7 +2587,9 @@ async function runStep(
2517
2587
  systemPrompt,
2518
2588
  allowed,
2519
2589
  mcpNames: consultNames,
2520
- 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,
2521
2593
  apis: substitutedApis,
2522
2594
  scripts: scriptSpecs,
2523
2595
  // The merged spec, not `front.runtime` — see the plan's `runtime`
@@ -2561,6 +2633,14 @@ async function runStep(
2561
2633
  // after and carries the agent's chosen endpoint + token.
2562
2634
  ...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
2563
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") } : {}),
2564
2644
  ...clockEnv,
2565
2645
  // Who is running: the in-process path stamps scripts with these
2566
2646
  // host-side, but a container rebuilds its tools from its own
@@ -2578,6 +2658,9 @@ async function runStep(
2578
2658
  // Stamps the sandbox, so stopping this run can destroy it.
2579
2659
  runId,
2580
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 },
2581
2664
  // A test run's state/ and storage/ changes come out under the run.
2582
2665
  divert: testRun && runId ? divertFor(runId) : undefined,
2583
2666
  test: testRun,
@@ -4558,9 +4641,12 @@ function driveRunInner(
4558
4641
  // Not after a stop: the failure a stop leaves behind is not one a
4559
4642
  // rescuer should take over. The merge in save() surfaces the flag.
4560
4643
  save();
4561
- for (const step of freshGroup) {
4562
- if (run.stopRequested) break;
4563
- 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;
4564
4650
  const rescuerDir = path.join(pDir, "agents", step.onFail);
4565
4651
  if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
4566
4652
  step.events.push({
@@ -4568,7 +4654,7 @@ function driveRunInner(
4568
4654
  type: "error",
4569
4655
  text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
4570
4656
  });
4571
- continue;
4657
+ return;
4572
4658
  }
4573
4659
  step.events.push({
4574
4660
  t: new Date().toISOString(),
@@ -4576,11 +4662,22 @@ function driveRunInner(
4576
4662
  text: `failed — handing to [[${step.onFail}]] (on-fail)`,
4577
4663
  });
4578
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}.` : "";
4579
4673
  const rescue: StepRecord = {
4580
4674
  agent: step.onFail,
4581
4675
  instruction:
4582
4676
  `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4583
- `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 } : {}),
4584
4681
  group: step.group,
4585
4682
  optional: false,
4586
4683
  model: step.model,
@@ -4626,6 +4723,11 @@ function driveRunInner(
4626
4723
  });
4627
4724
  }
4628
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);
4629
4731
  }
4630
4732
 
4631
4733
  // A step carried into a re-run failed in the run it came from, not
@@ -4726,7 +4828,21 @@ function driveRunInner(
4726
4828
  text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
4727
4829
  });
4728
4830
  save();
4729
- 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) {
4730
4846
  run.status = "failed";
4731
4847
  break;
4732
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
@@ -311,7 +311,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
311
311
  reverted what an agent learned would make every run a little dumber.
312
312
 
313
313
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
314
- 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\`).
315
319
 
316
320
  ## Talking to a deployed workspace
317
321
 
@@ -357,6 +361,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
357
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) |
358
362
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
359
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 |
360
365
 
361
366
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
362
367
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
@@ -464,6 +469,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
464
469
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
465
470
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
466
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
467
473
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
468
474
  provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
469
475
  permissionMode: plan # plan first, act once approved
package/src/store.ts CHANGED
@@ -49,7 +49,8 @@ import { refNames } from "./refs.ts";
49
49
  import { parseBudget, budgetProblem } from "./budget.ts";
50
50
  import { timezoneProblem } from "./clock.ts";
51
51
  import { parseStepLimits, readLimits } from "./limits.ts";
52
- import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
52
+ import { networkProblems, resolveNetwork, type NetworkPolicy } from "./network.ts";
53
+ import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.ts";
53
54
  import { actionProblems } from "./web-actions.ts";
54
55
  import { languageProblem } from "./language.ts";
55
56
  import { localeProblems } from "./locale.ts";
@@ -995,6 +996,15 @@ export interface AgentInfo {
995
996
  /** What is wrong with the `limits:` block's shape; key-vs-grant problems
996
997
  * need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
997
998
  limitProblems: string[];
999
+ /** What cannot be read in `network:` (network.ts) — a level that is not
1000
+ * one, a site with a scheme or a path. */
1001
+ networkProblems: string[];
1002
+ /** The sites this agent's steps may reach, resolved the way a run
1003
+ * resolves them — its `network:`, the workspace's, the account's, and
1004
+ * the default (open when it may fetch or browse the web, else trusted).
1005
+ * `implied` here is its `apis:` hosts; a run also adds its MCP servers',
1006
+ * its web providers' and its bound secrets' sites. */
1007
+ network: NetworkPolicy & { readsWeb: boolean };
998
1008
  /** Tools this agent declares inline rather than in `tools:` — `apis:`,
999
1009
  * `scripts:` and `mcpServers:` names — which a limit may also name. */
1000
1010
  inlineTools: string[];
@@ -1536,6 +1546,16 @@ function inlineToolNames(data: Record<string, unknown>): string[] {
1536
1546
  export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1537
1547
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1538
1548
  if (!fs.existsSync(dir)) return [];
1549
+ // The two levels above every agent, for the network: cascade.
1550
+ const frontOf = (file: string): Record<string, unknown> => {
1551
+ try {
1552
+ return readFrontmatter(file);
1553
+ } catch {
1554
+ return {}; // no AGENTS.md at that level, or one that does not parse
1555
+ }
1556
+ };
1557
+ const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
1558
+ const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
1539
1559
  return fs
1540
1560
  .readdirSync(dir)
1541
1561
  .filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
@@ -1543,7 +1563,20 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1543
1563
  .map((name) => {
1544
1564
  const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
1545
1565
  const tools = refNames(data.tools);
1566
+ const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
1567
+ const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
1568
+ const network = {
1569
+ ...resolveNetwork({
1570
+ account: accountFront.network,
1571
+ workspace: workspaceFront.network,
1572
+ agent: data.network,
1573
+ readsWeb,
1574
+ implied: parseApis(data.apis).map((a) => { try { return new URL(a.base).host; } catch { return ""; } }),
1575
+ }),
1576
+ readsWeb,
1577
+ };
1546
1578
  return {
1579
+ network,
1547
1580
  name,
1548
1581
  description: data.description ?? "",
1549
1582
  model: data.model ?? "default",
@@ -1567,6 +1600,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1567
1600
  webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1568
1601
  scheduleProblem: agentScheduleProblem(data),
1569
1602
  ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1603
+ networkProblems: networkProblems(data.network),
1570
1604
  inlineTools: inlineToolNames(data),
1571
1605
  };
1572
1606
  });
@@ -0,0 +1,176 @@
1
+ // A model with tools that live in this process, and nothing else.
2
+ //
3
+ // What the platform's assistant needs (and what the eval judge and the flow
4
+ // draft are a toolless special case of): one Agent SDK query whose ONLY
5
+ // tools are the ones the caller passes — no built-in tool, no file system,
6
+ // no shell, no sub-agent, no MCP server the machine happens to have — run in
7
+ // a throwaway directory, inside a turn, dollar and wall-clock ceiling.
8
+ //
9
+ // The tools run here, in the caller's process, so whatever they read they
10
+ // read with the caller's own scoping; the model only ever sees what a tool
11
+ // returns. That is why this needs no sandbox pod: nothing the model asks for
12
+ // is executed as code.
13
+
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { z } from "zod";
18
+ import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
19
+ import { hostSafeEnv } from "./host-env.ts";
20
+
21
+ /** One string argument: what it means, whether it may be left out, how long it may be. */
22
+ export interface LoopArg {
23
+ description?: string;
24
+ optional?: boolean;
25
+ max?: number;
26
+ }
27
+
28
+ export interface LoopTool {
29
+ name: string;
30
+ description: string;
31
+ args: Record<string, LoopArg>;
32
+ /** The answer the model reads. A throw becomes an error result, not a crash. */
33
+ run: (args: Record<string, string | undefined>) => Promise<string> | string;
34
+ }
35
+
36
+ export interface LoopUsage {
37
+ /** Everything sent, cache reads and writes included. */
38
+ inputTokens: number;
39
+ outputTokens: number;
40
+ costUsd: number;
41
+ }
42
+
43
+ export interface LoopResult {
44
+ /** The last turn's text — the answer, not the thinking aloud between tool calls. */
45
+ text: string;
46
+ /** What the SDK reports the query cost; 0 when it never reported (killed). */
47
+ costUsd: number;
48
+ usage: LoopUsage | null;
49
+ /** Why it ended, when it did not end well: max turns, budget, wall clock. */
50
+ stopped: string | null;
51
+ }
52
+
53
+ export interface LoopOptions {
54
+ system: string;
55
+ prompt: string;
56
+ tools: LoopTool[];
57
+ model: string;
58
+ /** The model credential and anything else the SDK process needs. */
59
+ env: Record<string, string | undefined>;
60
+ maxTurns: number;
61
+ maxBudgetUsd: number;
62
+ maxOutputTokens: number;
63
+ wallSeconds: number;
64
+ /** The reply as it is written, for a live view. */
65
+ onText?: (delta: string) => void;
66
+ }
67
+
68
+ const SERVER = "foldrun_tools";
69
+
70
+ /** The Agent SDK's name for one of our tools. */
71
+ export const loopToolName = (name: string) => `mcp__${SERVER}__${name}`;
72
+
73
+ function schemaOf(args: Record<string, LoopArg>): z.ZodRawShape {
74
+ return Object.fromEntries(
75
+ Object.entries(args).map(([name, a]) => {
76
+ let s = z.string().min(a.optional ? 0 : 1);
77
+ if (a.max) s = s.max(a.max);
78
+ if (a.description) s = s.describe(a.description);
79
+ return [name, a.optional ? s.optional() : s];
80
+ }),
81
+ );
82
+ }
83
+
84
+ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
85
+ const server = createSdkMcpServer({
86
+ name: SERVER,
87
+ version: "1.0.0",
88
+ tools: o.tools.map((t) =>
89
+ tool(t.name, t.description, schemaOf(t.args), async (args: Record<string, unknown>) => {
90
+ try {
91
+ const plain = Object.fromEntries(Object.entries(args).map(([k, v]) => [k, v === undefined ? undefined : String(v)]));
92
+ return { content: [{ type: "text" as const, text: await t.run(plain) }] };
93
+ } catch (err) {
94
+ return { content: [{ type: "text" as const, text: `error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
95
+ }
96
+ }),
97
+ ),
98
+ });
99
+ // Homeless on purpose: there are no file tools, and a throwaway cwd means
100
+ // even a confused call has no tree to touch.
101
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
102
+ const abort = new AbortController();
103
+ let killed = false;
104
+ const watchdog = setTimeout(() => {
105
+ killed = true;
106
+ abort.abort();
107
+ }, o.wallSeconds * 1000);
108
+ watchdog.unref?.();
109
+
110
+ let text = "";
111
+ let costUsd = 0;
112
+ let usage: LoopUsage | null = null;
113
+ let stopped: string | null = null;
114
+ try {
115
+ const q = query({
116
+ prompt: o.prompt,
117
+ options: {
118
+ model: o.model,
119
+ systemPrompt: o.system,
120
+ // No built-in tool at all (no Task, so no sub-agents either) — only
121
+ // ours, each pre-approved; anything else is refused unasked.
122
+ tools: [],
123
+ mcpServers: { [SERVER]: server },
124
+ strictMcpConfig: true,
125
+ allowedTools: o.tools.map((t) => loopToolName(t.name)),
126
+ permissionMode: "dontAsk",
127
+ settingSources: [],
128
+ persistSession: false,
129
+ maxTurns: o.maxTurns,
130
+ maxBudgetUsd: o.maxBudgetUsd,
131
+ includePartialMessages: true,
132
+ cwd: scratch,
133
+ abortController: abort,
134
+ env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
135
+ },
136
+ });
137
+ let turn = "";
138
+ for await (const message of q) {
139
+ if (message.type === "stream_event") {
140
+ const e = message.event;
141
+ if (e.type === "message_start") turn = "";
142
+ if (e.type === "content_block_delta" && e.delta.type === "text_delta") {
143
+ turn += e.delta.text;
144
+ o.onText?.(e.delta.text);
145
+ }
146
+ } else if (message.type === "assistant") {
147
+ const said = message.message.content
148
+ .filter((b) => b.type === "text")
149
+ .map((b) => (b as { text: string }).text)
150
+ .join("");
151
+ if (said.trim()) text = said;
152
+ } else if (message.type === "result") {
153
+ costUsd = message.total_cost_usd ?? 0;
154
+ const models = Object.values(message.modelUsage ?? {});
155
+ usage = {
156
+ inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
157
+ outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
158
+ costUsd,
159
+ };
160
+ if (message.subtype === "success") {
161
+ if (message.result?.trim()) text = message.result;
162
+ } else {
163
+ stopped = message.subtype;
164
+ if (!text) text = turn;
165
+ }
166
+ }
167
+ }
168
+ } catch (err) {
169
+ if (!killed) throw err;
170
+ } finally {
171
+ clearTimeout(watchdog);
172
+ fs.rmSync(scratch, { recursive: true, force: true });
173
+ }
174
+ if (killed) stopped = `ran past ${o.wallSeconds}s`;
175
+ return { text, costUsd, usage, stopped };
176
+ }