@foldrun/core 2.14.3 → 3.0.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.
@@ -2824,6 +2824,8 @@ mayDefer) {
2824
2824
  tenant,
2825
2825
  input: {
2826
2826
  agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
2827
+ // Its file tools inside the sandbox get the same allowance (FOL-96).
2828
+ configWrites: step.approve === true && Boolean(step.approvedAt),
2827
2829
  prompt,
2828
2830
  model,
2829
2831
  effort,
@@ -2913,6 +2915,11 @@ mayDefer) {
2913
2915
  network: { level: network.level, enforced: forward?.enforce === true },
2914
2916
  // A test run's state/ and storage/ changes come out under the run.
2915
2917
  divert: testRun && runId ? divertFor(runId) : undefined,
2918
+ // Read at copy-out: what the host handed back into the sandbox.
2919
+ handedBack: () => step.handedBack,
2920
+ // A person approved this step before it ran: configuration and code
2921
+ // it changes are kept, listed on the trail (FOL-96).
2922
+ configWrites: step.approve === true && Boolean(step.approvedAt),
2916
2923
  test: testRun,
2917
2924
  // Resume: a sandbox a previous driver left running, recorded on the
2918
2925
  // step. Consumed once — a fresh attempt after this one (a retry, a
@@ -3120,6 +3127,12 @@ mayDefer) {
3120
3127
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3121
3128
  if (step.subagentOf && outcome.applied?.length)
3122
3129
  step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
3130
+ if (outcome.refused?.length) {
3131
+ const seen = new Map((step.refused ?? []).map((r) => [r.path, r]));
3132
+ for (const r of outcome.refused)
3133
+ seen.set(r.path, r);
3134
+ step.refused = [...seen.values()];
3135
+ }
3123
3136
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
3124
3137
  const decision = podLossDecision(outcome.browserPod);
3125
3138
  if (decision.rerun) {
@@ -3291,6 +3304,9 @@ mayDefer) {
3291
3304
  const attemptInProcess = (modelEnv) => executeStep({
3292
3305
  agentDir,
3293
3306
  workspaceRoot,
3307
+ // A person approved this step (its gate) before it ran: it may change
3308
+ // configuration and code (FOL-96) — the desks' retro applies fixes so.
3309
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3294
3310
  libraryRoot: libraryDir(tenant),
3295
3311
  ...(subagents.length ? { subagents } : {}),
3296
3312
  // The colleagues' inbox, read after every tool call — the same hook
@@ -4991,6 +5007,22 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4991
5007
  // Its files go back with the reply, so the delegating step's
4992
5008
  // sandbox has them too (delegate-tools.ts copyBackFiles).
4993
5009
  const back = readCopyBack(pDir, child.wrote ?? []);
5010
+ // What goes back into the parent's sandbox, as the host sends it:
5011
+ // its copy-out reads this, not the sandbox's own note (FOL-96).
5012
+ if (back.files.length) {
5013
+ parent.handedBack = { ...(parent.handedBack ?? {}) };
5014
+ for (const f of back.files) {
5015
+ const h = crypto.createHash("sha256").update(Buffer.from(f.base64, "base64")).digest("hex");
5016
+ // Kept beside any earlier job's for the same path, never over
5017
+ // it: either may be what the sandbox ends up holding.
5018
+ const prev = parent.handedBack[f.path];
5019
+ const all = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev];
5020
+ if (!all.includes(h))
5021
+ all.push(h);
5022
+ parent.handedBack[f.path] = all.length === 1 ? all[0] : all;
5023
+ }
5024
+ save();
5025
+ }
4994
5026
  return {
4995
5027
  status,
4996
5028
  result: child.result ?? (errors || "(no reply)"),
@@ -57,6 +57,9 @@ export interface ExecOutcome {
57
57
  browserPod?: BrowserPodOutcome;
58
58
  }
59
59
  export interface ExecOptions {
60
+ /** A person approved this step before it ran: its file tools may write
61
+ * the workspace's configuration and code (confine.ts, FOL-96). */
62
+ configWrites?: boolean;
60
63
  /** What the step reports beyond its events (FOL-54 step 4): `metadata`
61
64
  * sends each model call (`turn`); `full` also sends each tool call's input
62
65
  * and output (`io`). Unset sends neither — the consult, the probe and any
@@ -315,7 +315,7 @@ async function executeStepInner(opts, runQuery) {
315
315
  }
316
316
  }
317
317
  const verdict = isFilesystemTool(hookInput.tool_name)
318
- ? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot })
318
+ ? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
319
319
  : null;
320
320
  if (verdict && !verdict.ok) {
321
321
  emit("error", verdict.reason, via ? { subagent: via } : undefined);
@@ -357,7 +357,7 @@ async function executeStepInner(opts, runQuery) {
357
357
  const again = hookInput.tool_name === "Bash"
358
358
  ? checkBash(String(v.input.command ?? ""))
359
359
  : isFilesystemTool(hookInput.tool_name)
360
- ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
360
+ ? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
361
361
  : { ok: true };
362
362
  const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
363
363
  // The sub-agent guard read the ORIGINAL input: a rewrite of a
@@ -432,7 +432,7 @@ async function executeStepInner(opts, runQuery) {
432
432
  const verdict = toolName === "Bash"
433
433
  ? checkBash(String(input.command ?? ""))
434
434
  : isFilesystemTool(toolName)
435
- ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
435
+ ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot, configWrites: opts.configWrites })
436
436
  : { ok: true };
437
437
  if (!verdict.ok) {
438
438
  refund();
@@ -1294,6 +1294,17 @@ export interface StepRecord {
1294
1294
  /** On an isolated sub-agent's run: the workspace paths its sandbox's
1295
1295
  * changes were applied to — what its delegating step is told it wrote. */
1296
1296
  wrote?: string[];
1297
+ /** What this step's sandbox changed that was not taken back to the
1298
+ * workspace: configuration and code, which a step proposes and a person
1299
+ * applies (copy-back.ts, FOL-96). */
1300
+ refused?: {
1301
+ path: string;
1302
+ kind: "config" | "code";
1303
+ }[];
1304
+ /** What the host handed back into this step's sandbox mid-step — its
1305
+ * `sandbox: own` jobs' files, path → sha256 — recorded HERE, on the host,
1306
+ * so the copy-out never has to take the sandbox's word for it (FOL-96). */
1307
+ handedBack?: Record<string, string | string[]>;
1297
1308
  /**
1298
1309
  * Sandbox seconds this step rented, and how many of them were cold start.
1299
1310
  * Set only on the isolated path — an in-process step rents nothing, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.14.3",
3
+ "version": "3.0.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
package/src/confine.ts CHANGED
@@ -30,6 +30,7 @@
30
30
 
31
31
  import fs from "node:fs";
32
32
  import path from "node:path";
33
+ import { refusedBack } from "./copy-back.ts";
33
34
 
34
35
  /**
35
36
  * A path with its symlinks resolved, as far as it exists.
@@ -97,6 +98,9 @@ export interface Roots {
97
98
  workspaceRoot: string;
98
99
  /** The account library: readable, never writable. */
99
100
  libraryRoot: string;
101
+ /** A person approved this step before it ran (an approval gate): its
102
+ * writes to the workspace's configuration and code are allowed. */
103
+ configWrites?: boolean;
100
104
  }
101
105
 
102
106
  // Skills are announced to the agent with virtual prefixes — `workspace/skills/…`
@@ -322,6 +326,21 @@ export function checkPaths(
322
326
  };
323
327
  }
324
328
 
329
+ // Configuration and code are a person's: the same list a sandbox's
330
+ // copy-back refuses (copy-back.ts, FOL-96), so a step that runs on this
331
+ // host, with no sandbox between it and the files, is held to the same
332
+ // rule by its file tools. Its shell is not confined here — isolation is.
333
+ const asConfig = isWrite && !inLibrary && !roots.configWrites ? refusedBack(within) : null;
334
+ if (asConfig) {
335
+ return {
336
+ ok: false,
337
+ reason:
338
+ `${toolName} was denied: "${raw}" is this workspace's ${asConfig === "config" ? "configuration" : "code"}, ` +
339
+ `which a run does not change. Say what should change and why in your answer — a person ` +
340
+ `applies it (or it becomes a Proposed fix). Your own notes go to outputs/ or workspace/memory/.`,
341
+ };
342
+ }
343
+
325
344
  // Hand the real path back to the tool, so the virtual prefix the prompt
326
345
  // advertises is one the agent can actually open — unless the step's
327
346
  // `workspace` link already opens it. Then the path is left as written,
@@ -0,0 +1,87 @@
1
+ // What a run's sandbox may hand back to the workspace (FOL-96, 27 Oct 2026).
2
+ //
3
+ // A step's file changes come back from its sandbox (run-container.ts
4
+ // applyContainerChanges — the docker and the k8s runner both), and until now
5
+ // everything came back that was not platform-owned, knowledge or hooks. So a
6
+ // step steered by a page it read could rewrite AGENTS.md, an agent.md or a
7
+ // flow — its own tools:, network:, a provider: block, a new scheduled flow —
8
+ // and every later run, the next step of the same run included, ran on it.
9
+ //
10
+ // Configuration and code are a person's to change. A step that wants a
11
+ // change proposes it (Proposed fixes, the assistant), and a person applies
12
+ // it; a step that writes one anyway has the write refused, and the run's
13
+ // trail names it. What steps legitimately write — state/, memory/, storage/,
14
+ // an agent's own outputs — comes back as before.
15
+ //
16
+ // The list is not written here. It is read off the kinds (kinds.ts), whose
17
+ // every row must say what its files are, so a kind added later is classified
18
+ // the moment it exists — tsc refuses a row without `copyBack`.
19
+
20
+ import { KINDS, ALL_KINDS, SUBAGENT_IMPORT_DIRS, WORKSPACE_CONFIG_FILES, type CopyBackClass, type Kind } from "./kinds.ts";
21
+
22
+ /** A path a step changed that was not taken back, and why. */
23
+ export interface RefusedPath {
24
+ path: string;
25
+ kind: "config" | "code";
26
+ }
27
+
28
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ const NAME = "\u0000";
30
+
31
+ /** Where an agent's own folder is — the scope agent-level kinds live under. */
32
+ const AGENT_SCOPE = KINDS.agents.file(NAME).split(NAME)[0]; // "agents/"
33
+
34
+ /** The path shapes one kind's files take, inside a workspace. */
35
+ export function kindPatterns(kind: Kind): RegExp[] {
36
+ const meta = KINDS[kind];
37
+ const file = meta.file(NAME);
38
+ // An agent's folder is a scope as well as a document: everything else in
39
+ // it is the agent's working directory. Its document is the one file.
40
+ if (kind === "agents") {
41
+ const [head, tail] = file.split(NAME);
42
+ return [new RegExp(`^${escape(head)}[^/]+${escape(tail)}$`)];
43
+ }
44
+ // Every other kind owns its top-level folder — a file document's siblings
45
+ // (a tool's script beside its .md) and a folder document's contents alike.
46
+ const root = file.split("/")[0];
47
+ const out = [new RegExp(`^${escape(root)}/`)];
48
+ if (meta.scopes.includes("agent")) out.push(new RegExp(`^${escape(AGENT_SCOPE)}[^/]+/${escape(root)}/`));
49
+ return out;
50
+ }
51
+
52
+ const RULES: { re: RegExp; kind: "config" | "code" }[] = [
53
+ ...WORKSPACE_CONFIG_FILES.map((f) => ({ re: new RegExp(`^${escape(f)}$`), kind: "config" as const })),
54
+ // Single-file subagents another tool wrote: a deploy imports them as agents.
55
+ ...[...new Set(SUBAGENT_IMPORT_DIRS.map((d) => d.split("/")[0]))].map((d) => ({ re: new RegExp(`^${escape(d)}/`), kind: "config" as const })),
56
+ ...ALL_KINDS.flatMap((k) => {
57
+ const cls: CopyBackClass = KINDS[k].copyBack;
58
+ return cls === "data" ? [] : kindPatterns(k).map((re) => ({ re, kind: cls }));
59
+ }),
60
+ ];
61
+
62
+ /** Whether a step's write to `rel` (workspace-relative) is refused on the way
63
+ * back, and as what; null when it is data and may come back. */
64
+ export function refusedBack(rel: string): "config" | "code" | null {
65
+ const norm = rel.replaceAll("\\", "/").replace(/^\.\//, "");
66
+ for (const r of RULES) if (r.re.test(norm)) return r.kind;
67
+ return null;
68
+ }
69
+
70
+ /** The trail's line for a step's refused writes. */
71
+ /** The trail's line for config and code a person-approved step changed and
72
+ * that were kept (an approval gate before the step: the person read what
73
+ * it would do — the desks' retro applies approved fixes this way). */
74
+ export function keptLine(kept: readonly RefusedPath[]): string {
75
+ const shown = kept.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
76
+ const more = kept.length > 20 ? ` and ${kept.length - 20} more` : "";
77
+ return `kept with approval: ${shown}${more} — configuration or code this step changed, kept because a person approved the step before it ran`;
78
+ }
79
+
80
+ export function refusedLine(refused: readonly RefusedPath[]): string {
81
+ const shown = refused.slice(0, 20).map((r) => `${r.path} (${r.kind})`).join(", ");
82
+ const more = refused.length > 20 ? ` and ${refused.length - 20} more` : "";
83
+ return (
84
+ `refused path: ${shown}${more} — a step cannot change the workspace's configuration or code; ` +
85
+ `the workspace keeps its own copy. Propose the change instead (Proposed fixes), and a person applies it.`
86
+ );
87
+ }
package/src/deploy.ts CHANGED
@@ -41,6 +41,7 @@ import { libraryTools } from "./library.ts";
41
41
  import { lintFlow, regexProblem } from "./flow-lint.ts";
42
42
  import { parseEval } from "./evals.ts";
43
43
  import { refNames } from "./refs.ts";
44
+ import { SUBAGENT_IMPORT_DIRS } from "./kinds.ts";
44
45
  import matter, { configMatter } from "./matter.ts";
45
46
  import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
46
47
  import { conformanceIssues } from "./okf.ts";
@@ -123,12 +124,9 @@ export interface DeployResult extends DeployPlan {
123
124
  * skipped rather than rejected, because a workspace living in a repo alongside
124
125
  * other things is the normal case, not an error.
125
126
  */
126
- // Where single-file subagents authored by other tools are found, in
127
- // precedence order: the vendor-neutral cross-client location first, then any
128
- // tool-specific one for pragmatic compatibility. Extend the tail, never the
129
- // head. Shared by the deploy import (readTree) and the CLI checker so the two
130
- // agree on what counts as an agent.
131
- export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
127
+ // Where single-file subagents authored by other tools are found — declared
128
+ // with the kinds (kinds.ts), which the copy-back rule reads too.
129
+ export { SUBAGENT_IMPORT_DIRS };
132
130
 
133
131
  /** What agents write as they run, not what anyone authored: state/ and
134
132
  * memory/, the workspace's or one agent's. */
package/src/kinds.ts CHANGED
@@ -55,6 +55,20 @@ export type Kind =
55
55
  // question each answers rather than the alphabet, and a consistency test
56
56
  // holds the sidebar and the asset pages to it.
57
57
 
58
+ /** How a run's write to a kind's files is treated on the way back. */
59
+ export type CopyBackClass = "config" | "code" | "data";
60
+
61
+ /** Workspace files that are configuration without being a kind: the shared
62
+ * context and its pre-rename name. */
63
+ export const WORKSPACE_CONFIG_FILES = ["AGENTS.md", "project.md"] as const;
64
+
65
+ // Where single-file subagents authored by other tools are found, in
66
+ // precedence order: the vendor-neutral cross-client location first, then any
67
+ // tool-specific one for pragmatic compatibility. Extend the tail, never the
68
+ // head. Shared by the deploy import (deploy.ts readTree), the CLI checker
69
+ // and the copy-back rule, so all three agree on what counts as an agent.
70
+ export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
71
+
58
72
  export interface KindMeta {
59
73
  kind: Kind;
60
74
  /** Singular noun, for buttons and prose. */
@@ -77,6 +91,14 @@ export interface KindMeta {
77
91
  hint: string;
78
92
  /** Path relative to the scope root. */
79
93
  file: (name: string) => string;
94
+ /**
95
+ * What a run's sandbox writing one of these means (copy-back.ts):
96
+ * `config` grants or changes what runs and how, `code` is something a
97
+ * later step executes — neither is ever taken back from a step, which
98
+ * proposes the change instead — and `data` is what steps legitimately
99
+ * write. Required, so a new kind cannot become copy-back-able unnoticed.
100
+ */
101
+ copyBack: CopyBackClass;
80
102
  /** Starting content. */
81
103
  template: (name: string, ctx?: TemplateContext) => string;
82
104
  }
@@ -105,6 +127,7 @@ const titleCase = (slug: string) => {
105
127
  export const KINDS: Record<Kind, KindMeta> = {
106
128
  agents: {
107
129
  kind: "agents",
130
+ copyBack: "config",
108
131
  one: "agent",
109
132
  docType: null,
110
133
  docKey: null,
@@ -129,6 +152,7 @@ this text is the whole of the agent's instructions.
129
152
 
130
153
  evals: {
131
154
  kind: "evals",
155
+ copyBack: "config",
132
156
  one: "eval",
133
157
  docType: null,
134
158
  docKey: null,
@@ -152,6 +176,7 @@ expect:
152
176
 
153
177
  flows: {
154
178
  kind: "flows",
179
+ copyBack: "config",
155
180
  one: "flow",
156
181
  docType: null,
157
182
  docKey: null,
@@ -171,6 +196,7 @@ trigger: manual
171
196
 
172
197
  tools: {
173
198
  kind: "tools",
199
+ copyBack: "config",
174
200
  one: "tool",
175
201
  docType: null,
176
202
  docKey: null,
@@ -198,6 +224,7 @@ tools: [${name}]
198
224
 
199
225
  hooks: {
200
226
  kind: "hooks",
227
+ copyBack: "code",
201
228
  one: "hook",
202
229
  docType: null,
203
230
  docKey: null,
@@ -228,6 +255,7 @@ it reads the event as JSON on stdin and prints one JSON answer —
228
255
  // a decision, so the sidebar has no door for it.
229
256
  knowledge: {
230
257
  kind: "knowledge",
258
+ copyBack: "data",
231
259
  one: "knowledge doc",
232
260
  docType: "Reference",
233
261
  // OKF's field and OKF's vocabulary — `Reference` is only the starting point.
@@ -250,6 +278,7 @@ The reference content. Agents read this and never rewrite it.
250
278
 
251
279
  memory: {
252
280
  kind: "memory",
281
+ copyBack: "data",
253
282
  one: "memory",
254
283
  docType: "Fact",
255
284
  // OKF's field and OKF's vocabulary — `Fact` is only the starting point.
@@ -277,6 +306,7 @@ The fact.
277
306
 
278
307
  skills: {
279
308
  kind: "skills",
309
+ copyBack: "config",
280
310
  one: "skill",
281
311
  docType: null,
282
312
  docKey: null,
@@ -297,6 +327,7 @@ Step-by-step instructions. Bundle code in this folder's scripts/ if it needs any
297
327
 
298
328
  scripts: {
299
329
  kind: "scripts",
330
+ copyBack: "code",
300
331
  one: "script",
301
332
  // Code, not a document — no frontmatter to carry either field. A tool
302
333
  // file points at it.
@@ -29,6 +29,7 @@ import { spawn, spawnSync } from "node:child_process";
29
29
  import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
30
30
  import { CAPTURE_CAP, isPlatformPath, type ApiSpec, type Effort, type ModelCall } from "./store.ts";
31
31
  import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.ts";
32
+ import { refusedBack, refusedLine, keptLine, type RefusedPath } from "./copy-back.ts";
32
33
  import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
33
34
  import type { TokenPrice } from "./catalog.ts";
34
35
  import { isFileValue, fileContent } from "./secrets.ts";
@@ -47,6 +48,9 @@ import { parseBrowserPod, type BrowserPodOutcome } from "./browser-pod.ts";
47
48
  * secrets in headers, assembled prompt, serializable MCP configs. */
48
49
  export interface ContainerStepInput {
49
50
  agentRel: string; // "agents/<name>" inside the workspace
51
+ /** A person approved this step before it ran: its file tools may write
52
+ * configuration and code inside the sandbox too (FOL-96). */
53
+ configWrites?: boolean;
50
54
  prompt: string;
51
55
  model: string;
52
56
  effort?: Effort | null;
@@ -229,6 +233,9 @@ export interface ContainerStepOutcome {
229
233
  * applyContainerChanges returned. An isolated sub-agent's reply names
230
234
  * them to the step that delegated (delegate-tools.ts). */
231
235
  applied?: string[];
236
+ /** What the step changed that was not taken back: configuration and code
237
+ * (copy-back.ts). On the step record and in the trail. */
238
+ refused?: RefusedPath[];
232
239
  }
233
240
 
234
241
  /**
@@ -333,6 +340,45 @@ export function hashTree(dir: string): Record<string, string> {
333
340
  return out;
334
341
  }
335
342
 
343
+ /**
344
+ * Where the host keeps what a sandbox was handed (hashTree), for a driver
345
+ * that inherits the step: under the run's own folder, which is platform-
346
+ * owned — never staged into a sandbox, never copied back, erased with the
347
+ * run (deleteRun). It used to ride into the pod beside the input and be
348
+ * read back from there, where the step could rewrite it (FOL-96).
349
+ */
350
+ export function handedInPath(workspaceRoot: string, runId: string, ref: string): string | null {
351
+ const safe = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
352
+ if (!safe.test(runId) || !safe.test(ref)) return null;
353
+ return path.join(workspaceRoot, "runs", runId, "sandbox", `${ref}.json`);
354
+ }
355
+
356
+ /** Note what a sandbox was handed, on the host. */
357
+ export function writeHandedIn(workspaceRoot: string, runId: string, ref: string, hashes: Record<string, string>): void {
358
+ const file = handedInPath(workspaceRoot, runId, ref);
359
+ if (!file) return;
360
+ fs.mkdirSync(path.dirname(file), { recursive: true });
361
+ fs.writeFileSync(file, JSON.stringify(hashes));
362
+ }
363
+
364
+ /** What the host noted a sandbox was handed, or undefined. */
365
+ export function readHandedIn(workspaceRoot: string, runId: string, ref: string): Record<string, string> | undefined {
366
+ const file = handedInPath(workspaceRoot, runId, ref);
367
+ if (!file) return undefined;
368
+ try {
369
+ const m = JSON.parse(fs.readFileSync(file, "utf8"));
370
+ return m && typeof m === "object" && !Array.isArray(m) ? (m as Record<string, string>) : undefined;
371
+ } catch {
372
+ return undefined;
373
+ }
374
+ }
375
+
376
+ /** Forget it once the step's copy-out is done. */
377
+ export function dropHandedIn(workspaceRoot: string, runId: string, ref: string): void {
378
+ const file = handedInPath(workspaceRoot, runId, ref);
379
+ if (file) fs.rmSync(file, { force: true });
380
+ }
381
+
336
382
  /**
337
383
  * Every file's mtime under `dir`, relative path to milliseconds — so a copy
338
384
  * that cannot carry times can have them put back. `kubectl cp` unpacks with
@@ -412,12 +458,28 @@ export function mergeAppends(was: Buffer | string | null, host: Buffer, next: Bu
412
458
  return Buffer.from(h.slice(0, end) + joined + stepAdded, "utf8");
413
459
  }
414
460
 
461
+ export interface ApplyOptions {
462
+ /** What the host handed back into this sandbox mid-step — a `sandbox: own`
463
+ * job's files (delegate-tools.ts), path → sha256 — as the HOST recorded
464
+ * it (StepRecord.handedBack). The sandbox's own note of the same is only
465
+ * believed where it agrees with this: a step can write that note. */
466
+ handedBack?: Record<string, string | string[]>;
467
+ /** Told of every path the step changed that was not taken back because it
468
+ * is configuration or code (copy-back.ts). */
469
+ onRefused?: (refused: RefusedPath) => void;
470
+ /** A person approved the step before it ran: configuration and code it
471
+ * changed come back, each told to onKept instead of refused. */
472
+ allowConfig?: boolean;
473
+ onKept?: (kept: RefusedPath) => void;
474
+ }
475
+
415
476
  export function applyContainerChanges(
416
477
  hostWs: string,
417
478
  containerWs: string,
418
479
  baseline?: string | Record<string, string>,
419
480
  note?: (message: string) => void,
420
481
  divert?: WriteDivert,
482
+ opts: ApplyOptions = {},
421
483
  ): string[] {
422
484
  const applied: string[] = [];
423
485
  // Files a sandbox: own job's reply copied into this sandbox mid-step
@@ -425,6 +487,10 @@ export function applyContainerChanges(
425
487
  // those, that is what the step started from. The job's change is already on
426
488
  // the workspace; read against the older baseline it came back as the
427
489
  // step's own and was merged in a second time (Codex review, 23 Oct 2026).
490
+ // The sandbox notes which it actually wrote (a conflict goes beside the
491
+ // file instead), but the step can write that note too, so an entry counts
492
+ // only where the host handed exactly those bytes back (FOL-96).
493
+ const handedBack = opts.handedBack ?? {};
428
494
  let imported: Record<string, string> = {};
429
495
  try {
430
496
  // Read here, on the host: a link the step planted is never followed,
@@ -433,7 +499,15 @@ export function applyContainerChanges(
433
499
  const file = path.join(dir, "copied-back.json");
434
500
  if (fs.lstatSync(dir).isDirectory() && fs.lstatSync(file).isFile()) {
435
501
  const m = JSON.parse(fs.readFileSync(file, "utf8"));
436
- if (m && typeof m === "object" && !Array.isArray(m)) imported = m as Record<string, string>;
502
+ if (m && typeof m === "object" && !Array.isArray(m)) {
503
+ for (const [rel, h] of Object.entries(m as Record<string, unknown>)) {
504
+ // Every version the host handed back for the path counts: two jobs
505
+ // can return the same file, and the sandbox may hold the first
506
+ // (the second went beside a parent edit as a conflict copy).
507
+ const ok = handedBack[rel];
508
+ if (typeof h === "string" && (Array.isArray(ok) ? ok.includes(h) : ok === h)) imported[rel] = h;
509
+ }
510
+ }
437
511
  }
438
512
  } catch {
439
513
  /* none copied back */
@@ -474,6 +548,17 @@ export function applyContainerChanges(
474
548
  const target = path.join(hostWs, rel);
475
549
  const hostNow = fs.existsSync(target) ? fs.readFileSync(target) : null;
476
550
  if (hostNow && hostNow.equals(next)) continue;
551
+ // Configuration and code are a person's (copy-back.ts): refused here,
552
+ // after "did the step change it" and before any merge, conflict copy
553
+ // or test divert — so none of those can carry it to the workspace,
554
+ // and the next step is staged from the workspace's own copy.
555
+ const refused = refusedBack(rel);
556
+ if (refused && opts.allowConfig) {
557
+ opts.onKept?.({ path: rel.replaceAll("\\", "/"), kind: refused });
558
+ } else if (refused) {
559
+ opts.onRefused?.({ path: rel.replaceAll("\\", "/"), kind: refused });
560
+ continue;
561
+ }
477
562
  // The host moved too since the step was handed its copy — another run
478
563
  // or a person wrote it. Writing the step's copy over it erased the
479
564
  // other run's ledger rows on 2026-09-14. Merge appends; keep both
@@ -806,6 +891,7 @@ try {
806
891
  agentDir,
807
892
  workspaceRoot: "/workspace",
808
893
  libraryRoot: "/library",
894
+ configWrites: input.configWrites === true,
809
895
  prompt: input.prompt,
810
896
  model: input.model,
811
897
  effort: input.effort ?? null,
@@ -1491,6 +1577,9 @@ export function heldCores(cpus: string, isolation: string | undefined, available
1491
1577
 
1492
1578
  export interface RunInContainerArgs {
1493
1579
  workspaceRoot: string;
1580
+ /** A person approved this step before it ran: configuration and code it
1581
+ * changed are kept, not refused (copy-back.ts, FOL-96). */
1582
+ configWrites?: boolean;
1494
1583
  libraryRoot: string;
1495
1584
  /** The platform's gallery, staged under /library BEFORE the account library so the account's copies win. */
1496
1585
  galleryRoot?: string;
@@ -1543,6 +1632,10 @@ export interface RunInContainerArgs {
1543
1632
  /** A test run: where state/ and storage/ changes go instead of the
1544
1633
  * workspace, and who to tell about each. See test-mode.ts. */
1545
1634
  divert?: WriteDivert;
1635
+ /** What the host handed back into this sandbox mid-step (StepRecord.
1636
+ * handedBack), read at copy-out — kept on the host, never read back from
1637
+ * the sandbox alone (FOL-96). */
1638
+ handedBack?: () => Record<string, string | string[]> | undefined;
1546
1639
  /** The run is a test run. An isolated executor labels the sandbox so the
1547
1640
  * cluster can cut its network down to the egress proxy: a script tool
1548
1641
  * calls the network directly, not through the proxy where the test-mode
@@ -1912,8 +2005,17 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1912
2005
  fs.mkdirSync(wsOut);
1913
2006
  const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
1914
2007
  let applied: string[] = [];
2008
+ const refused: RefusedPath[] = [];
2009
+ const kept: RefusedPath[] = [];
1915
2010
  if (back.status === 0) {
1916
- applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
2011
+ applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert, {
2012
+ handedBack: args.handedBack?.(),
2013
+ onRefused: (r) => refused.push(r),
2014
+ allowConfig: args.configWrites === true,
2015
+ onKept: (r) => kept.push(r),
2016
+ });
2017
+ if (refused.length) args.emit("info", refusedLine(refused));
2018
+ if (kept.length) args.emit("info", keptLine(kept));
1917
2019
  } else {
1918
2020
  args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
1919
2021
  }
@@ -1927,7 +2029,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
1927
2029
  phases,
1928
2030
  };
1929
2031
  args.emit("info", timingLine(timing));
1930
- return { ...outcome, timing, applied };
2032
+ return { ...outcome, timing, applied, ...(refused.length ? { refused } : {}) };
1931
2033
  } finally {
1932
2034
  if (containerId) spawnSync(cli(), ["rm", "-f", containerId], { stdio: "ignore" });
1933
2035
  fs.rmSync(staging, { recursive: true, force: true });
package/src/runner.ts CHANGED
@@ -3036,6 +3036,8 @@ async function runStep(
3036
3036
  tenant,
3037
3037
  input: {
3038
3038
  agentRel: path.relative(workspaceRoot, agentDir).replaceAll("\\", "/"),
3039
+ // Its file tools inside the sandbox get the same allowance (FOL-96).
3040
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3039
3041
  prompt,
3040
3042
  model,
3041
3043
  effort,
@@ -3127,6 +3129,11 @@ async function runStep(
3127
3129
  network: { level: network.level, enforced: forward?.enforce === true },
3128
3130
  // A test run's state/ and storage/ changes come out under the run.
3129
3131
  divert: testRun && runId ? divertFor(runId) : undefined,
3132
+ // Read at copy-out: what the host handed back into the sandbox.
3133
+ handedBack: () => step.handedBack,
3134
+ // A person approved this step before it ran: configuration and code
3135
+ // it changes are kept, listed on the trail (FOL-96).
3136
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3130
3137
  test: testRun,
3131
3138
  // Resume: a sandbox a previous driver left running, recorded on the
3132
3139
  // step. Consumed once — a fresh attempt after this one (a retry, a
@@ -3332,6 +3339,11 @@ async function runStep(
3332
3339
  step.image = outcome.image ?? null;
3333
3340
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3334
3341
  if (step.subagentOf && outcome.applied?.length) step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
3342
+ if (outcome.refused?.length) {
3343
+ const seen = new Map((step.refused ?? []).map((r) => [r.path, r]));
3344
+ for (const r of outcome.refused) seen.set(r.path, r);
3345
+ step.refused = [...seen.values()];
3346
+ }
3335
3347
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
3336
3348
  const decision = podLossDecision(outcome.browserPod);
3337
3349
  if (decision.rerun) {
@@ -3503,6 +3515,9 @@ async function runStep(
3503
3515
  const attemptInProcess = (modelEnv: Record<string, string | undefined>) => executeStep({
3504
3516
  agentDir,
3505
3517
  workspaceRoot,
3518
+ // A person approved this step (its gate) before it ran: it may change
3519
+ // configuration and code (FOL-96) — the desks' retro applies fixes so.
3520
+ configWrites: step.approve === true && Boolean(step.approvedAt),
3506
3521
  libraryRoot: libraryDir(tenant),
3507
3522
  ...(subagents.length ? { subagents } : {}),
3508
3523
  // The colleagues' inbox, read after every tool call — the same hook
@@ -5241,6 +5256,21 @@ function driveRunInner(
5241
5256
  // Its files go back with the reply, so the delegating step's
5242
5257
  // sandbox has them too (delegate-tools.ts copyBackFiles).
5243
5258
  const back = readCopyBack(pDir, child.wrote ?? []);
5259
+ // What goes back into the parent's sandbox, as the host sends it:
5260
+ // its copy-out reads this, not the sandbox's own note (FOL-96).
5261
+ if (back.files.length) {
5262
+ parent.handedBack = { ...(parent.handedBack ?? {}) };
5263
+ for (const f of back.files) {
5264
+ const h = crypto.createHash("sha256").update(Buffer.from(f.base64, "base64")).digest("hex");
5265
+ // Kept beside any earlier job's for the same path, never over
5266
+ // it: either may be what the sandbox ends up holding.
5267
+ const prev = parent.handedBack[f.path];
5268
+ const all = prev === undefined ? [] : Array.isArray(prev) ? prev : [prev];
5269
+ if (!all.includes(h)) all.push(h);
5270
+ parent.handedBack[f.path] = all.length === 1 ? all[0] : all;
5271
+ }
5272
+ save();
5273
+ }
5244
5274
  return {
5245
5275
  status,
5246
5276
  result: child.result ?? (errors || "(no reply)"),