@foldrun/core 1.0.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.
@@ -35,7 +35,8 @@ import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacy
35
35
  import { refNames } from "./refs.js";
36
36
  import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
37
37
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
38
- import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.js";
38
+ import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
39
+ import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
39
40
  import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.js";
40
41
  import { buildApiTools, secretsUsedByApi } from "./api-tools.js";
41
42
  import { attachOperations, prefetchOpenApi } from "./openapi.js";
@@ -60,6 +61,24 @@ export { localDate } from "./clock.js";
60
61
  // An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
61
62
  // in env and headers resolve server-side, so a credential reaches the server
62
63
  // process without ever entering the model's context.
64
+ /** Remote MCP servers with each bound secret's value put back as its
65
+ * `${NAME}`, granted to that server's host — the sandbox then holds the
66
+ * name and the proxy fills it, as for an http tool. */
67
+ function unbindMcpHeaders(servers, bound, values, grant) {
68
+ const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
69
+ const out = {};
70
+ for (const [name, server] of Object.entries(servers)) {
71
+ if (server.type !== "http" || !server.headers) {
72
+ out[name] = server;
73
+ continue;
74
+ }
75
+ const host = hostOf(server.url);
76
+ out[name] = host
77
+ ? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
78
+ : server;
79
+ }
80
+ return out;
81
+ }
63
82
  function mcpConfig(spec, tenant, workspace) {
64
83
  const fill = (values) => {
65
84
  const out = {};
@@ -2231,7 +2250,46 @@ stepIndex) {
2231
2250
  }
2232
2251
  }
2233
2252
  }
2253
+ // `network:` — which sites the sandbox may reach (network.ts). Resolved
2254
+ // before the lease, because the proxy holds the list per request. A
2255
+ // step that may fetch or browse the open web defaults to open; every
2256
+ // other to the trusted list plus what it declares.
2257
+ const workspaceName = path.basename(workspaceRootOf(agentDir));
2258
+ const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
2259
+ // Secrets bound to sites in the vault: filled by the proxy for those
2260
+ // sites alone, never put in the sandbox (egress.ts).
2261
+ const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
2262
+ const network = resolveNetwork({
2263
+ account: readAgentsMd(accountDir(tenant))?.data?.network,
2264
+ workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
2265
+ agent: front.network,
2266
+ readsWeb,
2267
+ implied: [
2268
+ ...apiSpecs.map((a) => hostOf(a.base)).filter((h) => !!h),
2269
+ ...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h) => !!h),
2270
+ ...Object.values(grant.secrets).flatMap((g) => g.hosts),
2271
+ ...Object.values(boundSites).flat(),
2272
+ ],
2273
+ });
2274
+ grant.network = { level: network.level, hosts: network.hosts };
2234
2275
  const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
2276
+ // The forward door: every program in the sandbox sends through the
2277
+ // proxy, which holds the site list and fills bound secrets. A proxy
2278
+ // without the door (an older platform) leaves programs going direct.
2279
+ // A step that may browse gets no proxy variables: the browser in it
2280
+ // reads HTTPS_PROXY too, and Chromium cannot send credentials written
2281
+ // into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
2282
+ // (14 Oct 2026, the reel posters). Such a step is open by default,
2283
+ // and its bound secrets materialise as they did before the door.
2284
+ const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
2285
+ const forward = lease?.proxy && !browses ? lease : null;
2286
+ push("info", networkLine(network, forward ? forward.enforce === true : null));
2287
+ const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
2288
+ for (const name of bound) {
2289
+ for (const site of boundSites[name])
2290
+ addGrant(grant, name, liveSecrets[name], site);
2291
+ (grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
2292
+ }
2235
2293
  // Which secrets must still be real inside the sandbox: a script tool
2236
2294
  // reads them from its environment, and unless it says `secrets:
2237
2295
  // proxied` it needs the value, not the name. bash is a script by
@@ -2243,19 +2301,24 @@ stepIndex) {
2243
2301
  ...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
2244
2302
  ...(allowed.includes("Bash") ? ["bash"] : []),
2245
2303
  ];
2246
- const materialise = !lease || materialisers.length > 0;
2247
2304
  const declared = Object.keys(liveSecrets);
2305
+ // A bound secret is never materialised, whatever the step grants.
2306
+ const unbound = declared.filter((name) => !bound.includes(name));
2307
+ const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
2248
2308
  if (lease) {
2309
+ const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
2249
2310
  push("info", declared.length
2250
2311
  ? materialise
2251
- ? `secrets: ${declared.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}; api tools and the model key go through the egress proxy`
2252
- : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
2312
+ ? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
2313
+ : `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
2253
2314
  : "secrets: none declared; the model key goes through the egress proxy");
2254
2315
  }
2255
2316
  // Sandbox env for the declared secrets: the value, or its own name as
2256
2317
  // a placeholder that the proxy fills on the way out.
2257
2318
  const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
2258
- let sandboxSecrets = materialise ? liveSecrets : placeholders;
2319
+ let sandboxSecrets = materialise
2320
+ ? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
2321
+ : placeholders;
2259
2322
  // A test run hands the sandbox no send-capable value. The env var is
2260
2323
  // still there, reading TEST_MODE_WITHHELD, so a script that never
2261
2324
  // checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
@@ -2263,11 +2326,13 @@ stepIndex) {
2263
2326
  // has a declared sender (everything withheld) or a declared reader
2264
2327
  // (nothing withheld); see test-mode.ts for the order.
2265
2328
  if (testRun && materialise) {
2266
- const held = withholdSecrets(liveSecrets, {
2329
+ const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
2267
2330
  outward: scriptSpecs.some((sc) => sc.outward),
2268
2331
  allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
2269
2332
  });
2270
- sandboxSecrets = held.env;
2333
+ // Bound secrets stay placeholders: the proxy, which holds the test
2334
+ // policy, is the only party that ever fills them.
2335
+ sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
2271
2336
  for (const name of held.withheld) {
2272
2337
  push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
2273
2338
  }
@@ -2322,7 +2387,9 @@ stepIndex) {
2322
2387
  systemPrompt,
2323
2388
  allowed,
2324
2389
  mcpNames: consultNames,
2325
- mcpServers,
2390
+ // A remote MCP server's headers had their secrets filled host-side;
2391
+ // a bound one goes back to its ${NAME} for the proxy to fill.
2392
+ mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
2326
2393
  apis: substitutedApis,
2327
2394
  scripts: scriptSpecs,
2328
2395
  // The merged spec, not `front.runtime` — see the plan's `runtime`
@@ -2365,6 +2432,14 @@ stepIndex) {
2365
2432
  // after and carries the agent's chosen endpoint + token.
2366
2433
  ...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
2367
2434
  ...(lease ? { [EGRESS_ENV]: lease.url } : {}),
2435
+ // Every program's HTTP(S) through the proxy's forward door, and
2436
+ // the CA it signs bound sites with, for the sandbox to trust.
2437
+ ...(forward ? proxyEnv(forward.proxy) : {}),
2438
+ // Only a step with a bound secret has a site the proxy
2439
+ // intercepts, so only that step's TLS clients are pointed at a
2440
+ // bundle with the egress CA in it; every other step's are as
2441
+ // they always were.
2442
+ ...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
2368
2443
  ...clockEnv,
2369
2444
  // Who is running: the in-process path stamps scripts with these
2370
2445
  // host-side, but a container rebuilds its tools from its own
@@ -2381,6 +2456,9 @@ stepIndex) {
2381
2456
  // Stamps the sandbox, so stopping this run can destroy it.
2382
2457
  runId,
2383
2458
  size,
2459
+ // The pod's network, for the executor's own wall (the k8s tier's
2460
+ // network policy): a filtered pod reaches the proxy and DNS only.
2461
+ network: { level: network.level, enforced: forward?.enforce === true },
2384
2462
  // A test run's state/ and storage/ changes come out under the run.
2385
2463
  divert: testRun && runId ? divertFor(runId) : undefined,
2386
2464
  test: testRun,
@@ -4297,11 +4375,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4297
4375
  // Not after a stop: the failure a stop leaves behind is not one a
4298
4376
  // rescuer should take over. The merge in save() surfaces the flag.
4299
4377
  save();
4300
- for (const step of freshGroup) {
4301
- if (run.stopRequested)
4302
- break;
4303
- if (step.status !== "failed" || !step.onFail)
4304
- continue;
4378
+ // One rescue of one failed step. Also used when an evaluator loop
4379
+ // runs out of cycles below, which used to fail the run without ever
4380
+ // reaching the step's on-fail (medium-desk's claim-trimmer was
4381
+ // written for exactly that case and could never run).
4382
+ const rescueStep = async (step) => {
4383
+ if (!step.onFail)
4384
+ return;
4305
4385
  const rescuerDir = path.join(pDir, "agents", step.onFail);
4306
4386
  if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
4307
4387
  step.events.push({
@@ -4309,7 +4389,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4309
4389
  type: "error",
4310
4390
  text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
4311
4391
  });
4312
- continue;
4392
+ return;
4313
4393
  }
4314
4394
  step.events.push({
4315
4395
  t: new Date().toISOString(),
@@ -4317,10 +4397,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4317
4397
  text: `failed — handing to [[${step.onFail}]] (on-fail)`,
4318
4398
  });
4319
4399
  const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
4400
+ // A gated step's approval travels with it: the rescuer acts inside
4401
+ // the same answer the failed agent had, and must be told what it
4402
+ // was. Fixers used to dig it out of the run record, and stop when
4403
+ // they could not (15 Oct 2026).
4404
+ const approved = step.approvedAt
4405
+ ? `\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.`
4406
+ : "";
4407
+ const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
4320
4408
  const rescue = {
4321
4409
  agent: step.onFail,
4322
4410
  instruction: `${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
4323
- `Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
4411
+ `Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
4412
+ ...(step.ask ? { ask: step.ask } : {}),
4413
+ ...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
4414
+ ...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
4324
4415
  group: step.group,
4325
4416
  optional: false,
4326
4417
  model: step.model,
@@ -4368,6 +4459,13 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4368
4459
  });
4369
4460
  }
4370
4461
  save();
4462
+ };
4463
+ for (const step of freshGroup) {
4464
+ if (run.stopRequested)
4465
+ break;
4466
+ if (step.status !== "failed" || !step.onFail)
4467
+ continue;
4468
+ await rescueStep(step);
4371
4469
  }
4372
4470
  // A step carried into a re-run failed in the run it came from, not
4373
4471
  // here: the person chose to start after it. Counting it stopped
@@ -4467,7 +4565,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4467
4565
  text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
4468
4566
  });
4469
4567
  save();
4470
- if (!looper.optional) {
4568
+ if (looper.onFail && !run.stopRequested) {
4569
+ await rescueStep(looper);
4570
+ // The rescuer owes what the loop owed: its marker.
4571
+ // rescueStep mutates the status; widen past TS's "failed" narrowing.
4572
+ if (looper.status === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
4573
+ looper.status = "failed";
4574
+ looper.events.push({
4575
+ t: new Date().toISOString(),
4576
+ type: "error",
4577
+ text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
4578
+ });
4579
+ save();
4580
+ }
4581
+ }
4582
+ if (looper.status === "failed" && !looper.optional) {
4471
4583
  run.status = "failed";
4472
4584
  break;
4473
4585
  }
@@ -26,6 +26,11 @@ interface StoredSecret {
26
26
  * record written before per-account keys existed is encrypted with — so an
27
27
  * old vault stays readable without a migration having to run first. */
28
28
  k?: "tenant";
29
+ /** The sites this secret is bound to (network.ts SITE shape). A bound
30
+ * secret never enters a sandbox: the egress proxy fills its `${NAME}` for
31
+ * these sites alone. Metadata, not a value, so it is stored in the clear
32
+ * beside the ciphertext and survives the value being set again. */
33
+ sites?: string[];
29
34
  }
30
35
  type SecretsFile = Record<string, StoredSecret>;
31
36
  /**
@@ -36,6 +41,18 @@ type SecretsFile = Record<string, StoredSecret>;
36
41
  export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
37
42
  export declare const SECRET_NAME: RegExp;
38
43
  export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
44
+ /**
45
+ * Bind a secret to sites, or unbind it (an empty list). The secret must
46
+ * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
47
+ * scheme, path or port. Returns the list as stored.
48
+ */
49
+ export declare function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[];
50
+ /**
51
+ * The sites each named secret is bound to, nearest scope first — the same
52
+ * record getSecret would read the value from. A name with no binding (or
53
+ * no secret) is absent from the answer.
54
+ */
55
+ export declare function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]>;
39
56
  /** Encrypt one value for an account — the same envelope secrets use, exported
40
57
  * so sibling stores (OAuth clients) don't invent a second crypto path. */
41
58
  export declare function encryptValue(tenant: string, value: string): {
@@ -65,6 +82,9 @@ export interface SecretEntry {
65
82
  /** How this credential works: auto-refreshing oauth2, a signed service
66
83
  * account, a file/ssh/api materialised into runs — absent for static values. */
67
84
  kind?: SecretKind;
85
+ /** The sites it is bound to — the proxy fills it for these and no other,
86
+ * and it never enters a sandbox. Absent when unbound. */
87
+ sites?: string[];
68
88
  }
69
89
  export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
70
90
  export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
@@ -39,6 +39,7 @@ const tenantKey = (tenant) => platform.tenantKey(tenant);
39
39
  import crypto from "node:crypto";
40
40
  import { assertSafeName } from "./store.js";
41
41
  import { trimSlashes } from "./paths.js";
42
+ import { SITE } from "./network.js";
42
43
  const keyFile = () => path.join(dataRoot(), ".secret-key");
43
44
  /**
44
45
  * The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
@@ -163,14 +164,70 @@ export function setSecret(tenant, name, value, workspace, kind) {
163
164
  throw new Error("secret too long");
164
165
  const sealed = encryptValue(tenant, value);
165
166
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
167
+ // A new value keeps the sites the old one was bound to: binding is a
168
+ // property of the credential's use, not of its current value, and a
169
+ // rotation that silently unbound it would put the new value in pods.
170
+ const sites = all[name]?.sites;
166
171
  all[name] = {
167
172
  ...sealed,
168
173
  updatedAt: new Date().toISOString(),
169
174
  ...(kind ? { kind } : {}),
175
+ ...(sites?.length ? { sites } : {}),
170
176
  };
171
177
  return all;
172
178
  });
173
179
  }
180
+ /**
181
+ * Bind a secret to sites, or unbind it (an empty list). The secret must
182
+ * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
183
+ * scheme, path or port. Returns the list as stored.
184
+ */
185
+ export function setSecretSites(tenant, name, sites, workspace) {
186
+ const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
187
+ for (const site of clean) {
188
+ if (!SITE.test(site))
189
+ throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
190
+ }
191
+ let found = false;
192
+ mutateVaultFile(secretsFile(tenant, workspace), (all) => {
193
+ const rec = all[name];
194
+ if (!rec)
195
+ return null;
196
+ found = true;
197
+ // A file, an ssh key or an @api header set is not a value that travels
198
+ // in a header — it is unpacked inside the sandbox, so there is nothing
199
+ // for the proxy to fill.
200
+ if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
201
+ 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`);
202
+ }
203
+ if (clean.length)
204
+ rec.sites = clean;
205
+ else
206
+ delete rec.sites;
207
+ return all;
208
+ });
209
+ if (!found)
210
+ throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
211
+ return clean;
212
+ }
213
+ /**
214
+ * The sites each named secret is bound to, nearest scope first — the same
215
+ * record getSecret would read the value from. A name with no binding (or
216
+ * no secret) is absent from the answer.
217
+ */
218
+ export function secretSites(tenant, names, workspace) {
219
+ const out = {};
220
+ const own = workspace ? read(tenant, workspace) : {};
221
+ const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
222
+ const inherited = source ? read(tenant, source) : {};
223
+ const account = read(tenant);
224
+ for (const name of names) {
225
+ const rec = own[name] ?? inherited[name] ?? account[name];
226
+ if (rec?.sites?.length)
227
+ out[name] = [...rec.sites];
228
+ }
229
+ return out;
230
+ }
174
231
  /**
175
232
  * Which key writes this account's records.
176
233
  *
@@ -271,6 +328,7 @@ export function listSecrets(tenant, workspace) {
271
328
  updatedAt: rec.updatedAt,
272
329
  scope: "account",
273
330
  ...(rec.kind ? { kind: rec.kind } : {}),
331
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
274
332
  }));
275
333
  if (!workspace)
276
334
  return account.sort((a, b) => a.name.localeCompare(b.name));
@@ -280,6 +338,7 @@ export function listSecrets(tenant, workspace) {
280
338
  scope: "workspace",
281
339
  shadowed: account.some((a) => a.name === name),
282
340
  ...(rec.kind ? { kind: rec.kind } : {}),
341
+ ...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
283
342
  }));
284
343
  const ownNames = new Set(own.map((s) => s.name));
285
344
  return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) => a.name.localeCompare(b.name));
@@ -748,7 +807,12 @@ export function rewrapTenantSecrets(tenant) {
748
807
  return null;
749
808
  const next = { ...all };
750
809
  for (const [name, value] of plain) {
751
- next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
810
+ next[name] = {
811
+ ...encryptValue(tenant, value),
812
+ updatedAt: all[name].updatedAt,
813
+ ...(all[name].kind ? { kind: all[name].kind } : {}),
814
+ ...(all[name].sites?.length ? { sites: all[name].sites } : {}),
815
+ };
752
816
  }
753
817
  moved += plain.size;
754
818
  return next;
@@ -295,7 +295,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
295
295
  reverted what an agent learned would make every run a little dumber.
296
296
 
297
297
  Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
298
- agents reference them by name.
298
+ agents reference them by name. Bind one to the sites it is for
299
+ (\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
300
+ the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
301
+ a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
302
+ the trusted list plus what it declares for every other (\`foldrun agent network\`).
299
303
 
300
304
  ## Talking to a deployed workspace
301
305
 
@@ -341,6 +345,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
341
345
  | \`/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) |
342
346
  | \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
343
347
  | \`/vocabulary\` | GET | what this workspace's documents may say |
348
+ | \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
344
349
 
345
350
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
346
351
  \`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
@@ -448,6 +453,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
448
453
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
449
454
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
450
455
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
456
+ network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
451
457
  timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
452
458
  provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
453
459
  permissionMode: plan # plan first, act once approved
@@ -12,6 +12,7 @@ import type { OperatorEvent } from "./operator.ts";
12
12
  * the runtime, and there is nothing to fail to import.
13
13
  */
14
14
  export declare function readFrontmatter(file: string): Record<string, unknown>;
15
+ import { type NetworkPolicy } from "./network.ts";
15
16
  import { type WireFormat, type AuthShape } from "./providers.ts";
16
17
  /**
17
18
  * Single-workspace mode. When set, the workspace IS this directory — no
@@ -331,6 +332,17 @@ export interface AgentInfo {
331
332
  /** What is wrong with the `limits:` block's shape; key-vs-grant problems
332
333
  * need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
333
334
  limitProblems: string[];
335
+ /** What cannot be read in `network:` (network.ts) — a level that is not
336
+ * one, a site with a scheme or a path. */
337
+ networkProblems: string[];
338
+ /** The sites this agent's steps may reach, resolved the way a run
339
+ * resolves them — its `network:`, the workspace's, the account's, and
340
+ * the default (open when it may fetch or browse the web, else trusted).
341
+ * `implied` here is its `apis:` hosts; a run also adds its MCP servers',
342
+ * its web providers' and its bound secrets' sites. */
343
+ network: NetworkPolicy & {
344
+ readsWeb: boolean;
345
+ };
334
346
  /** Tools this agent declares inline rather than in `tools:` — `apis:`,
335
347
  * `scripts:` and `mcpServers:` names — which a limit may also name. */
336
348
  inlineTools: string[];
package/dist/src/store.js CHANGED
@@ -44,7 +44,8 @@ import { refNames } from "./refs.js";
44
44
  import { parseBudget, budgetProblem } from "./budget.js";
45
45
  import { timezoneProblem } from "./clock.js";
46
46
  import { parseStepLimits, readLimits } from "./limits.js";
47
- import { webProblems, webWarnings, runnerEngines } from "./providers.js";
47
+ import { networkProblems, resolveNetwork } from "./network.js";
48
+ import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.js";
48
49
  import { actionProblems } from "./web-actions.js";
49
50
  import { languageProblem } from "./language.js";
50
51
  import { localeProblems } from "./locale.js";
@@ -1095,6 +1096,17 @@ export function listAgents(tenant, workspace) {
1095
1096
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1096
1097
  if (!fs.existsSync(dir))
1097
1098
  return [];
1099
+ // The two levels above every agent, for the network: cascade.
1100
+ const frontOf = (file) => {
1101
+ try {
1102
+ return readFrontmatter(file);
1103
+ }
1104
+ catch {
1105
+ return {}; // no AGENTS.md at that level, or one that does not parse
1106
+ }
1107
+ };
1108
+ const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
1109
+ const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
1098
1110
  return fs
1099
1111
  .readdirSync(dir)
1100
1112
  .filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
@@ -1102,7 +1114,25 @@ export function listAgents(tenant, workspace) {
1102
1114
  .map((name) => {
1103
1115
  const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
1104
1116
  const tools = refNames(data.tools);
1117
+ const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
1118
+ const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
1119
+ const network = {
1120
+ ...resolveNetwork({
1121
+ account: accountFront.network,
1122
+ workspace: workspaceFront.network,
1123
+ agent: data.network,
1124
+ readsWeb,
1125
+ implied: parseApis(data.apis).map((a) => { try {
1126
+ return new URL(a.base).host;
1127
+ }
1128
+ catch {
1129
+ return "";
1130
+ } }),
1131
+ }),
1132
+ readsWeb,
1133
+ };
1105
1134
  return {
1135
+ network,
1106
1136
  name,
1107
1137
  description: data.description ?? "",
1108
1138
  model: data.model ?? "default",
@@ -1126,6 +1156,7 @@ export function listAgents(tenant, workspace) {
1126
1156
  webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1127
1157
  scheduleProblem: agentScheduleProblem(data),
1128
1158
  ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1159
+ networkProblems: networkProblems(data.network),
1129
1160
  inlineTools: inlineToolNames(data),
1130
1161
  };
1131
1162
  });
@@ -0,0 +1,45 @@
1
+ /** One string argument: what it means, whether it may be left out, how long it may be. */
2
+ export interface LoopArg {
3
+ description?: string;
4
+ optional?: boolean;
5
+ max?: number;
6
+ }
7
+ export interface LoopTool {
8
+ name: string;
9
+ description: string;
10
+ args: Record<string, LoopArg>;
11
+ /** The answer the model reads. A throw becomes an error result, not a crash. */
12
+ run: (args: Record<string, string | undefined>) => Promise<string> | string;
13
+ }
14
+ export interface LoopUsage {
15
+ /** Everything sent, cache reads and writes included. */
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ costUsd: number;
19
+ }
20
+ export interface LoopResult {
21
+ /** The last turn's text — the answer, not the thinking aloud between tool calls. */
22
+ text: string;
23
+ /** What the SDK reports the query cost; 0 when it never reported (killed). */
24
+ costUsd: number;
25
+ usage: LoopUsage | null;
26
+ /** Why it ended, when it did not end well: max turns, budget, wall clock. */
27
+ stopped: string | null;
28
+ }
29
+ export interface LoopOptions {
30
+ system: string;
31
+ prompt: string;
32
+ tools: LoopTool[];
33
+ model: string;
34
+ /** The model credential and anything else the SDK process needs. */
35
+ env: Record<string, string | undefined>;
36
+ maxTurns: number;
37
+ maxBudgetUsd: number;
38
+ maxOutputTokens: number;
39
+ wallSeconds: number;
40
+ /** The reply as it is written, for a live view. */
41
+ onText?: (delta: string) => void;
42
+ }
43
+ /** The Agent SDK's name for one of our tools. */
44
+ export declare const loopToolName: (name: string) => string;
45
+ export declare function runToolLoop(o: LoopOptions): Promise<LoopResult>;