@inflexa-ai/harness 0.33.1 → 0.34.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.
@@ -18,7 +18,10 @@
18
18
  * It starts a report thread as a child of the conversation, and the Report
19
19
  * Builder composes the report in that thread.
20
20
  * The workspace read surface (`read_file`, `grep`, `workspace_search`) is
21
- * wired here over the `WorkspaceFilesystem` seam.
21
+ * wired here over the `WorkspaceFilesystem` seam, and the write pair
22
+ * (`write_file`, `edit_file`) over the session-scoped `WorkspaceMutator` —
23
+ * confined to the analysis root, with one provenance session event per
24
+ * successful write.
22
25
  */
23
26
  import type { Pool } from "pg";
24
27
  import type { ExecuteAnalysisInput, ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
@@ -115,8 +118,9 @@ export interface ConversationAgentDeps extends EnvironmentStorePaths {
115
118
  readonly eyes?: AcquireEyes;
116
119
  /**
117
120
  * The provenance seam. `start_report_session` hands it to its spawn, which
118
- * emits the creation of the session at the moment that the child lands. No
119
- * other tool of this roster reads it.
121
+ * emits the creation of the session at the moment that the child lands.
122
+ * The session-scoped mutator behind `write_file` and `edit_file` emits one
123
+ * `write-file` session event for each successful write.
120
124
  */
121
125
  readonly provenance?: ProvenanceSeam;
122
126
  /**
@@ -18,7 +18,10 @@
18
18
  * It starts a report thread as a child of the conversation, and the Report
19
19
  * Builder composes the report in that thread.
20
20
  * The workspace read surface (`read_file`, `grep`, `workspace_search`) is
21
- * wired here over the `WorkspaceFilesystem` seam.
21
+ * wired here over the `WorkspaceFilesystem` seam, and the write pair
22
+ * (`write_file`, `edit_file`) over the session-scoped `WorkspaceMutator` —
23
+ * confined to the analysis root, with one provenance session event per
24
+ * successful write.
22
25
  */
23
26
  import { createWorkingMemory } from "../memory/working-memory.js";
24
27
  import { conversationPrompt } from "../prompts/conversation.js";
@@ -28,7 +31,7 @@ import { searchGeneTool, lookupAnnotationTool, searchInteractionsTool, chemblToo
28
31
  import { createNcbiTools, createChemDbTools } from "../tools/bio/keys.js";
29
32
  // Dependency-bearing tool factories.
30
33
  import { createGeneratePlanTool, createInspectRunTool, createInspectDataProfileTool, createGenerateAnalogyReportTool, createSearchSemanticScholarTool, searchArxivTool, } from "../tools/research/index.js";
31
- import { createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createShowPlanTool, createWorkspaceSearchTool, showFileTool, } from "../tools/workspace/index.js";
34
+ import { createEditFileTool, createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createSessionWorkspaceMutator, createShowPlanTool, createWorkspaceSearchTool, createWriteFileTool, showFileTool, } from "../tools/workspace/index.js";
32
35
  import { showUserTool } from "../tools/display/index.js";
33
36
  import { createUpdateWorkingMemoryTool } from "../tools/memory/index.js";
34
37
  import { createListAvailablePackagesTool } from "../tools/sandbox/list-available-packages.js";
@@ -46,6 +49,14 @@ export function createConversationAgent(deps) {
46
49
  const workingMemory = createWorkingMemory(pool);
47
50
  const ncbi = createNcbiTools(bioKeys);
48
51
  const chemDb = createChemDbTools(bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
52
+ // The write path of this agent: coordinates resolve per call from the
53
+ // session's analysis scope, the write prefix is the analysis root, and
54
+ // every successful write emits a `write-file` provenance session event.
55
+ const mutator = createSessionWorkspaceMutator({
56
+ resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
57
+ ...(deps.provenance ? { provenance: deps.provenance } : {}),
58
+ ...(deps.logger ? { logger: deps.logger } : {}),
59
+ });
49
60
  const tools = [
50
61
  // Identifier resolution — the ENSG most other bio tools key off.
51
62
  searchGeneTool,
@@ -146,6 +157,13 @@ export function createConversationAgent(deps) {
146
157
  createListFilesTool(workspaceFs),
147
158
  createFileStatTool(workspaceFs),
148
159
  createGrepTool(workspaceFs),
160
+ // Workspace write pair over the session-scoped mutate seam. The working
161
+ // directory of this agent is the analysis root, so a write lands
162
+ // anywhere inside its own analysis tree; `..`, a foreign analysis, and
163
+ // a symlink escape stay refused. No sandbox is involved — the mutator
164
+ // writes host-side, exactly as it does for a sandbox agent.
165
+ createWriteFileTool({ mutator }),
166
+ createEditFileTool({ mutator, workspaceFilesystem: workspaceFs }),
149
167
  // Working memory.
150
168
  createUpdateWorkingMemoryTool(workingMemory, pool),
151
169
  // Host-contributed tools ride the same context and dispatch path as the
@@ -5,8 +5,9 @@
5
5
  * `AgentDefinition` for one sandbox agent (see the harness-durable-runtime spec): SOUL kernel + the
6
6
  * agent's own prompt body + sandbox standards composed into a single
7
7
  * static `systemPrompt` string (no processors at request time, see the harness-providers spec),
8
- * the workspace mutate trio (`execute_command`, `write_file`, `edit_file`)
9
- * bound to the shared `SandboxClient` and the step's `allowedWritePrefix`,
8
+ * the workspace mutate trio — `execute_command` bound to the shared
9
+ * `SandboxClient`, and `write_file` / `edit_file` confined to the step's
10
+ * `allowedWritePrefix` through the host-side `WorkspaceMutator` —,
10
11
  * the workspace read tools (`read_file`, `grep`) over the shared
11
12
  * `WorkspaceFilesystem`, `inspect_data_profile` over the shared `Pool`, and the
12
13
  * bio/literature/context7/run-inspection tools its `meta.tools` allowlist names.
@@ -5,8 +5,9 @@
5
5
  * `AgentDefinition` for one sandbox agent (see the harness-durable-runtime spec): SOUL kernel + the
6
6
  * agent's own prompt body + sandbox standards composed into a single
7
7
  * static `systemPrompt` string (no processors at request time, see the harness-providers spec),
8
- * the workspace mutate trio (`execute_command`, `write_file`, `edit_file`)
9
- * bound to the shared `SandboxClient` and the step's `allowedWritePrefix`,
8
+ * the workspace mutate trio — `execute_command` bound to the shared
9
+ * `SandboxClient`, and `write_file` / `edit_file` confined to the step's
10
+ * `allowedWritePrefix` through the host-side `WorkspaceMutator` —,
10
11
  * the workspace read tools (`read_file`, `grep`) over the shared
11
12
  * `WorkspaceFilesystem`, `inspect_data_profile` over the shared `Pool`, and the
12
13
  * bio/literature/context7/run-inspection tools its `meta.tools` allowlist names.
@@ -134,16 +135,9 @@ function buildWorkspaceTools(deps, readOnly) {
134
135
  ? []
135
136
  : (() => {
136
137
  const mutator = createWorkspaceMutator({
137
- sandboxClient,
138
- sandbox: step.sandbox,
139
138
  workspaceRoot: step.workspaceRoot,
140
139
  analysisId: step.analysisId,
141
- stepId: step.stepId,
142
- workflowId: step.workflowId,
143
140
  workingDir: hostWorkingDir,
144
- sandboxWorkingDir,
145
- nextFunctionId: step.nextFunctionId,
146
- deadlineMs: step.deadlineMs,
147
141
  ...(lineageCollector ? { lineageCollector } : {}),
148
142
  });
149
143
  return [createWriteFileTool({ mutator }), createEditFileTool({ mutator, workspaceFilesystem: workspaceFs, workingDir: hostWorkingDir })];
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Artifact record — the shape post-step deps and the provenance collector
3
- * read for each file the step produced (hash + size + tool provenance +
4
- * write timestamp). Pure data type; lives in its own file because it is
5
- * shared across `execution/` and `provenance/`.
3
+ * read for each file the step produced (hash + size + tool provenance).
4
+ * Pure data type; lives in its own file because it is shared across
5
+ * `execution/` and `provenance/`.
6
6
  */
7
7
  export interface ArtifactRecord {
8
8
  path: string;
@@ -14,6 +14,10 @@ export interface ArtifactRecord {
14
14
  * their provenance comes from the collector.
15
15
  */
16
16
  toolName?: string;
17
- /** ISO 8601 timestamp captured at write time. */
18
- timestamp: string;
17
+ /**
18
+ * The loop's tool-call id of the write — replay-stable (the durably
19
+ * cached model turn minted it), so the provenance bridge can key a
20
+ * deterministic call-activity identifier on it.
21
+ */
22
+ invocationId: string;
19
23
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Artifact record — the shape post-step deps and the provenance collector
3
- * read for each file the step produced (hash + size + tool provenance +
4
- * write timestamp). Pure data type; lives in its own file because it is
5
- * shared across `execution/` and `provenance/`.
3
+ * read for each file the step produced (hash + size + tool provenance).
4
+ * Pure data type; lives in its own file because it is shared across
5
+ * `execution/` and `provenance/`.
6
6
  */
7
7
  export {};
@@ -22,7 +22,7 @@
22
22
  import { stat } from "node:fs/promises";
23
23
  import path from "node:path";
24
24
  import { createNoopLogger } from "../lib/console-logger.js";
25
- import { computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
25
+ import { classifyWithinRoot, computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
26
26
  import { artifactReconcileDropped, lineageInputDropped } from "../lib/metrics.js";
27
27
  export async function reconcileManifestWithDisk(input) {
28
28
  const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
@@ -41,6 +41,18 @@ export async function reconcileManifestWithDisk(input) {
41
41
  logger.warn("skipping out-of-bounds entry", { path: entry.path });
42
42
  continue;
43
43
  }
44
+ // Physical bound: the check above compares path strings, and `entry.path`
45
+ // can name a symlink the step planted — the `stat`/`computeSha256File`
46
+ // below both follow it. A real target outside the analysis tree is not
47
+ // attestable; same verdict as the lexical skip above. An `absent`
48
+ // verdict (a dangling link, a missing file) falls through to the stat,
49
+ // which drops it as a phantom. The sandbox is destroyed before
50
+ // reconcile runs, so the tree is quiescent and a checked link cannot be
51
+ // swapped under the hash.
52
+ if ((await classifyWithinRoot(workspaceRoot, absPath)) === "escaped") {
53
+ logger.warn("skipping symlink-escape entry", { path: entry.path });
54
+ continue;
55
+ }
44
56
  let info;
45
57
  try {
46
58
  info = await stat(absPath);
@@ -139,6 +151,18 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
139
151
  collector.dropInput(ref);
140
152
  continue;
141
153
  }
154
+ // Physical bound: the two checks above compare path strings, and a
155
+ // symlink at `hostPath` turns an in-tree string into an out-of-tree
156
+ // target that `stat`/`computeSha256File` would follow. An escaping
157
+ // target is not an attestable edge — drop it from lineage exactly like
158
+ // the lexical out-of-tree reads above; it is not drift. An `absent`
159
+ // verdict (a dangling link) falls through to the ENOENT drop below.
160
+ if ((await classifyWithinRoot(resourceRoot, hostPath)) === "escaped") {
161
+ logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "realpath" });
162
+ lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "symlink-escape" });
163
+ collector.dropInput(ref);
164
+ continue;
165
+ }
142
166
  let info;
143
167
  try {
144
168
  info = await stat(hostPath);
@@ -209,7 +209,7 @@ export class ProvenanceCollector {
209
209
  producer: {
210
210
  type: "file_tool",
211
211
  tool: artifact.toolName ?? "unknown",
212
- timestamp: artifact.timestamp,
212
+ invocationId: artifact.invocationId,
213
213
  },
214
214
  inputs: [],
215
215
  scriptPath: null,
@@ -95,8 +95,9 @@ type BlockAct = {
95
95
  * One observation of a session.
96
96
  *
97
97
  * The union names one event for each act: the start of a session, the four block operations, the title,
98
- * the derivation, the preview, and the record. Each event carries the analysis and the thread, thus a
99
- * consumer places it with no lookup. The title sits on the document, thus the title event names no block.
98
+ * the derivation, the preview, the record, and the file write of a conversation turn. Each event carries
99
+ * the analysis and the thread, thus a consumer places it with no lookup. The title sits on the document,
100
+ * thus the title event names no block.
100
101
  *
101
102
  * Each member carries what its own site holds at the moment that the act lands, and nothing more. The
102
103
  * derivation carries the chain of the table, because the site pins it and a consumer cannot rebuild it
@@ -150,6 +151,26 @@ export type SessionProvenanceEvent = {
150
151
  threadId: string;
151
152
  versionId: string;
152
153
  replaced: boolean;
154
+ } | {
155
+ /**
156
+ * One file that a conversation turn wrote with a file tool — a write under no run and no
157
+ * step, thus a session observation rather than a collector record. The hash and the size
158
+ * come from the exact bytes written, computed in-process at the write site.
159
+ */
160
+ type: "write-file";
161
+ analysisId: string;
162
+ /** The conversation thread whose turn wrote the file. Absent when the scope carries none. */
163
+ threadId?: string;
164
+ /** Analysis-root-relative path of the landed file. */
165
+ path: string;
166
+ /** SHA-256 of the bytes written. */
167
+ hash: string;
168
+ /** Size in bytes. */
169
+ size: number;
170
+ /** The agent-visible file tool that authored the content (`write_file` / `edit_file`). */
171
+ tool: string;
172
+ /** The loop's tool-call id of the write — the replay-stable identity of the invocation. */
173
+ invocationId: string;
153
174
  };
154
175
  /**
155
176
  * The provenance seam of the harness. An embedder realizes the members that it records, and the harness
@@ -7,11 +7,16 @@
7
7
  * - What script generated it (if any)
8
8
  */
9
9
  import { z } from "zod";
10
- /** File tool write (write_file, edit_file). */
10
+ /**
11
+ * File tool write (write_file, edit_file). Carries the loop's tool-call
12
+ * `invocationId` — replay-stable, and the identity the bridge mixes into the
13
+ * deterministic call-activity QName — instead of a wall-clock timestamp,
14
+ * which would be re-minted on every durable replay.
15
+ */
11
16
  export declare const FileToolProducerSchema: z.ZodObject<{
12
17
  type: z.ZodLiteral<"file_tool">;
13
18
  tool: z.ZodString;
14
- timestamp: z.ZodString;
19
+ invocationId: z.ZodString;
15
20
  }, z.core.$strip>;
16
21
  /** Sandbox command execution (execute_command). */
17
22
  export declare const CommandProducerSchema: z.ZodObject<{
@@ -25,7 +30,7 @@ export declare const CommandProducerSchema: z.ZodObject<{
25
30
  export declare const ProducerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
26
31
  type: z.ZodLiteral<"file_tool">;
27
32
  tool: z.ZodString;
28
- timestamp: z.ZodString;
33
+ invocationId: z.ZodString;
29
34
  }, z.core.$strip>, z.ZodObject<{
30
35
  type: z.ZodLiteral<"command">;
31
36
  command: z.ZodString;
@@ -66,7 +71,7 @@ export declare const ProvenanceRecordSchema: z.ZodObject<{
66
71
  producer: z.ZodDiscriminatedUnion<[z.ZodObject<{
67
72
  type: z.ZodLiteral<"file_tool">;
68
73
  tool: z.ZodString;
69
- timestamp: z.ZodString;
74
+ invocationId: z.ZodString;
70
75
  }, z.core.$strip>, z.ZodObject<{
71
76
  type: z.ZodLiteral<"command">;
72
77
  command: z.ZodString;
@@ -8,11 +8,16 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  // ── Producer ────────────────────────────────────────────────────────
11
- /** File tool write (write_file, edit_file). */
11
+ /**
12
+ * File tool write (write_file, edit_file). Carries the loop's tool-call
13
+ * `invocationId` — replay-stable, and the identity the bridge mixes into the
14
+ * deterministic call-activity QName — instead of a wall-clock timestamp,
15
+ * which would be re-minted on every durable replay.
16
+ */
12
17
  export const FileToolProducerSchema = z.object({
13
18
  type: z.literal("file_tool"),
14
19
  tool: z.string(),
15
- timestamp: z.string(),
20
+ invocationId: z.string(),
16
21
  });
17
22
  /** Sandbox command execution (execute_command). */
18
23
  export const CommandProducerSchema = z.object({
@@ -1,14 +1,17 @@
1
1
  /**
2
- * `edit_file` — sandbox-gated workspace edit, confined to the agent's writable
3
- * working directory. Composes the read seam (fetch current content) with a
2
+ * `edit_file` — workspace edit, confined to the agent's writable working
3
+ * directory. Composes the read seam (fetch current content) with a
4
4
  * search/replace and the `WorkspaceMutator` seam (write the result) — same
5
5
  * resolution + confinement contract as `write_file`, no path logic of its own
6
6
  * (see the harness-workspace-tools spec).
7
7
  *
8
8
  * Edit semantics: replace `old_string` with `new_string`. `old_string` MUST
9
9
  * occur in the file; when `replace_all` is false (default) it MUST occur
10
- * exactly once. Provenance hashes the post-edit content — recorded by the
11
- * mutator.
10
+ * exactly once. With `regex=true`, `old_string` is a JS RegExp pattern
11
+ * (compiled with the `gm` flags — multiline anchors), `new_string` may use
12
+ * capture-group references, and exactly one of `replace_all=true` or
13
+ * `expected_matches` gates the write. Provenance hashes the post-edit
14
+ * content — recorded by the mutator.
12
15
  */
13
16
  import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
14
17
  import type { WorkspaceMutator, WriteFileResult } from "./mutator.js";
@@ -23,6 +26,21 @@ export type EditFileResult = {
23
26
  readonly status: "not_unique";
24
27
  readonly path: string;
25
28
  readonly occurrences: number;
29
+ } | {
30
+ readonly status: "invalid_pattern";
31
+ readonly path: string;
32
+ readonly pattern: string;
33
+ readonly error: string;
34
+ } | {
35
+ readonly status: "invalid_arguments";
36
+ readonly path: string;
37
+ readonly reason: string;
38
+ } | {
39
+ readonly status: "match_count_mismatch";
40
+ readonly path: string;
41
+ readonly expected: number;
42
+ readonly actual: number;
43
+ readonly lines: readonly number[];
26
44
  } | Exclude<WriteFileResult, {
27
45
  status: "ok";
28
46
  }> | {
@@ -30,17 +48,25 @@ export type EditFileResult = {
30
48
  readonly path: string;
31
49
  readonly replacements: number;
32
50
  readonly bytesWritten: number;
51
+ /** Regex mode only: 1-based line numbers where the matches started. */
52
+ readonly lines?: readonly number[];
33
53
  };
34
54
  export interface EditFileDeps {
35
55
  readonly mutator: WorkspaceMutator;
36
56
  /** Read seam used to fetch current file content for search/replace. */
37
57
  readonly workspaceFilesystem: WorkspaceFilesystem;
38
- /** Absolute host working directory — relative read/write paths resolve here. */
39
- readonly workingDir: string;
58
+ /**
59
+ * Absolute host working directory — relative read paths resolve here. Omit
60
+ * for the conversation agent: the read seam then defaults to the analysis
61
+ * root, matching the session-scoped mutator's write prefix.
62
+ */
63
+ readonly workingDir?: string;
40
64
  }
41
65
  export declare function createEditFileTool(deps: EditFileDeps): import("../define-tool.js").Tool<{
42
66
  path: string;
43
67
  old_string: string;
44
68
  new_string: string;
45
69
  replace_all: boolean;
70
+ regex: boolean;
71
+ expected_matches?: number | undefined;
46
72
  }, EditFileResult>;
@@ -1,14 +1,17 @@
1
1
  /**
2
- * `edit_file` — sandbox-gated workspace edit, confined to the agent's writable
3
- * working directory. Composes the read seam (fetch current content) with a
2
+ * `edit_file` — workspace edit, confined to the agent's writable working
3
+ * directory. Composes the read seam (fetch current content) with a
4
4
  * search/replace and the `WorkspaceMutator` seam (write the result) — same
5
5
  * resolution + confinement contract as `write_file`, no path logic of its own
6
6
  * (see the harness-workspace-tools spec).
7
7
  *
8
8
  * Edit semantics: replace `old_string` with `new_string`. `old_string` MUST
9
9
  * occur in the file; when `replace_all` is false (default) it MUST occur
10
- * exactly once. Provenance hashes the post-edit content — recorded by the
11
- * mutator.
10
+ * exactly once. With `regex=true`, `old_string` is a JS RegExp pattern
11
+ * (compiled with the `gm` flags — multiline anchors), `new_string` may use
12
+ * capture-group references, and exactly one of `replace_all=true` or
13
+ * `expected_matches` gates the write. Provenance hashes the post-edit
14
+ * content — recorded by the mutator.
12
15
  */
13
16
  import { ok } from "neverthrow";
14
17
  import { z } from "zod";
@@ -22,15 +25,31 @@ const EditFileInputSchema = z.object({
22
25
  old_string: z
23
26
  .string()
24
27
  .min(1)
25
- .describe("The exact text to find and replace. Must occur in the file. When " +
26
- "replace_all=false (default), must occur exactly once — include " +
28
+ .describe("The exact text to find and replace — or, when regex=true, a " +
29
+ "JavaScript regular expression pattern. In exact mode with " +
30
+ "replace_all=false (default), it must occur exactly once — include " +
27
31
  "enough surrounding context to make it unique."),
28
- new_string: z.string().describe("The replacement text."),
32
+ new_string: z.string().describe("The replacement text. When regex=true it may use capture-group references such as $1."),
29
33
  replace_all: z
30
34
  .boolean()
31
35
  .optional()
32
36
  .default(false)
33
37
  .describe("If true, replace every occurrence. If false (default), old_string " + "must occur exactly once or the edit returns a `not_unique` data variant."),
38
+ regex: z
39
+ .boolean()
40
+ .optional()
41
+ .default(false)
42
+ .describe("If true, old_string is a JavaScript regular expression compiled with " +
43
+ "the `gm` flags (^ and $ match line boundaries). Requires exactly one " +
44
+ "of replace_all=true or expected_matches."),
45
+ expected_matches: z
46
+ .number()
47
+ .int()
48
+ .min(1)
49
+ .optional()
50
+ .describe("Regex mode only: the exact number of matches you expect. On a " +
51
+ "mismatch the edit writes nothing and returns the actual count with " +
52
+ "the matching line numbers."),
34
53
  });
35
54
  function replaceString(content, oldString, newString, replaceAll) {
36
55
  if (!content.includes(oldString))
@@ -54,48 +73,115 @@ function replaceString(content, oldString, newString, replaceAll) {
54
73
  replacements: 1,
55
74
  };
56
75
  }
76
+ /** 1-based line numbers of the given start offsets (ascending), deduplicated. */
77
+ function lineNumbersAt(content, offsets) {
78
+ const lines = [];
79
+ let line = 1;
80
+ let cursor = 0;
81
+ for (const offset of offsets) {
82
+ for (; cursor < offset; cursor++) {
83
+ if (content.charCodeAt(cursor) === 10)
84
+ line++;
85
+ }
86
+ if (lines[lines.length - 1] !== line)
87
+ lines.push(line);
88
+ }
89
+ return lines;
90
+ }
91
+ function replaceRegex(content, pattern, replacement, expected) {
92
+ let compiled;
93
+ try {
94
+ compiled = new RegExp(pattern, "gm");
95
+ }
96
+ catch (err) {
97
+ return { kind: "invalid_pattern", error: err instanceof Error ? err.message : String(err) };
98
+ }
99
+ const matches = [...content.matchAll(compiled)];
100
+ if (matches.length === 0)
101
+ return { kind: "not_found" };
102
+ const lines = lineNumbersAt(content, matches.map((match) => match.index));
103
+ if (expected !== undefined && matches.length !== expected) {
104
+ return { kind: "count_mismatch", expected, actual: matches.length, lines };
105
+ }
106
+ return { kind: "ok", content: content.replace(compiled, replacement), replacements: matches.length, lines };
107
+ }
57
108
  export function createEditFileTool(deps) {
58
109
  return defineTool({
59
110
  id: "edit_file",
60
- // Body-only `awaitExec`-recv → runs unwrapped in the workflow body (see the harness-tools spec).
111
+ // The mutator wraps the disk mutation in `ctx.runStep` itself, so the
112
+ // body runs unwrapped in the workflow body (see the harness-tools spec).
61
113
  executionMode: "workflow",
62
114
  description: "Edit a file in your working directory by replacing specific text. " +
63
115
  "Read the file first to get the exact text. When replace_all is false " +
64
- "(default), old_string must occur exactly once. Returns `out_of_prefix` " +
65
- "/ `out_of_scope` / `file_not_found` / `not_found` / `not_unique` data " +
66
- "variants — never throws on expected outcomes.",
116
+ "(default), old_string must occur exactly once. For a bulk pattern " +
117
+ "edit set regex=true: old_string is a JS regex, new_string may use $1 " +
118
+ "references, and exactly one of replace_all=true or expected_matches " +
119
+ "gates the write. Returns `out_of_prefix` / `out_of_scope` / " +
120
+ "`file_not_found` / `not_found` / `not_unique` / `match_count_mismatch` " +
121
+ "data variants — never throws on expected outcomes.",
67
122
  inputSchema: EditFileInputSchema,
68
123
  // The path only. The replaced text is file content, not a description.
69
124
  describeCall: ({ path }) => path,
70
- execute: async ({ path, old_string, new_string, replace_all }, ctx) => {
125
+ execute: async ({ path, old_string, new_string, replace_all, regex, expected_matches }, ctx) => {
126
+ const replaceAll = replace_all === true;
127
+ const useRegex = regex === true;
128
+ if (!useRegex && expected_matches !== undefined) {
129
+ return ok({ status: "invalid_arguments", path, reason: "expected_matches requires regex=true." });
130
+ }
131
+ if (useRegex && (expected_matches !== undefined) === replaceAll) {
132
+ return ok({
133
+ status: "invalid_arguments",
134
+ path,
135
+ reason: "regex=true requires exactly one of replace_all=true or expected_matches.",
136
+ });
137
+ }
71
138
  const read = unwrapOrThrow(await deps.workspaceFilesystem.readFile({
72
139
  session: ctx.session,
73
140
  path,
74
- workingDir: deps.workingDir,
141
+ ...(deps.workingDir !== undefined ? { workingDir: deps.workingDir } : {}),
75
142
  }));
76
143
  if (read.kind === "not_found")
77
144
  return ok({ status: "file_not_found", path });
78
145
  if (read.kind === "out_of_scope")
79
146
  return ok({ status: "out_of_scope", path });
80
- const replaced = replaceString(read.content.toString("utf8"), old_string, new_string, replace_all);
147
+ const content = read.content.toString("utf8");
148
+ const replaced = useRegex
149
+ ? replaceRegex(content, old_string, new_string, expected_matches)
150
+ : replaceString(content, old_string, new_string, replaceAll);
81
151
  if (replaced.kind === "not_found")
82
152
  return ok({ status: "not_found", path });
83
153
  if (replaced.kind === "not_unique") {
84
154
  return ok({ status: "not_unique", path, occurrences: replaced.count });
85
155
  }
156
+ if (replaced.kind === "invalid_pattern") {
157
+ return ok({ status: "invalid_pattern", path, pattern: old_string, error: replaced.error });
158
+ }
159
+ if (replaced.kind === "count_mismatch") {
160
+ return ok({
161
+ status: "match_count_mismatch",
162
+ path,
163
+ expected: replaced.expected,
164
+ actual: replaced.actual,
165
+ lines: replaced.lines,
166
+ });
167
+ }
86
168
  const result = await deps.mutator.writeFile({
87
169
  path,
88
170
  content: replaced.content,
89
171
  toolName: "edit_file",
90
- emit: ctx.emit,
172
+ invocationId: ctx.invocationId,
173
+ runStep: ctx.runStep,
174
+ session: ctx.session,
91
175
  });
92
176
  if (result.status !== "ok")
93
177
  return ok(result);
178
+ const lines = replaced.lines;
94
179
  return ok({
95
180
  status: "ok",
96
181
  path: result.path,
97
182
  replacements: replaced.replacements,
98
183
  bytesWritten: result.bytesWritten,
184
+ ...(lines === undefined ? {} : { lines }),
99
185
  });
100
186
  },
101
187
  });
@@ -3,20 +3,33 @@
3
3
  * working directory. The write-side counterpart to `WorkspaceFilesystem`
4
4
  * (see the harness-workspace-tools spec).
5
5
  *
6
- * It lives here (next to `run-exec`) rather than under `workspace/` because the
7
- * write path is sandbox-coupled: a write is one `SandboxClient` exec, not a
8
- * host `fs` call. The seam owns the whole gauntlet that `write_file` and
9
- * `edit_file` previously each re-implemented inline — resolve + confine to the
10
- * working directory, derive the in-sandbox path, and write the bytes through
11
- * the sandbox — so the confinement invariant is concentrated in one place
12
- * instead of being a per-tool convention.
6
+ * The seam owns the whole gauntlet that `write_file` and `edit_file` share —
7
+ * resolve + confine to the working directory, the symlink-hardened landing,
8
+ * the host `fs` write, and the provenance record — so the confinement
9
+ * invariant is concentrated in one place instead of being a per-tool
10
+ * convention.
11
+ *
12
+ * Two realizations serve the two agent contexts. `createWorkspaceMutator`
13
+ * closes over fixed per-step coordinates and records into the step-scoped
14
+ * lineage collector (the sandbox agents). `createSessionWorkspaceMutator`
15
+ * resolves its coordinates per call from the session's analysis scope — the
16
+ * write prefix is the analysis root — and emits a `write-file` session event
17
+ * through the provenance seam (the conversation agent, which runs under no
18
+ * run and no step).
19
+ *
20
+ * The landing is hardened beyond the lexical resolver: missing parent
21
+ * directories are created only inside the write prefix, the deepest existing
22
+ * ancestor is realpath-checked against the realpath'd prefix (a symlinked
23
+ * ancestor cannot re-aim the write), and the final open uses `O_NOFOLLOW`
24
+ * (a symlinked final component is refused, never followed).
13
25
  */
26
+ import type { AgentSession } from "../../auth/types.js";
27
+ import type { Logger } from "../../lib/logger.js";
28
+ import type { RunStep } from "../../loop/types.js";
14
29
  import type { ProvenanceCollector } from "../../provenance/collector.js";
15
- import type { SandboxClient } from "../../sandbox/client.js";
16
- import type { SandboxRef } from "../../sandbox/types.js";
17
- import type { EmitFn } from "../define-tool.js";
18
- import { type BoundedExecResult } from "./result-bounds.js";
19
- /** Outcome of a confined write. Expected outcomes are data variants — never throws. */
30
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
31
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
32
+ /** Outcome of a confined write. Expected outcomes are data variants — never throws on them. */
20
33
  export type WriteFileResult = {
21
34
  readonly status: "ok";
22
35
  readonly path: string;
@@ -27,10 +40,10 @@ export type WriteFileResult = {
27
40
  } | {
28
41
  readonly status: "out_of_prefix";
29
42
  readonly path: string;
30
- } | ({
31
- readonly status: "write_failed";
43
+ } | {
44
+ readonly status: "symlink_denied";
32
45
  readonly path: string;
33
- } & BoundedExecResult);
46
+ };
34
47
  /**
35
48
  * Agent-visible name of the tool driving a confined write. Rides the write args
36
49
  * so a successful write is attributed to the invoking tool in provenance
@@ -39,25 +52,11 @@ export type WriteFileResult = {
39
52
  */
40
53
  export type MutateToolName = "write_file" | "edit_file";
41
54
  export interface WorkspaceMutatorDeps {
42
- readonly sandboxClient: SandboxClient;
43
- readonly sandbox: SandboxRef;
44
55
  /** Absolute host root of this analysis's workspace tree — used by the resolver. */
45
56
  readonly workspaceRoot: string;
46
57
  readonly analysisId: string;
47
- readonly stepId: string;
48
- readonly workflowId: string;
49
58
  /** Absolute host working directory: relative paths resolve here AND writes are confined here. */
50
59
  readonly workingDir: string;
51
- /**
52
- * In-sandbox absolute path of `workingDir` (mirrors `execute_command`'s
53
- * `defaultCwd`). Passed as the write exec's `cwd` so sandbox-server roots
54
- * its tree-differ here and reports the written file as a live delta — an
55
- * empty `cwd` disables the differ, so write-only files (e.g. scripts/)
56
- * would never reach the live file tree.
57
- */
58
- readonly sandboxWorkingDir: string;
59
- readonly nextFunctionId: () => string;
60
- readonly deadlineMs: () => number;
61
60
  /**
62
61
  * Step-scoped lineage collector. On a successful confined write the seam
63
62
  * records a file-tool provenance record here — hash and size computed
@@ -70,14 +69,51 @@ export interface WorkspaceMutator {
70
69
  /**
71
70
  * Resolve `path` against the working directory (relative) or analysis root
72
71
  * (absolute `/{analysisId}/...`), confine the result to the working
73
- * directory, and write `content` through the sandbox. `toolName` names the
74
- * invoking tool so a successful write is attributed to it in provenance.
72
+ * directory, and land `content` on the host filesystem. `toolName` names
73
+ * the invoking tool so a successful write is attributed to it in
74
+ * provenance. `invocationId` is the tool call's loop id — pass the tool
75
+ * context's `invocationId`: it rides the provenance record so the write's
76
+ * call activity gets a deterministic identity. `runStep` wraps the disk
77
+ * mutation in a replay-cached step — pass the tool context's `runStep`.
78
+ * `session` is the calling agent's session — pass the tool context's
79
+ * `session`: the step-scoped realization ignores it, the session-scoped
80
+ * one resolves its coordinates and its provenance attribution from it.
75
81
  */
76
82
  writeFile(args: {
77
83
  readonly path: string;
78
84
  readonly content: string;
79
85
  readonly toolName: MutateToolName;
80
- readonly emit: EmitFn;
86
+ readonly invocationId: string;
87
+ readonly runStep: RunStep;
88
+ readonly session: AgentSession;
81
89
  }): Promise<WriteFileResult>;
82
90
  }
83
91
  export declare function createWorkspaceMutator(deps: WorkspaceMutatorDeps): WorkspaceMutator;
92
+ /** Construction deps of the session-scoped realization — see {@link createSessionWorkspaceMutator}. */
93
+ export interface SessionWorkspaceMutatorDeps {
94
+ /** Workspace-root resolution seam — the write coordinates resolve per call from the session's analysis scope. */
95
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
96
+ /**
97
+ * The provenance seam. On a successful confined write the seam's session
98
+ * emit receives one `write-file` event — hash and size computed in-process
99
+ * from the exact bytes written, attributed to the session's analysis and
100
+ * thread. An unbound session emit records nothing; the write itself
101
+ * proceeds unchanged.
102
+ */
103
+ readonly provenance?: ProvenanceSeam;
104
+ readonly logger?: Logger;
105
+ }
106
+ /**
107
+ * The session-scoped `WorkspaceMutator` realization — the conversation agent's
108
+ * write path. Where `createWorkspaceMutator` closes over fixed per-step
109
+ * coordinates, this one serves every analysis from one construction: each call
110
+ * resolves the workspace root from the session's analysis scope, and the write
111
+ * prefix IS that root — the agent modifies its own analysis tree freely, while
112
+ * `..` traversal, a foreign analysis, and the symlink escapes stay refused by
113
+ * the same gauntlet.
114
+ *
115
+ * A write here runs under no run and no step, so its provenance is a session
116
+ * observation, not a collector record: each successful write emits one
117
+ * `write-file` session event (see `provenance/seam.ts`).
118
+ */
119
+ export declare function createSessionWorkspaceMutator(deps: SessionWorkspaceMutatorDeps): WorkspaceMutator;
@@ -3,79 +3,227 @@
3
3
  * working directory. The write-side counterpart to `WorkspaceFilesystem`
4
4
  * (see the harness-workspace-tools spec).
5
5
  *
6
- * It lives here (next to `run-exec`) rather than under `workspace/` because the
7
- * write path is sandbox-coupled: a write is one `SandboxClient` exec, not a
8
- * host `fs` call. The seam owns the whole gauntlet that `write_file` and
9
- * `edit_file` previously each re-implemented inline — resolve + confine to the
10
- * working directory, derive the in-sandbox path, and write the bytes through
11
- * the sandbox — so the confinement invariant is concentrated in one place
12
- * instead of being a per-tool convention.
6
+ * The seam owns the whole gauntlet that `write_file` and `edit_file` share —
7
+ * resolve + confine to the working directory, the symlink-hardened landing,
8
+ * the host `fs` write, and the provenance record — so the confinement
9
+ * invariant is concentrated in one place instead of being a per-tool
10
+ * convention.
11
+ *
12
+ * Two realizations serve the two agent contexts. `createWorkspaceMutator`
13
+ * closes over fixed per-step coordinates and records into the step-scoped
14
+ * lineage collector (the sandbox agents). `createSessionWorkspaceMutator`
15
+ * resolves its coordinates per call from the session's analysis scope — the
16
+ * write prefix is the analysis root — and emits a `write-file` session event
17
+ * through the provenance seam (the conversation agent, which runs under no
18
+ * run and no step).
19
+ *
20
+ * The landing is hardened beyond the lexical resolver: missing parent
21
+ * directories are created only inside the write prefix, the deepest existing
22
+ * ancestor is realpath-checked against the realpath'd prefix (a symlinked
23
+ * ancestor cannot re-aim the write), and the final open uses `O_NOFOLLOW`
24
+ * (a symlinked final component is refused, never followed).
13
25
  */
26
+ import { constants } from "node:fs";
27
+ import { lstat, mkdir, open, realpath } from "node:fs/promises";
28
+ import { basename, dirname, join as joinPath, relative as relativePath, resolve as resolvePath, sep } from "node:path";
29
+ import { createNoopLogger } from "../../lib/console-logger.js";
14
30
  import { computeSha256 } from "../../lib/fs-helpers.js";
31
+ import { tryFs, tryFsWrite } from "../../lib/fs-result.js";
32
+ import { unwrapOrThrow } from "../../lib/result.js";
33
+ import { bindSessionEmit } from "../../provenance/seam.js";
15
34
  import { resolveForWrite } from "../../workspace/paths.js";
16
- import { boundExecResult } from "./result-bounds.js";
17
- import { runSandboxExec } from "./run-exec.js";
18
- const WRITE_BYTES_PROGRAM = [
19
- "import sys,base64,os",
20
- "p=sys.argv[1]",
21
- "os.makedirs(os.path.dirname(p), exist_ok=True)",
22
- "open(p,'wb').write(base64.b64decode(sys.argv[2]))",
23
- ].join("\n");
35
+ /**
36
+ * Land `bytes` at `absolute`, hardened against a symlink re-aiming the write.
37
+ * `absolute` is already lexically confined under `prefix` by the resolver;
38
+ * this function guards the physical path:
39
+ *
40
+ * 1. Realpath the prefix (created if absent — it is workflow-owned
41
+ * infrastructure, not agent input).
42
+ * 2. Realpath the deepest existing ancestor of the target and refuse when it
43
+ * lands outside the realpath'd prefix — a symlinked ancestor inside the
44
+ * prefix cannot point the write elsewhere. A dangling-symlink ancestor
45
+ * has no landing at all and is refused too.
46
+ * 3. Create the missing parents under that canonical ancestor, then realpath
47
+ * the created directory and refuse when it lands outside the prefix — a
48
+ * symlink raced into a missing segment between the ancestor check and the
49
+ * recursive mkdir would re-aim the tail of the path.
50
+ * 4. Refuse a symlinked final component (lstat), then open with
51
+ * `O_NOFOLLOW` so a symlink raced in after the check still cannot be
52
+ * followed.
53
+ *
54
+ * Refusals are data; a genuine I/O failure throws (the loop's dispatch catch
55
+ * maps it to an error tool result).
56
+ */
57
+ async function landBytes(prefix, absolute, bytes) {
58
+ unwrapOrThrow(await tryFsWrite("mutator.mkdirPrefix", () => mkdir(prefix, { recursive: true }), { path: prefix }));
59
+ const realPrefix = unwrapOrThrow(await tryFs("mutator.realpathPrefix", () => realpath(prefix), { path: prefix }));
60
+ // Deepest existing ancestor of the target. The walk stops at the prefix,
61
+ // which exists — the resolver already confined the lexical path under it.
62
+ let deepest = dirname(absolute);
63
+ while (deepest !== prefix) {
64
+ const stat = unwrapOrThrow(await tryFs("mutator.lstatAncestor", () => lstat(deepest), { path: deepest, onAbsent: () => null }));
65
+ if (stat !== null)
66
+ break;
67
+ deepest = dirname(deepest);
68
+ }
69
+ const realDeepest = unwrapOrThrow(await tryFs("mutator.realpathAncestor", () => realpath(deepest), { path: deepest, onAbsent: () => null }));
70
+ if (realDeepest === null)
71
+ return "symlink_denied";
72
+ if (realDeepest !== realPrefix && !realDeepest.startsWith(realPrefix + sep))
73
+ return "out_of_prefix";
74
+ const targetDir = joinPath(realDeepest, relativePath(deepest, dirname(absolute)));
75
+ let landingDir = realDeepest;
76
+ if (targetDir !== realDeepest) {
77
+ unwrapOrThrow(await tryFsWrite("mutator.mkdirParents", () => mkdir(targetDir, { recursive: true }), { path: targetDir }));
78
+ // Recursive mkdir follows a symlink that already occupies a segment, so
79
+ // a symlink raced into a missing segment after the ancestor check would
80
+ // re-aim the tail. The canonical path of the created directory exposes
81
+ // it; a directory that vanished before the check is hostile too. The
82
+ // open below uses the canonical path, so the write never traverses the
83
+ // raced segment again.
84
+ const realTarget = unwrapOrThrow(await tryFs("mutator.realpathTarget", () => realpath(targetDir), { path: targetDir, onAbsent: () => null }));
85
+ if (realTarget === null)
86
+ return "symlink_denied";
87
+ if (realTarget !== realPrefix && !realTarget.startsWith(realPrefix + sep))
88
+ return "out_of_prefix";
89
+ landingDir = realTarget;
90
+ }
91
+ const finalPath = joinPath(landingDir, basename(absolute));
92
+ const finalStat = unwrapOrThrow(await tryFs("mutator.lstatFinal", () => lstat(finalPath), { path: finalPath, onAbsent: () => null }));
93
+ if (finalStat !== null && finalStat.isSymbolicLink())
94
+ return "symlink_denied";
95
+ // `O_NOFOLLOW` backstops the lstat refusal: a symlink raced in between the
96
+ // two calls fails the open (`ELOOP`) instead of being followed.
97
+ const handle = unwrapOrThrow(await tryFsWrite("mutator.open", () => open(finalPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW), {
98
+ path: finalPath,
99
+ }));
100
+ try {
101
+ unwrapOrThrow(await tryFsWrite("mutator.writeBytes", () => handle.writeFile(bytes), { path: finalPath }));
102
+ }
103
+ finally {
104
+ unwrapOrThrow(await tryFsWrite("mutator.close", () => handle.close(), { path: finalPath }));
105
+ }
106
+ return "ok";
107
+ }
108
+ /**
109
+ * The shared write gauntlet both realizations run: resolve + confine
110
+ * (`resolveForWrite`), then the hardened landing wrapped in `runStep`. A
111
+ * refusal comes back as data; an ok carries what the caller's provenance
112
+ * record needs — the analysis-relative path and the exact bytes that landed.
113
+ */
114
+ async function confinedWrite(args) {
115
+ const scoped = resolveForWrite({
116
+ workspaceRoot: args.workspaceRoot,
117
+ analysisId: args.analysisId,
118
+ workingDir: args.workingDir,
119
+ path: args.path,
120
+ });
121
+ if (scoped.kind === "out_of_scope")
122
+ return { status: "out_of_scope" };
123
+ if (scoped.kind === "out_of_prefix")
124
+ return { status: "out_of_prefix" };
125
+ // Analysis-root-relative, forward-slashed: the agent-facing result
126
+ // path prepends the `/{analysisId}` frame; a provenance record uses
127
+ // the bare tail (the collector normalizes it step-relative itself).
128
+ const relative = scoped.relative.split("\\").join("/");
129
+ const agentPath = `/${args.analysisId}/${relative}`;
130
+ const bytes = Buffer.from(args.content, "utf8");
131
+ const prefix = resolvePath(args.workingDir);
132
+ // The loop dispatches a workflow-mode tool body unwrapped (see
133
+ // `dispatchTools` in loop/run-agent.ts), so a DBOS replay re-runs
134
+ // this body. The step wrapper caches the landing: a replay returns
135
+ // the recorded outcome instead of touching the disk again. On the
136
+ // chat route the injected `runStep` is the passthrough — the same
137
+ // call, no durability, no fork of this path.
138
+ const landed = await args.runStep("write", () => landBytes(prefix, scoped.absolute, bytes));
139
+ if (landed !== "ok")
140
+ return { status: landed };
141
+ return { status: "ok", relative, agentPath, bytes };
142
+ }
24
143
  export function createWorkspaceMutator(deps) {
25
144
  return {
26
- async writeFile({ path, content, toolName, emit }) {
27
- const scoped = resolveForWrite({
145
+ async writeFile({ path, content, toolName, invocationId, runStep }) {
146
+ const landed = await confinedWrite({
28
147
  workspaceRoot: deps.workspaceRoot,
29
148
  analysisId: deps.analysisId,
30
149
  workingDir: deps.workingDir,
31
150
  path,
151
+ content,
152
+ runStep,
32
153
  });
33
- if (scoped.kind === "out_of_scope")
34
- return { status: "out_of_scope", path };
35
- if (scoped.kind === "out_of_prefix")
36
- return { status: "out_of_prefix", path };
37
- // Analysis-root-relative, forward-slashed: the sandbox path prepends
38
- // the `/{analysisId}` mount; the provenance record uses the bare tail
39
- // (the collector normalizes it step-relative itself).
40
- const relative = scoped.relative.split("\\").join("/");
41
- const sandboxPath = `/${deps.analysisId}/${relative}`;
42
- const contentBytes = Buffer.from(content, "utf8");
43
- const execId = `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`;
44
- const result = await runSandboxExec({
45
- sandboxClient: deps.sandboxClient,
46
- sandbox: deps.sandbox,
47
- execId,
48
- command: ["python3", "-c", WRITE_BYTES_PROGRAM, sandboxPath, contentBytes.toString("base64")],
49
- cwd: deps.sandboxWorkingDir,
50
- deadlineMs: deps.deadlineMs(),
51
- emit,
52
- });
53
- if (result.exitCode !== 0) {
54
- return { status: "write_failed", path: sandboxPath, ...boundExecResult(result) };
55
- }
154
+ if (landed.status !== "ok")
155
+ return { status: landed.status, path };
56
156
  // Attest the write in-process from the exact bytes just written — the
57
157
  // seam owns write provenance the same way it owns confinement. The
58
- // sandbox exec frame this write produces is deliberately not fed to
59
- // `feedExecFrame`: doing so would mint a `python3`+base64 command
60
- // record, the very misattribution this file-tool record supersedes.
158
+ // record lands outside the write step on purpose: the collector is
159
+ // process-local state, so a recovered replay must re-record into the
160
+ // fresh collector even while the cached step skips the disk write.
61
161
  //
62
- // `timestamp` is a write-time wall clock — normally a replay hazard in
63
- // provenance, but safe here because the record contributes only producer
64
- // IDENTITY downstream: the bridge drops it (the cli's file-tool ref has no
65
- // timestamp field) and the signed `inflexa:FileToolWrite` activity carries
66
- // just the tool name, so a re-execution's fresh stamp changes nothing in
67
- // the attested graph. Do NOT start forwarding it into an identifier or a
68
- // formal PROV position without making it replay-stable first.
162
+ // `invocationId` is replay-stable: the model turn that minted the tool
163
+ // call is a durably cached step, so a re-execution replays the same id.
164
+ // That is what lets the bridge key a DETERMINISTIC call-activity
165
+ // identifier on it — a wall-clock stamp here would be re-minted per
166
+ // replay and must never reach an identifier or a formal PROV position.
69
167
  if (deps.lineageCollector) {
70
168
  deps.lineageCollector.recordFileToolWrite({
71
- path: relative,
72
- hash: computeSha256(contentBytes),
73
- size: contentBytes.length,
169
+ path: landed.relative,
170
+ hash: computeSha256(landed.bytes),
171
+ size: landed.bytes.length,
74
172
  toolName,
75
- timestamp: new Date().toISOString(),
173
+ invocationId,
76
174
  });
77
175
  }
78
- return { status: "ok", path: sandboxPath, bytesWritten: contentBytes.length };
176
+ return { status: "ok", path: landed.agentPath, bytesWritten: landed.bytes.length };
177
+ },
178
+ };
179
+ }
180
+ /**
181
+ * The session-scoped `WorkspaceMutator` realization — the conversation agent's
182
+ * write path. Where `createWorkspaceMutator` closes over fixed per-step
183
+ * coordinates, this one serves every analysis from one construction: each call
184
+ * resolves the workspace root from the session's analysis scope, and the write
185
+ * prefix IS that root — the agent modifies its own analysis tree freely, while
186
+ * `..` traversal, a foreign analysis, and the symlink escapes stay refused by
187
+ * the same gauntlet.
188
+ *
189
+ * A write here runs under no run and no step, so its provenance is a session
190
+ * observation, not a collector record: each successful write emits one
191
+ * `write-file` session event (see `provenance/seam.ts`).
192
+ */
193
+ export function createSessionWorkspaceMutator(deps) {
194
+ const logger = (deps.logger ?? createNoopLogger()).named("workspace-mutator");
195
+ const observe = bindSessionEmit(deps.provenance, logger);
196
+ return {
197
+ async writeFile({ path, content, toolName, invocationId, runStep, session }) {
198
+ // Only an analysis scope has a workspace tree; under any other
199
+ // scope there is nothing in scope to write.
200
+ const scope = session.scope;
201
+ if (scope.kind !== "analysis")
202
+ return { status: "out_of_scope", path };
203
+ // Throws on an unknown analysis — the loop's dispatch catch maps
204
+ // it to an error tool result, like any unexpected host failure.
205
+ const workspaceRoot = deps.resolveWorkspaceRoot(scope.analysisId);
206
+ const landed = await confinedWrite({
207
+ workspaceRoot,
208
+ analysisId: scope.analysisId,
209
+ workingDir: workspaceRoot,
210
+ path,
211
+ content,
212
+ runStep,
213
+ });
214
+ if (landed.status !== "ok")
215
+ return { status: landed.status, path };
216
+ observe({
217
+ type: "write-file",
218
+ analysisId: scope.analysisId,
219
+ ...(scope.threadId !== undefined ? { threadId: scope.threadId } : {}),
220
+ path: landed.relative,
221
+ hash: computeSha256(landed.bytes),
222
+ size: landed.bytes.length,
223
+ tool: toolName,
224
+ invocationId,
225
+ });
226
+ return { status: "ok", path: landed.agentPath, bytesWritten: landed.bytes.length };
79
227
  },
80
228
  };
81
229
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Internal helper used by dependency-bearing tools to drive one sandbox exec
3
3
  * through the `SandboxClient`. Centralises the submit/await pair, exec-id
4
- * derivation, and intermediate-event forwarding so all three tools share
5
- * one chokepoint. `write_file` / `edit_file` are layered on the workspace
6
- * mutator and environment introspection tools such as `list_available_refs`
7
- * use this same replay-safe submit/await path rather than a parallel protocol.
4
+ * derivation, and intermediate-event forwarding in one chokepoint.
5
+ * `execute_command` and environment introspection tools such as
6
+ * `list_available_refs` use this same replay-safe submit/await path rather
7
+ * than a parallel protocol.
8
8
  *
9
9
  * Not a registered tool — `defineTool` is called by the user-facing tools
10
- * (`execute-command.ts`, `write-file.ts`, `edit-file.ts`) which wrap this.
10
+ * (`execute-command.ts`) which wrap this.
11
11
  */
12
12
  import type { SandboxClient } from "../../sandbox/client.js";
13
13
  import type { ExecResult, SandboxRef } from "../../sandbox/types.js";
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Internal helper used by dependency-bearing tools to drive one sandbox exec
3
3
  * through the `SandboxClient`. Centralises the submit/await pair, exec-id
4
- * derivation, and intermediate-event forwarding so all three tools share
5
- * one chokepoint. `write_file` / `edit_file` are layered on the workspace
6
- * mutator and environment introspection tools such as `list_available_refs`
7
- * use this same replay-safe submit/await path rather than a parallel protocol.
4
+ * derivation, and intermediate-event forwarding in one chokepoint.
5
+ * `execute_command` and environment introspection tools such as
6
+ * `list_available_refs` use this same replay-safe submit/await path rather
7
+ * than a parallel protocol.
8
8
  *
9
9
  * Not a registered tool — `defineTool` is called by the user-facing tools
10
- * (`execute-command.ts`, `write-file.ts`, `edit-file.ts`) which wrap this.
10
+ * (`execute-command.ts`) which wrap this.
11
11
  */
12
12
  export async function runSandboxExec(args) {
13
13
  const body = {
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `write_file` — sandbox-gated workspace write, confined to the agent's
3
- * writable working directory.
2
+ * `write_file` — workspace write, confined to the agent's writable working
3
+ * directory.
4
4
  *
5
5
  * A thin adapter over the `WorkspaceMutator` seam (see the harness-durable-runtime / harness-workspace-tools specs): the
6
- * mutator owns resolve + confine + sandbox write + provenance. This tool only
7
- * declares the input schema and forwards. `edit_file` rides the same seam.
6
+ * mutator owns resolve + confine + hardened host write + provenance. This tool
7
+ * only declares the input schema and forwards. `edit_file` rides the same seam.
8
8
  */
9
9
  import type { WorkspaceMutator } from "./mutator.js";
10
10
  export interface WriteFileDeps {
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `write_file` — sandbox-gated workspace write, confined to the agent's
3
- * writable working directory.
2
+ * `write_file` — workspace write, confined to the agent's writable working
3
+ * directory.
4
4
  *
5
5
  * A thin adapter over the `WorkspaceMutator` seam (see the harness-durable-runtime / harness-workspace-tools specs): the
6
- * mutator owns resolve + confine + sandbox write + provenance. This tool only
7
- * declares the input schema and forwards. `edit_file` rides the same seam.
6
+ * mutator owns resolve + confine + hardened host write + provenance. This tool
7
+ * only declares the input schema and forwards. `edit_file` rides the same seam.
8
8
  */
9
9
  import { ok } from "neverthrow";
10
10
  import { z } from "zod";
@@ -21,7 +21,8 @@ const WriteFileInputSchema = z.object({
21
21
  export function createWriteFileTool(deps) {
22
22
  return defineTool({
23
23
  id: "write_file",
24
- // Body-only `awaitExec`-recv → runs unwrapped in the workflow body (see the harness-tools spec).
24
+ // The mutator wraps the disk mutation in `ctx.runStep` itself, so the
25
+ // body runs unwrapped in the workflow body (see the harness-tools spec).
25
26
  executionMode: "workflow",
26
27
  description: "Write a UTF-8 text file in your working directory. Relative paths " +
27
28
  "resolve against it; a path outside it returns an `out_of_prefix` data " +
@@ -31,6 +32,13 @@ export function createWriteFileTool(deps) {
31
32
  // The path only. `content` is a whole file and must never ride a display
32
33
  // channel, which the emit-site length cap enforces regardless.
33
34
  describeCall: ({ path }) => path,
34
- execute: async ({ path, content }, ctx) => ok(await deps.mutator.writeFile({ path, content, toolName: "write_file", emit: ctx.emit })),
35
+ execute: async ({ path, content }, ctx) => ok(await deps.mutator.writeFile({
36
+ path,
37
+ content,
38
+ toolName: "write_file",
39
+ invocationId: ctx.invocationId,
40
+ runStep: ctx.runStep,
41
+ session: ctx.session,
42
+ })),
35
43
  });
36
44
  }
@@ -155,7 +155,7 @@ export declare function tailWritePrefix(args: {
155
155
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
156
156
  * the sandbox path is the resource id plus the root-relative tail — the host
157
157
  * location of the root never leaks into the container. Single source of truth
158
- * for the `execute_command` / `write_file` cwd and for the working directory
158
+ * for the `execute_command` cwd and for the working directory
159
159
  * a step briefing names (see the harness-workspace-tools spec).
160
160
  */
161
161
  export declare function toSandboxPath(workspaceRoot: string, resourceId: string, hostAbsPath: string): string;
@@ -137,7 +137,7 @@ export function tailWritePrefix(args) {
137
137
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
138
138
  * the sandbox path is the resource id plus the root-relative tail — the host
139
139
  * location of the root never leaks into the container. Single source of truth
140
- * for the `execute_command` / `write_file` cwd and for the working directory
140
+ * for the `execute_command` cwd and for the working directory
141
141
  * a step briefing names (see the harness-workspace-tools spec).
142
142
  */
143
143
  export function toSandboxPath(workspaceRoot, resourceId, hostAbsPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",