@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.
- package/dist/agents/conversation-agent.d.ts +7 -3
- package/dist/agents/conversation-agent.js +20 -2
- package/dist/agents/sandbox/shared.d.ts +3 -2
- package/dist/agents/sandbox/shared.js +3 -9
- package/dist/execution/artifact-record.d.ts +9 -5
- package/dist/execution/artifact-record.js +3 -3
- package/dist/execution/reconcile-manifest.js +25 -1
- package/dist/provenance/collector.js +1 -1
- package/dist/provenance/seam.d.ts +23 -2
- package/dist/provenance/types.d.ts +9 -4
- package/dist/provenance/types.js +7 -2
- package/dist/tools/workspace/edit-file.d.ts +32 -6
- package/dist/tools/workspace/edit-file.js +101 -15
- package/dist/tools/workspace/mutator.d.ts +68 -32
- package/dist/tools/workspace/mutator.js +203 -55
- package/dist/tools/workspace/run-exec.d.ts +5 -5
- package/dist/tools/workspace/run-exec.js +5 -5
- package/dist/tools/workspace/write-file.d.ts +4 -4
- package/dist/tools/workspace/write-file.js +14 -6
- package/dist/workspace/paths.d.ts +1 -1
- package/dist/workspace/paths.js +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
119
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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);
|
|
@@ -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,
|
|
99
|
-
* consumer places it with no lookup. The title sits on the document,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
invocationId: z.ZodString;
|
|
70
75
|
}, z.core.$strip>, z.ZodObject<{
|
|
71
76
|
type: z.ZodLiteral<"command">;
|
|
72
77
|
command: z.ZodString;
|
package/dist/provenance/types.js
CHANGED
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
// ── Producer ────────────────────────────────────────────────────────
|
|
11
|
-
/**
|
|
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
|
-
|
|
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` —
|
|
3
|
-
*
|
|
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.
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
39
|
-
|
|
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` —
|
|
3
|
-
*
|
|
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.
|
|
11
|
-
*
|
|
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
|
|
26
|
-
"
|
|
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
|
-
//
|
|
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.
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* host `fs`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
16
|
-
import type
|
|
17
|
-
|
|
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: "
|
|
43
|
+
} | {
|
|
44
|
+
readonly status: "symlink_denied";
|
|
32
45
|
readonly path: string;
|
|
33
|
-
}
|
|
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
|
|
74
|
-
* invoking tool so a successful write is attributed to it in
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* host `fs`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
27
|
-
const
|
|
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 (
|
|
34
|
-
return { status:
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
// `
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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(
|
|
73
|
-
size:
|
|
169
|
+
path: landed.relative,
|
|
170
|
+
hash: computeSha256(landed.bytes),
|
|
171
|
+
size: landed.bytes.length,
|
|
74
172
|
toolName,
|
|
75
|
-
|
|
173
|
+
invocationId,
|
|
76
174
|
});
|
|
77
175
|
}
|
|
78
|
-
return { status: "ok", path:
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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` —
|
|
3
|
-
*
|
|
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 +
|
|
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` —
|
|
3
|
-
*
|
|
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 +
|
|
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
|
-
//
|
|
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({
|
|
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`
|
|
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;
|
package/dist/workspace/paths.js
CHANGED
|
@@ -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`
|
|
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