@yagni-app/code-staging 1.1.2-staging.1368.1 → 1.1.2-staging.1370.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension/branding.d.ts +23 -0
- package/dist/extension/branding.js +36 -0
- package/dist/extension/condensedTools.js +63 -2
- package/dist/extension/fileGuards.d.ts +84 -0
- package/dist/extension/fileGuards.js +210 -0
- package/dist/extension/index.js +14 -2
- package/dist/extension/sandbox/bash.d.ts +52 -1
- package/dist/extension/sandbox/bash.js +117 -6
- package/dist/extension/sandbox/session.d.ts +4 -1
- package/dist/extension/sandbox/session.js +62 -6
- package/package.json +2 -2
|
@@ -72,6 +72,19 @@ export declare const TICKET_IMAGE_RULE: string;
|
|
|
72
72
|
* the standalone word "pi", must not open with a `- ` bullet line, no emojis.
|
|
73
73
|
*/
|
|
74
74
|
export declare const GITHUB_OPERATIONS: string;
|
|
75
|
+
/**
|
|
76
|
+
* File-tool steering: prefer the first-class write/edit tools over bash file
|
|
77
|
+
* writes, one write call per file instead of a heredoc batch, and never create
|
|
78
|
+
* documentation files unless asked. Mirrors Claude Code's "Using your tools"
|
|
79
|
+
* preference block plus its write-tool description guidance. The observed
|
|
80
|
+
* bash — the model traded tool-call count against the known-safe path — so
|
|
81
|
+
* the multi-file line steers that incentive back to one write call per file.
|
|
82
|
+
*
|
|
83
|
+
* Content constraints (parity with {@link GITHUB_OPERATIONS}): must not
|
|
84
|
+
* contain the standalone word "pi", must not open with a `- ` bullet line,
|
|
85
|
+
* no emojis, no ticket ids.
|
|
86
|
+
*/
|
|
87
|
+
export declare const FILE_TOOL_STEERING: string;
|
|
75
88
|
/**
|
|
76
89
|
* The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
|
|
77
90
|
* carries this exact sentence in every system prompt so its whole reminder
|
|
@@ -147,6 +160,16 @@ export interface BrandSystemPromptOptions {
|
|
|
147
160
|
* attribution talk at all.
|
|
148
161
|
*/
|
|
149
162
|
attributionSection?: string | null;
|
|
163
|
+
/**
|
|
164
|
+
* Inject the file-tool steering section (write over bash writes, edit for
|
|
165
|
+
* modifications). Set ONLY where the file-tool guards it promises are
|
|
166
|
+
* actually registered — the condensed-tools registration in index.ts. An
|
|
167
|
+
* opt-in flag rather than a section string because the mismatch it guards
|
|
168
|
+
* against (a prompt saying "this tool will fail without a read" on a
|
|
169
|
+
* surface with no guard — eval mode, desktop, classic rows) is an
|
|
170
|
+
* all-or-nothing per-surface call, not user policy.
|
|
171
|
+
*/
|
|
172
|
+
fileToolSteering?: boolean;
|
|
150
173
|
}
|
|
151
174
|
/**
|
|
152
175
|
* Rebrand pi's assembled system prompt as YAGNI Code's, and optionally inject a
|
|
@@ -155,6 +155,35 @@ export const GITHUB_OPERATIONS = "## GitHub Operations\n" +
|
|
|
155
155
|
"multi-paragraph.";
|
|
156
156
|
/** Stable header that starts the GitHub operations section (idempotency anchor). */
|
|
157
157
|
const GITHUB_OPERATIONS_HEADER = "## GitHub Operations";
|
|
158
|
+
/**
|
|
159
|
+
* File-tool steering: prefer the first-class write/edit tools over bash file
|
|
160
|
+
* writes, one write call per file instead of a heredoc batch, and never create
|
|
161
|
+
* documentation files unless asked. Mirrors Claude Code's "Using your tools"
|
|
162
|
+
* preference block plus its write-tool description guidance. The observed
|
|
163
|
+
* bash — the model traded tool-call count against the known-safe path — so
|
|
164
|
+
* the multi-file line steers that incentive back to one write call per file.
|
|
165
|
+
*
|
|
166
|
+
* Content constraints (parity with {@link GITHUB_OPERATIONS}): must not
|
|
167
|
+
* contain the standalone word "pi", must not open with a `- ` bullet line,
|
|
168
|
+
* no emojis, no ticket ids.
|
|
169
|
+
*/
|
|
170
|
+
export const FILE_TOOL_STEERING = "## File tools over bash file writes\n" +
|
|
171
|
+
"\n" +
|
|
172
|
+
"Prefer the first-class file tools over bash for every file change:\n" +
|
|
173
|
+
"\n" +
|
|
174
|
+
"* Use `write` to create files, never `cat >` with a heredoc or `echo >`\n" +
|
|
175
|
+
" redirection. When several files must be written, issue one `write` call per\n" +
|
|
176
|
+
" file rather than batching them into a single bash heredoc.\n" +
|
|
177
|
+
"* Use `edit` to modify existing files — `write` is for new files and complete\n" +
|
|
178
|
+
" rewrites, not for changes to files you have read.\n" +
|
|
179
|
+
"* NEVER create documentation files (*.md) or README files unless the user\n" +
|
|
180
|
+
" explicitly asks for one.\n" +
|
|
181
|
+
"\n" +
|
|
182
|
+
"Reserve bash for system commands and terminal operations that require shell\n" +
|
|
183
|
+
"execution. Using the file tools lets the user review your work and keeps the\n" +
|
|
184
|
+
"permission path silent; a bash write escapes both.";
|
|
185
|
+
/** Stable header that starts the file-tool steering section (idempotency anchor). */
|
|
186
|
+
const FILE_TOOL_STEERING_HEADER = "## File tools over bash file writes";
|
|
158
187
|
/**
|
|
159
188
|
* The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
|
|
160
189
|
* carries this exact sentence in every system prompt so its whole reminder
|
|
@@ -345,6 +374,13 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
345
374
|
if (!s.includes(GITHUB_OPERATIONS_HEADER)) {
|
|
346
375
|
s = `${s}\n\n${GITHUB_OPERATIONS}`;
|
|
347
376
|
}
|
|
377
|
+
// 5b2b. File-tool steering — same standing-directive placement and
|
|
378
|
+
// idempotency shape as GitHub operations (guarded by its stable header),
|
|
379
|
+
// but gated on the caller confirming the guards it promises are live
|
|
380
|
+
// (see fileToolSteering's doc).
|
|
381
|
+
if (opts.fileToolSteering && !s.includes(FILE_TOOL_STEERING_HEADER)) {
|
|
382
|
+
s = `${s}\n\n${FILE_TOOL_STEERING}`;
|
|
383
|
+
}
|
|
348
384
|
// 5b3. Commit / PR attribution — a standing directive next to the GitHub
|
|
349
385
|
// operations it qualifies (same placement, same idempotency shape, keyed on
|
|
350
386
|
// the section's stable first line). Settings-driven: the section is absent
|
|
@@ -28,6 +28,7 @@ import * as os from "node:os";
|
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getAgentDir, getLanguageFromPath, highlightCode, renderDiff, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
31
|
+
import { ReadStateTracker, READ_FIRST_ERROR, readIsPartial, readTextOf, STALE_ERROR } from "./fileGuards.js";
|
|
31
32
|
import { isQuiet, kindForTool, ToolRunTracker } from "./toolRuns.js";
|
|
32
33
|
/** Lines of write content shown collapsed (mirrors Claude Code's preview). */
|
|
33
34
|
export const WRITE_PREVIEW_LINES = 8;
|
|
@@ -55,6 +56,19 @@ const TITLE_BY_NAME = {
|
|
|
55
56
|
find: "Find",
|
|
56
57
|
ls: "List",
|
|
57
58
|
};
|
|
59
|
+
/** Claude Code's write-tool description guidance, adapted to our arg names. */
|
|
60
|
+
const WRITE_DESCRIPTION = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. " +
|
|
61
|
+
"Automatically creates parent directories.\n" +
|
|
62
|
+
"Usage:\n" +
|
|
63
|
+
"- If this is an existing file, you MUST use the read tool first to read the file's contents. " +
|
|
64
|
+
"This tool will fail if you did not read the file first.\n" +
|
|
65
|
+
"- Prefer the edit tool for modifying existing files — it only sends the diff. Only use this tool " +
|
|
66
|
+
"to create new files or for complete rewrites.\n" +
|
|
67
|
+
"- NEVER create documentation files (*.md) or README files unless explicitly requested by the user.";
|
|
68
|
+
/** Claude Code's edit-tool pre-read instruction, adapted. */
|
|
69
|
+
const EDIT_DESCRIPTION_APPENDIX = "\nUsage:\n" +
|
|
70
|
+
"- You must use the read tool at least once in the conversation before editing. This tool will " +
|
|
71
|
+
"error if you attempt an edit without reading the file.";
|
|
58
72
|
/** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
|
|
59
73
|
function clip(text, max) {
|
|
60
74
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
@@ -257,6 +271,10 @@ function textOf(result) {
|
|
|
257
271
|
function hasImages(result) {
|
|
258
272
|
return (result?.content ?? []).some((c) => c.type === "image");
|
|
259
273
|
}
|
|
274
|
+
/** A failed tool result carries no fresh state to record. */
|
|
275
|
+
function isErrorResult(result) {
|
|
276
|
+
return result?.isError === true;
|
|
277
|
+
}
|
|
260
278
|
function empty() {
|
|
261
279
|
return new Container();
|
|
262
280
|
}
|
|
@@ -318,6 +336,11 @@ export function registerCondensedTools(pi, deps = {}) {
|
|
|
318
336
|
return defs;
|
|
319
337
|
};
|
|
320
338
|
definitionsByCwd.set(registrationCwd, buildDefinitions(registrationCwd));
|
|
339
|
+
// Read-first + staleness guards (Claude Code's FileWriteTool/FileEditTool
|
|
340
|
+
// parity): one session-scoped tracker records what `read` last returned, and
|
|
341
|
+
// write/edit refuse to overwrite an existing unread or changed file. The
|
|
342
|
+
// scratchpad is exempt so permission-free scratchpad writes stay silent.
|
|
343
|
+
const readState = new ReadStateTracker(deps.scratchpadDir);
|
|
321
344
|
for (const name of BUILTIN_NAMES) {
|
|
322
345
|
const base = definitionsFor(registrationCwd)[name];
|
|
323
346
|
const kind = kindForTool(name);
|
|
@@ -333,10 +356,48 @@ export function registerCondensedTools(pi, deps = {}) {
|
|
|
333
356
|
};
|
|
334
357
|
pi.registerTool({
|
|
335
358
|
...base,
|
|
359
|
+
...(name === "write" ? { description: WRITE_DESCRIPTION } : {}),
|
|
360
|
+
...(name === "edit" ? { description: `${base.description}${EDIT_DESCRIPTION_APPENDIX}` } : {}),
|
|
336
361
|
renderShell: "self",
|
|
337
|
-
execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
362
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
338
363
|
const cwd = ctx?.cwd ?? registrationCwd;
|
|
339
|
-
|
|
364
|
+
const args = params;
|
|
365
|
+
const target = typeof args?.path === "string" ? args.path : undefined;
|
|
366
|
+
// write/edit: refuse unread or stale overwrites, then refresh the
|
|
367
|
+
// read state so consecutive writes/edits keep working. A guard fs
|
|
368
|
+
// error that is not one of the two refusals fails OPEN (delegate);
|
|
369
|
+
// pi's own execute produces the error the model would have seen.
|
|
370
|
+
if (target && (name === "write" || name === "edit")) {
|
|
371
|
+
try {
|
|
372
|
+
await readState.assertFreshRead(target, cwd);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (error instanceof Error && (error.message === READ_FIRST_ERROR || error.message === STALE_ERROR)) {
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
// Not a refusal — delegate and let pi's execute handle it.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const result = await definitionsFor(cwd)[name].execute(toolCallId, params, signal, onUpdate, ctx);
|
|
382
|
+
// read: record what the model saw. write/edit: the just-written
|
|
383
|
+
// state is the fresh truth. AWAITED, not fire-and-forget — a follow-up
|
|
384
|
+
// write/edit in the same burst must not race a pending refresh and
|
|
385
|
+
// get refused as stale (Claude Code updates readFileState before the
|
|
386
|
+
// tool call returns; the captures are one stat and at most one small
|
|
387
|
+
// read, and each fails soft internally). A FAILED read records
|
|
388
|
+
// nothing — its content is error text, not the file — and must not
|
|
389
|
+
// clobber a prior good record of the same path.
|
|
390
|
+
if (name === "read" && target && !isErrorResult(result)) {
|
|
391
|
+
const partial = readIsPartial({ path: args.path, offset: args.offset, limit: args.limit }, result.details ?? undefined);
|
|
392
|
+
await readState.recordRead(target, cwd, readTextOf(result), partial);
|
|
393
|
+
}
|
|
394
|
+
else if (name === "write" && target && typeof args.content === "string" && !isErrorResult(result)) {
|
|
395
|
+
await readState.recordWrite(target, cwd, args.content);
|
|
396
|
+
}
|
|
397
|
+
else if (name === "edit" && target && !isErrorResult(result)) {
|
|
398
|
+
await readState.refreshFromDisk(target, cwd);
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
340
401
|
},
|
|
341
402
|
renderCall(args, theme, context) {
|
|
342
403
|
const slice = context;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-first + staleness guards for the `write` and `edit` file tools —
|
|
3
|
+
* Claude Code's FileWriteTool/FileEditTool safety level, adapted to pi's
|
|
4
|
+
* tool shapes.
|
|
5
|
+
*
|
|
6
|
+
* A session-scoped `ReadStateTracker` records what the `read` tool last
|
|
7
|
+
* returned per canonical path (fs.realpath where it exists, path.resolve
|
|
8
|
+
* otherwise). `write`/`edit` refuse to overwrite an existing file the
|
|
9
|
+
* model has not read in this session, and refuse again when the file
|
|
10
|
+
* changed on disk since that read (mtime, with a content-compare fallback
|
|
11
|
+
* so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
|
|
12
|
+
* does not produce a false refusal on a full read). New-file creates and
|
|
13
|
+
* partial reads follow Claude Code exactly: a create needs no prior read;
|
|
14
|
+
* an offset/limit or truncated read does NOT satisfy the read-first
|
|
15
|
+
* requirement (`isPartialView`).
|
|
16
|
+
*
|
|
17
|
+
* Fail-open contract: any guard-internal filesystem error that is not one
|
|
18
|
+
* of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
|
|
19
|
+
* straight to the underlying pi execute, which produces its own error.
|
|
20
|
+
* The guard must never invent a refusal pi wouldn't.
|
|
21
|
+
*/
|
|
22
|
+
/** Claude Code's exact refusal, errorCode 2. */
|
|
23
|
+
export declare const READ_FIRST_ERROR = "File has not been read yet. Read it first before writing to it.";
|
|
24
|
+
/** Claude Code's exact refusal, errorCode 3. */
|
|
25
|
+
export declare const STALE_ERROR = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
|
|
26
|
+
/** A read result's details slice, as pi's read tool emits it. */
|
|
27
|
+
interface ReadDetails {
|
|
28
|
+
truncation?: {
|
|
29
|
+
truncated?: boolean;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface ReadArgs {
|
|
33
|
+
path?: unknown;
|
|
34
|
+
offset?: unknown;
|
|
35
|
+
limit?: unknown;
|
|
36
|
+
}
|
|
37
|
+
/** Whether the read call viewed only a slice of the file. */
|
|
38
|
+
export declare function readIsPartial(args: ReadArgs, details: ReadDetails | undefined): boolean;
|
|
39
|
+
/** The text content of a read tool result, empty when it carries none. */
|
|
40
|
+
export declare function readTextOf(result: {
|
|
41
|
+
content?: Array<{
|
|
42
|
+
type: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
}>;
|
|
45
|
+
} | undefined): string;
|
|
46
|
+
/**
|
|
47
|
+
* Session read state. One instance per registration (created by
|
|
48
|
+
* registerCondensedTools), so driver, `/go` stage children, and subagents
|
|
49
|
+
* each track their own session's reads — matching Claude Code's
|
|
50
|
+
* per-conversation readFileState.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ReadStateTracker {
|
|
53
|
+
/** Session scratchpad dir; paths under it are exempt from the guards. */
|
|
54
|
+
private readonly scratchpadDir;
|
|
55
|
+
/** Stat/read seams for tests. */
|
|
56
|
+
private readonly io;
|
|
57
|
+
private reads;
|
|
58
|
+
constructor(
|
|
59
|
+
/** Session scratchpad dir; paths under it are exempt from the guards. */
|
|
60
|
+
scratchpadDir: string | undefined,
|
|
61
|
+
/** Stat/read seams for tests. */
|
|
62
|
+
io?: {
|
|
63
|
+
stat?: (p: string) => Promise<{
|
|
64
|
+
mtimeMs: number;
|
|
65
|
+
}>;
|
|
66
|
+
readFile?: (p: string) => Promise<string>;
|
|
67
|
+
});
|
|
68
|
+
/** Record a successful read of `rawPath` (resolved against `cwd`). */
|
|
69
|
+
recordRead(rawPath: string, cwd: string, text: string, partial: boolean): Promise<void>;
|
|
70
|
+
/** Record a successful write (the written content is the fresh truth). */
|
|
71
|
+
recordWrite(rawPath: string, cwd: string, content: string): Promise<void>;
|
|
72
|
+
/** Refresh a record from disk after a successful edit (content unknown to us). */
|
|
73
|
+
refreshFromDisk(rawPath: string, cwd: string): Promise<void>;
|
|
74
|
+
private capture;
|
|
75
|
+
/**
|
|
76
|
+
* Throw one of the two refusals when the write/edit must not proceed.
|
|
77
|
+
* Resolves (returns) when the overwrite may continue; rethrows any fs
|
|
78
|
+
* error it did not classify, so the wrapper can fail open.
|
|
79
|
+
*/
|
|
80
|
+
assertFreshRead(rawPath: string, cwd: string): Promise<void>;
|
|
81
|
+
private isInside;
|
|
82
|
+
}
|
|
83
|
+
export {};
|
|
84
|
+
//# sourceMappingURL=fileGuards.d.ts.map
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-first + staleness guards for the `write` and `edit` file tools —
|
|
3
|
+
* Claude Code's FileWriteTool/FileEditTool safety level, adapted to pi's
|
|
4
|
+
* tool shapes.
|
|
5
|
+
*
|
|
6
|
+
* A session-scoped `ReadStateTracker` records what the `read` tool last
|
|
7
|
+
* returned per canonical path (fs.realpath where it exists, path.resolve
|
|
8
|
+
* otherwise). `write`/`edit` refuse to overwrite an existing file the
|
|
9
|
+
* model has not read in this session, and refuse again when the file
|
|
10
|
+
* changed on disk since that read (mtime, with a content-compare fallback
|
|
11
|
+
* so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
|
|
12
|
+
* does not produce a false refusal on a full read). New-file creates and
|
|
13
|
+
* partial reads follow Claude Code exactly: a create needs no prior read;
|
|
14
|
+
* an offset/limit or truncated read does NOT satisfy the read-first
|
|
15
|
+
* requirement (`isPartialView`).
|
|
16
|
+
*
|
|
17
|
+
* Fail-open contract: any guard-internal filesystem error that is not one
|
|
18
|
+
* of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
|
|
19
|
+
* straight to the underlying pi execute, which produces its own error.
|
|
20
|
+
* The guard must never invent a refusal pi wouldn't.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs/promises";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { logEvent } from "./errorSink.js";
|
|
25
|
+
/** Claude Code's exact refusal, errorCode 2. */
|
|
26
|
+
export const READ_FIRST_ERROR = "File has not been read yet. Read it first before writing to it.";
|
|
27
|
+
/** Claude Code's exact refusal, errorCode 3. */
|
|
28
|
+
export const STALE_ERROR = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
|
|
29
|
+
/** Whether the read call viewed only a slice of the file. */
|
|
30
|
+
export function readIsPartial(args, details) {
|
|
31
|
+
if (args.offset !== undefined || args.limit !== undefined)
|
|
32
|
+
return true;
|
|
33
|
+
return details?.truncation?.truncated === true;
|
|
34
|
+
}
|
|
35
|
+
/** The text content of a read tool result, empty when it carries none. */
|
|
36
|
+
export function readTextOf(result) {
|
|
37
|
+
return (result?.content ?? [])
|
|
38
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
39
|
+
.map((c) => c.text)
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
/** Canonical key: fs.realpath where the entry exists (symlinks resolved,
|
|
43
|
+
* so two spellings of one file share a record and a symlink cannot smuggle
|
|
44
|
+
* the scratchpad exemption to an outside file). When the entry does not
|
|
45
|
+
* exist: a DANGLING symlink keys as its eventual target (a link inside a
|
|
46
|
+
* privileged dir whose target is outside must not carry the exemption to
|
|
47
|
+
* the file a write would create), and a genuinely missing tail keys as the
|
|
48
|
+
* canonicalized parent plus the basename (a not-yet-created target). */
|
|
49
|
+
async function canonicalKey(absolute, depth = 0) {
|
|
50
|
+
if (depth > 32) {
|
|
51
|
+
// A symlink chain too deep to resolve (a loop, or just a legitimately
|
|
52
|
+
// deep chain): the key falls back to the raw path (treated as an
|
|
53
|
+
// ordinary unread target — fail-safe) and the trip reaches the trail
|
|
54
|
+
// with the depth, so a confusing refusal on such a path is diagnosable —
|
|
55
|
+
// same intent as the sibling catch trails. "depth" not "loop": the cap
|
|
56
|
+
// fires for deep non-looping chains too.
|
|
57
|
+
logEvent({
|
|
58
|
+
source: "fileGuards",
|
|
59
|
+
level: "info",
|
|
60
|
+
event: "canonical_key_depth_guard",
|
|
61
|
+
fields: { depth },
|
|
62
|
+
});
|
|
63
|
+
return absolute;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return await fs.realpath(absolute);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
const stat = await fs.lstat(absolute).catch(() => null);
|
|
70
|
+
if (stat?.isSymbolicLink()) {
|
|
71
|
+
const target = await fs.readlink(absolute);
|
|
72
|
+
return canonicalKey(path.resolve(path.dirname(absolute), target), depth + 1);
|
|
73
|
+
}
|
|
74
|
+
const parent = path.dirname(absolute);
|
|
75
|
+
if (parent === absolute)
|
|
76
|
+
return absolute; // filesystem root
|
|
77
|
+
return path.join(await canonicalKey(parent, depth), path.basename(absolute));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Default disk read, typed as string (fs.readFile's overload returns a
|
|
81
|
+
* buffer union the tracker's seam does not). */
|
|
82
|
+
async function readTextFile(p) {
|
|
83
|
+
return (await fs.readFile(p)).toString("utf-8");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Session read state. One instance per registration (created by
|
|
87
|
+
* registerCondensedTools), so driver, `/go` stage children, and subagents
|
|
88
|
+
* each track their own session's reads — matching Claude Code's
|
|
89
|
+
* per-conversation readFileState.
|
|
90
|
+
*/
|
|
91
|
+
export class ReadStateTracker {
|
|
92
|
+
scratchpadDir;
|
|
93
|
+
io;
|
|
94
|
+
reads = new Map();
|
|
95
|
+
constructor(
|
|
96
|
+
/** Session scratchpad dir; paths under it are exempt from the guards. */
|
|
97
|
+
scratchpadDir,
|
|
98
|
+
/** Stat/read seams for tests. */
|
|
99
|
+
io = {}) {
|
|
100
|
+
this.scratchpadDir = scratchpadDir;
|
|
101
|
+
this.io = io;
|
|
102
|
+
}
|
|
103
|
+
/** Record a successful read of `rawPath` (resolved against `cwd`). */
|
|
104
|
+
async recordRead(rawPath, cwd, text, partial) {
|
|
105
|
+
return this.capture(rawPath, cwd, { content: text, partial });
|
|
106
|
+
}
|
|
107
|
+
/** Record a successful write (the written content is the fresh truth). */
|
|
108
|
+
async recordWrite(rawPath, cwd, content) {
|
|
109
|
+
return this.capture(rawPath, cwd, { content, partial: false });
|
|
110
|
+
}
|
|
111
|
+
/** Refresh a record from disk after a successful edit (content unknown to us). */
|
|
112
|
+
async refreshFromDisk(rawPath, cwd) {
|
|
113
|
+
return this.capture(rawPath, cwd, {});
|
|
114
|
+
}
|
|
115
|
+
async capture(rawPath, cwd, known) {
|
|
116
|
+
const absolute = path.resolve(cwd, rawPath);
|
|
117
|
+
try {
|
|
118
|
+
const stat = await (this.io.stat ?? fs.stat)(absolute);
|
|
119
|
+
const content = known.content ?? await (this.io.readFile ?? readTextFile)(absolute);
|
|
120
|
+
// Floor at RECORD time as well as compare time (Claude Code's shape):
|
|
121
|
+
// a full-precision recorded mtime (ns fraction) would always exceed the
|
|
122
|
+
// floored current one and the staleness check would never fire.
|
|
123
|
+
const key = await canonicalKey(absolute);
|
|
124
|
+
this.reads.set(key, {
|
|
125
|
+
content,
|
|
126
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
127
|
+
partial: known.partial ?? false,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// The file vanished between the tool result and the capture, or stat
|
|
132
|
+
// failed — the record stays absent and the NEXT write treats the file
|
|
133
|
+
// as unread. Fail-safe, never fail-loud — but never silent either:
|
|
134
|
+
// info (not debug — the durable trail filters debug out of a default
|
|
135
|
+
// session) so a later "unread" refusal after a successful read is
|
|
136
|
+
// diagnosable without a repro.
|
|
137
|
+
logEvent({
|
|
138
|
+
source: "fileGuards",
|
|
139
|
+
level: "info",
|
|
140
|
+
event: "record_capture_failed",
|
|
141
|
+
fields: { code: error?.code ?? "unknown" },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Throw one of the two refusals when the write/edit must not proceed.
|
|
147
|
+
* Resolves (returns) when the overwrite may continue; rethrows any fs
|
|
148
|
+
* error it did not classify, so the wrapper can fail open.
|
|
149
|
+
*/
|
|
150
|
+
async assertFreshRead(rawPath, cwd) {
|
|
151
|
+
const absolute = path.resolve(cwd, rawPath);
|
|
152
|
+
const key = await canonicalKey(absolute);
|
|
153
|
+
if (this.scratchpadDir) {
|
|
154
|
+
const scratchKey = await canonicalKey(path.resolve(this.scratchpadDir));
|
|
155
|
+
if (this.isInside(key, scratchKey)) {
|
|
156
|
+
return; // permission-free scratchpad writes stay silent (never refuse)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let mtimeMs;
|
|
160
|
+
try {
|
|
161
|
+
mtimeMs = (await (this.io.stat ?? fs.stat)(key)).mtimeMs;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const code = error?.code;
|
|
165
|
+
if (code === "ENOENT")
|
|
166
|
+
return; // a new-file create — no prior read needed
|
|
167
|
+
throw error; // unknown fs failure — the wrapper fails open
|
|
168
|
+
}
|
|
169
|
+
const record = this.reads.get(key);
|
|
170
|
+
if (!record || record.partial) {
|
|
171
|
+
throw new Error(READ_FIRST_ERROR);
|
|
172
|
+
}
|
|
173
|
+
if (Math.floor(mtimeMs) > record.mtimeMs) {
|
|
174
|
+
// Timestamp moved, but on some platforms (cloud sync, antivirus) it
|
|
175
|
+
// moves without content changes. For a full read, compare content
|
|
176
|
+
// before refusing — Claude Code's exact fallback. The record holds
|
|
177
|
+
// the read tool's exact output and pi returns file bytes verbatim,
|
|
178
|
+
// so identical content compares byte-equal.
|
|
179
|
+
let onDisk;
|
|
180
|
+
try {
|
|
181
|
+
onDisk = await (this.io.readFile ?? readTextFile)(key);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
// Unreadable now, but the read succeeded then — stale — but the real
|
|
185
|
+
// cause (EACCES/EIO on the re-read, not an edit) rides the trail so
|
|
186
|
+
// the refusal is not misdiagnosed as a content change.
|
|
187
|
+
logEvent({
|
|
188
|
+
source: "fileGuards",
|
|
189
|
+
level: "info",
|
|
190
|
+
event: "staleness_reread_failed",
|
|
191
|
+
fields: { code: error?.code ?? "unknown" },
|
|
192
|
+
});
|
|
193
|
+
throw new Error(STALE_ERROR);
|
|
194
|
+
}
|
|
195
|
+
if (onDisk !== record.content) {
|
|
196
|
+
throw new Error(STALE_ERROR);
|
|
197
|
+
}
|
|
198
|
+
// Content-identical: refresh the record so the next write's mtime
|
|
199
|
+
// comparison uses the bumped timestamp instead of refusing again.
|
|
200
|
+
this.reads.set(key, { ...record, mtimeMs: Math.floor(mtimeMs) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
isInside(absolute, base) {
|
|
204
|
+
const slashed = (p) => p.replace(/\\/g, "/");
|
|
205
|
+
const a = slashed(absolute);
|
|
206
|
+
const b = slashed(base).replace(/\/+$/, "");
|
|
207
|
+
return a === b || a.startsWith(`${b}/`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=fileGuards.js.map
|
package/dist/extension/index.js
CHANGED
|
@@ -1248,8 +1248,18 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1248
1248
|
// eval mode keeps pi's stock registration (measured behavior stays
|
|
1249
1249
|
// byte-identical) and the desktop surface keeps the structured pipeline.
|
|
1250
1250
|
// YAGNI_CLASSIC_TOOL_ROWS=1 is the debugging escape hatch back to pi's
|
|
1251
|
-
// boxed renderers.
|
|
1252
|
-
|
|
1251
|
+
// boxed renderers. The file-tool guards and the guarded write/edit
|
|
1252
|
+
// descriptions ride this SAME registration, so the steering prompt section
|
|
1253
|
+
// (which promises those guards) is injected only when this is active —
|
|
1254
|
+
// eval mode stays byte-identical and desktop never gets a promise the
|
|
1255
|
+
// tools there don't keep.
|
|
1256
|
+
const condensedToolsWanted = !evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1";
|
|
1257
|
+
// The steering flag tracks the registration OUTCOME, not the condition:
|
|
1258
|
+
// the catch below keeps activation alive when registration throws, and a
|
|
1259
|
+
// throw means the guards never went live — the prompt section that
|
|
1260
|
+
// promises them must not ship either.
|
|
1261
|
+
let condensedToolsRegistered = false;
|
|
1262
|
+
if (condensedToolsWanted) {
|
|
1253
1263
|
try {
|
|
1254
1264
|
registerCondensedTools(pi, {
|
|
1255
1265
|
...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
|
|
@@ -1258,6 +1268,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1258
1268
|
// the sandbox is off.
|
|
1259
1269
|
...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
|
|
1260
1270
|
});
|
|
1271
|
+
condensedToolsRegistered = true;
|
|
1261
1272
|
}
|
|
1262
1273
|
catch {
|
|
1263
1274
|
// Rendering must never break activation; pi's built-ins remain.
|
|
@@ -1307,6 +1318,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1307
1318
|
rulesSection,
|
|
1308
1319
|
scratchpadSection: scratchpadSectionText,
|
|
1309
1320
|
attributionSection: attributionPromptSection(attribution()),
|
|
1321
|
+
fileToolSteering: condensedToolsRegistered,
|
|
1310
1322
|
}),
|
|
1311
1323
|
});
|
|
1312
1324
|
// Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
|
|
@@ -127,13 +127,64 @@ export declare function shouldUseSandboxForUserCommand(command: string, manager:
|
|
|
127
127
|
* advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
|
|
128
128
|
* hint (no annotation context, and the sink is not wired yet).
|
|
129
129
|
*/
|
|
130
|
-
export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string
|
|
130
|
+
export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string, fsScope?: {
|
|
131
|
+
cwd: string;
|
|
132
|
+
scope: FsDenialScope;
|
|
133
|
+
}): {
|
|
131
134
|
text: string;
|
|
132
135
|
net: {
|
|
133
136
|
cls: NetworkDenialClass;
|
|
134
137
|
hint: string;
|
|
135
138
|
} | null;
|
|
139
|
+
fs: {
|
|
140
|
+
cls: FsDenialClass;
|
|
141
|
+
hint: string;
|
|
142
|
+
} | null;
|
|
136
143
|
};
|
|
144
|
+
/** The filesystem denial classes this classifier knows (closed set — rides
|
|
145
|
+
* the sink line as a low-cardinality field, the same contract as
|
|
146
|
+
* NetworkDenialClass). write-scope-protected is a distinct class (not a
|
|
147
|
+
* sub-case) because its REMEDY differs: protected paths can never be
|
|
148
|
+
* admitted with an allow rule, so the hint must not point at the knob. */
|
|
149
|
+
export type FsDenialClass = "write-scope" | "write-scope-protected" | "heredoc-cwd";
|
|
150
|
+
/** The writable/denied root lists the fs classifier needs, passed in by the
|
|
151
|
+
* caller so the classifier stays pure (the established pattern — see
|
|
152
|
+
* networkDenialHint). allowWrite: the sandbox's writable roots (session cwd,
|
|
153
|
+
* the temp dirs, config allowWrite). denyWrite: the protected paths that are
|
|
154
|
+
* denied even when a wider allow would cover them (config files, state dirs). */
|
|
155
|
+
export interface FsDenialScope {
|
|
156
|
+
allowWrite: readonly string[];
|
|
157
|
+
denyWrite: readonly string[];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Classify a sandbox filesystem denial from command + output. Pure;
|
|
161
|
+
* advisory-only (the fs twin of networkDenialHint — never widens anything,
|
|
162
|
+
* the remedy is model-side or a user config edit, never a grant). Matches
|
|
163
|
+
* the signatures observed live and in the session review:
|
|
164
|
+
* - heredoc-cwd: `cannot create temp file for here document` /
|
|
165
|
+
* `/dev/fd/NN: Operation not permitted` — bash 3.2
|
|
166
|
+
* writes heredoc temp files relative to the CURRENT
|
|
167
|
+
* directory, so a cd outside the writable roots makes
|
|
168
|
+
* every heredoc fail even when the target is writable
|
|
169
|
+
* (reproduced live; the e2b /dev/fd/62 case is the
|
|
170
|
+
* process-substitution sibling of the same mechanism)
|
|
171
|
+
* - write-scope: an EPERM whose line names a write TARGET path
|
|
172
|
+
* outside the writable roots — the literal `/tmp`
|
|
173
|
+
* writes observed in the sessions (`cat > /tmp/x`,
|
|
174
|
+
* `> /tmp/after.txt`, python PermissionError on a
|
|
175
|
+
* non-root path)
|
|
176
|
+
* - write-scope-protected: same shape but the target is a PROTECTED path
|
|
177
|
+
* (denyWrite) — the config.json / state-dir denies.
|
|
178
|
+
* Protected paths can never be admitted with allow
|
|
179
|
+
* rules, so the hint copy differs.
|
|
180
|
+
* Returns null when the output carries no fs-denial signature — a network
|
|
181
|
+
* denial must NOT get an fs hint (the pinned negative in networkDenialHint
|
|
182
|
+
* is symmetric here).
|
|
183
|
+
*/
|
|
184
|
+
export declare function fsDenialHint(command: string, output: string, cwd: string, scope: FsDenialScope): {
|
|
185
|
+
cls: FsDenialClass;
|
|
186
|
+
hint: string;
|
|
187
|
+
} | null;
|
|
137
188
|
/** cwd must exist before spawn (guard borrowed from pi's local ops). */
|
|
138
189
|
export declare function assertSpawnableCwd(cwd: string): void;
|
|
139
190
|
/**
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* lessons (process-group kill, stdio release).
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
24
25
|
import { logEvent } from "../errorSink.js";
|
|
25
26
|
import { scrubSecrets } from "../pipeline/scrubSecrets.js";
|
|
26
27
|
function stripLeadingSafeEnvVars(command) {
|
|
@@ -347,18 +348,128 @@ export function shouldUseSandboxForUserCommand(command, manager, settings) {
|
|
|
347
348
|
* advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
|
|
348
349
|
* hint (no annotation context, and the sink is not wired yet).
|
|
349
350
|
*/
|
|
350
|
-
export function annotateCommandOutput(manager, command, output) {
|
|
351
|
+
export function annotateCommandOutput(manager, command, output, fsScope) {
|
|
351
352
|
if (!manager.initialized)
|
|
352
|
-
return { text: output, net: null };
|
|
353
|
+
return { text: output, net: null, fs: null };
|
|
353
354
|
const net = networkDenialHint(output);
|
|
354
355
|
if (!output.includes("Operation not permitted") && !net)
|
|
355
|
-
return { text: output, net: null };
|
|
356
|
+
return { text: output, net: null, fs: null };
|
|
356
357
|
const annotated = output.includes("Operation not permitted")
|
|
357
358
|
? manager.annotateStderrWithSandboxFailures(command, output)
|
|
358
359
|
: output;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
360
|
+
// The fs classification: same regex surface, same composition position,
|
|
361
|
+
// but the NETWORK hint wins when both classify (network denials have a
|
|
362
|
+
// user-flippable knob; fs remedies are model-side). When net wins, fs
|
|
363
|
+
// returns null — the callers log BOTH returns unconditionally, so a
|
|
364
|
+
// non-null loser would emit a second census line for one denial.
|
|
365
|
+
const fs = net
|
|
366
|
+
? null
|
|
367
|
+
: fsScope
|
|
368
|
+
? fsDenialHint(command, output, fsScope.cwd, fsScope.scope)
|
|
369
|
+
: null;
|
|
370
|
+
const hint = net ?? fs;
|
|
371
|
+
if (!hint)
|
|
372
|
+
return { text: annotated, net: null, fs: null };
|
|
373
|
+
return { text: `${annotated}\n\n[sandbox] ${hint.hint}`, net, fs };
|
|
374
|
+
}
|
|
375
|
+
/** Is `p` at or under one of the roots? Prefix match on path-segment
|
|
376
|
+
* boundaries (`/var/folders/T` must not admit `/var/folders/T-evil`). */
|
|
377
|
+
function underRoot(p, roots) {
|
|
378
|
+
return roots.some((r) => {
|
|
379
|
+
if (p === r)
|
|
380
|
+
return true;
|
|
381
|
+
return p.startsWith(r.endsWith("/") ? r : r + "/");
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** Does the command carry a heredoc marker or a process substitution, plus a
|
|
385
|
+
* cd to a directory outside the writable roots? The discriminator for the
|
|
386
|
+
* heredoc-cwd drift shape: the temp-file write follows the shell's CURRENT
|
|
387
|
+
* directory, so a heredoc from inside the roots never has this failure —
|
|
388
|
+
* only the cd-drifted form does. */
|
|
389
|
+
function hasHeredocCwdDrift(command, cwd, scope) {
|
|
390
|
+
if (!/<<|<\(/.test(command))
|
|
391
|
+
return false;
|
|
392
|
+
// find every cd target and require at least one OUTSIDE the writable roots
|
|
393
|
+
const cdRe = /(^|[\n;&|(\s])cd\s+([^\n;&|)\s]+)/g;
|
|
394
|
+
let m;
|
|
395
|
+
while ((m = cdRe.exec(command)) !== null) {
|
|
396
|
+
const target = m[2].replace(/^("|')+|("|')+$/g, "");
|
|
397
|
+
// Resolve the same way a shell would for the classification question:
|
|
398
|
+
// absolute as-is, ~ against HOME (the common drift target), relative
|
|
399
|
+
// against the session cwd (a relative cd stays inside the roots only if
|
|
400
|
+
// the resolved path is under them).
|
|
401
|
+
let resolved = null;
|
|
402
|
+
if (target.startsWith("/"))
|
|
403
|
+
resolved = target;
|
|
404
|
+
else if (target.startsWith("~"))
|
|
405
|
+
resolved = target.replace(/^~/, process.env.HOME ?? "~");
|
|
406
|
+
else if (target !== "-" && !target.startsWith("$"))
|
|
407
|
+
resolved = join(cwd, target);
|
|
408
|
+
if (resolved && !underRoot(resolved, scope.allowWrite))
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Classify a sandbox filesystem denial from command + output. Pure;
|
|
415
|
+
* advisory-only (the fs twin of networkDenialHint — never widens anything,
|
|
416
|
+
* the remedy is model-side or a user config edit, never a grant). Matches
|
|
417
|
+
* the signatures observed live and in the session review:
|
|
418
|
+
* - heredoc-cwd: `cannot create temp file for here document` /
|
|
419
|
+
* `/dev/fd/NN: Operation not permitted` — bash 3.2
|
|
420
|
+
* writes heredoc temp files relative to the CURRENT
|
|
421
|
+
* directory, so a cd outside the writable roots makes
|
|
422
|
+
* every heredoc fail even when the target is writable
|
|
423
|
+
* (reproduced live; the e2b /dev/fd/62 case is the
|
|
424
|
+
* process-substitution sibling of the same mechanism)
|
|
425
|
+
* - write-scope: an EPERM whose line names a write TARGET path
|
|
426
|
+
* outside the writable roots — the literal `/tmp`
|
|
427
|
+
* writes observed in the sessions (`cat > /tmp/x`,
|
|
428
|
+
* `> /tmp/after.txt`, python PermissionError on a
|
|
429
|
+
* non-root path)
|
|
430
|
+
* - write-scope-protected: same shape but the target is a PROTECTED path
|
|
431
|
+
* (denyWrite) — the config.json / state-dir denies.
|
|
432
|
+
* Protected paths can never be admitted with allow
|
|
433
|
+
* rules, so the hint copy differs.
|
|
434
|
+
* Returns null when the output carries no fs-denial signature — a network
|
|
435
|
+
* denial must NOT get an fs hint (the pinned negative in networkDenialHint
|
|
436
|
+
* is symmetric here).
|
|
437
|
+
*/
|
|
438
|
+
export function fsDenialHint(command, output, cwd, scope) {
|
|
439
|
+
// heredoc-cwd first (most specific): the temp-file EPERM signatures, gated
|
|
440
|
+
// on the command actually carrying the drift shape.
|
|
441
|
+
if (/(cannot create temp file for here document|\/dev\/fd\/\d+: Operation not permitted)/.test(output) &&
|
|
442
|
+
hasHeredocCwdDrift(command, cwd, scope)) {
|
|
443
|
+
return {
|
|
444
|
+
cls: "heredoc-cwd",
|
|
445
|
+
hint: "This looks like a heredoc temp-file denial — bash writes heredoc temp files relative to the current directory, and a cd outside the writable roots makes every heredoc fail even when the heredoc target is writable. " +
|
|
446
|
+
"Run the heredoc from the working directory (no cd first), or use printf or a file in the working directory instead. Retrying with dangerouslyDisableSandbox is NOT needed.",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
// write-target EPERM: the bash line shape (reusing extractBlockedWritePath's
|
|
450
|
+
// regex family) plus the python PermissionError shape.
|
|
451
|
+
const blocked = extractBlockedWritePath(output) ??
|
|
452
|
+
(output.match(/PermissionError: \[Errno \d+\] Operation not permitted: '([^']+)'/)?.[1] ?? null);
|
|
453
|
+
if (!blocked)
|
|
454
|
+
return null;
|
|
455
|
+
// A target inside the writable roots is NOT a write-scope denial — the
|
|
456
|
+
// scratchpad lives under the temp dir and its writes never hit the fence
|
|
457
|
+
// (the observed non-bug; pinned by test).
|
|
458
|
+
if (underRoot(blocked, scope.allowWrite))
|
|
459
|
+
return null;
|
|
460
|
+
if (underRoot(blocked, scope.denyWrite)) {
|
|
461
|
+
return {
|
|
462
|
+
cls: "write-scope-protected",
|
|
463
|
+
hint: "This looks like a sandbox write to a protected path — this path is denied even with allow rules (it holds the agent's own configuration). " +
|
|
464
|
+
"Do not write here; ask the user or use a different location. Retrying with dangerouslyDisableSandbox will still ask for permission and is the wrong tool for this — the protection is deliberate.",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
cls: "write-scope",
|
|
469
|
+
hint: "This looks like a sandbox write-scope denial — the target is outside the writable roots (the working directory and $TMPDIR). " +
|
|
470
|
+
"Use $TMPDIR or the project directory for temporary files. To write here anyway, add the path to sandbox.filesystem.allowWrite in your settings (the /sandbox panel's Config tab shows the current scope). " +
|
|
471
|
+
"Retrying with dangerouslyDisableSandbox is usually NOT needed — move the target inside the scope instead.",
|
|
472
|
+
};
|
|
362
473
|
}
|
|
363
474
|
/** cwd must exist before spawn (guard borrowed from pi's local ops). */
|
|
364
475
|
export function assertSpawnableCwd(cwd) {
|
|
@@ -28,7 +28,10 @@ import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
|
28
28
|
* here would be overwritten and the sandbox would silently never reach
|
|
29
29
|
* model-driven tool calls.
|
|
30
30
|
*/
|
|
31
|
-
export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void
|
|
31
|
+
export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void, fsScope?: () => {
|
|
32
|
+
allowWrite: readonly string[];
|
|
33
|
+
denyWrite: readonly string[];
|
|
34
|
+
} | null): (def: ToolDefinition) => ToolDefinition;
|
|
32
35
|
export interface SandboxSessionHandle {
|
|
33
36
|
manager: YagniSandboxManager;
|
|
34
37
|
settings: () => SandboxSettings;
|
|
@@ -23,7 +23,7 @@ import { isDebug } from "../diagnostics.js";
|
|
|
23
23
|
import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
|
|
24
24
|
import { loadSandboxSettings } from "./config.js";
|
|
25
25
|
import { resolveWorktreeGitAccess } from "./worktreeGit.js";
|
|
26
|
-
import { annotateCommandOutput, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
26
|
+
import { annotateCommandOutput, fsDenialHint, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
27
27
|
import { YagniSandboxManager } from "./manager.js";
|
|
28
28
|
import { SandboxPanel, buildPanelState, engineeringPresetBlock } from "./panel.js";
|
|
29
29
|
import { effectiveRules } from "../permissionRules/loadConfig.js";
|
|
@@ -39,7 +39,7 @@ import { effectiveRules } from "../permissionRules/loadConfig.js";
|
|
|
39
39
|
* here would be overwritten and the sandbox would silently never reach
|
|
40
40
|
* model-driven tool calls.
|
|
41
41
|
*/
|
|
42
|
-
export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry) {
|
|
42
|
+
export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry, fsScope) {
|
|
43
43
|
return (def) => {
|
|
44
44
|
if (!settings().enabled)
|
|
45
45
|
return def;
|
|
@@ -50,6 +50,12 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
50
50
|
shellPath: userShellPath,
|
|
51
51
|
spawnHook,
|
|
52
52
|
});
|
|
53
|
+
// The fs-denial classification scope (advisory hint only): resolved
|
|
54
|
+
// lazily per annotation — a denial is rare, the merge is not free.
|
|
55
|
+
const fsScopeForAnnotate = () => {
|
|
56
|
+
const scope = fsScope?.();
|
|
57
|
+
return scope ? { cwd, scope } : undefined;
|
|
58
|
+
};
|
|
53
59
|
const schema = Type.Object({
|
|
54
60
|
command: Type.String({ description: "The bash command to execute" }),
|
|
55
61
|
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
@@ -138,9 +144,11 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
138
144
|
// The thrown-message gate matches the result-text gate: a
|
|
139
145
|
// network-signature denial in EITHER surface gets the
|
|
140
146
|
// annotation + hint (node throws lowercase "operation not
|
|
141
|
-
// permitted", which the bare substring misses).
|
|
142
|
-
|
|
147
|
+
// permitted", which the bare substring misses). The fs hint
|
|
148
|
+
// composes under the same gate (one diagnosis, network wins).
|
|
149
|
+
const annotated = annotateCommandOutput(manager, input.command, err.message, fsScopeForAnnotate());
|
|
143
150
|
logNetworkDenialHint(annotated.net);
|
|
151
|
+
logFsDenialHint(annotated.fs, "thrown");
|
|
144
152
|
throw new Error(annotated.text);
|
|
145
153
|
}
|
|
146
154
|
throw err;
|
|
@@ -151,8 +159,9 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
151
159
|
if (result?.content) {
|
|
152
160
|
const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
153
161
|
if (text.includes("Operation not permitted") || networkDenialHint(text)) {
|
|
154
|
-
const annotated = annotateCommandOutput(manager, input.command, text);
|
|
162
|
+
const annotated = annotateCommandOutput(manager, input.command, text, fsScopeForAnnotate());
|
|
155
163
|
logNetworkDenialHint(annotated.net);
|
|
164
|
+
logFsDenialHint(annotated.fs, "result");
|
|
156
165
|
result = { ...result, content: [{ type: "text", text: annotated.text }] };
|
|
157
166
|
}
|
|
158
167
|
}
|
|
@@ -179,6 +188,33 @@ export function registerSandbox(pi, opts) {
|
|
|
179
188
|
}).settings;
|
|
180
189
|
let currentSettings = load();
|
|
181
190
|
let rules = [];
|
|
191
|
+
// The fs-denial classification scope for the advisory hint (the roots
|
|
192
|
+
// the runtime merge resolves: cwd + tmpdir + config allowWrite, denied:
|
|
193
|
+
// the protected paths). Lazy + memo-invalidated per call — a denial is
|
|
194
|
+
// rare and buildRuntimeMerge is not free; re-reading per annotation also
|
|
195
|
+
// picks up rules/settings changes without a cache to invalidate.
|
|
196
|
+
const fsScope = () => {
|
|
197
|
+
if (!manager.initialized)
|
|
198
|
+
return null;
|
|
199
|
+
try {
|
|
200
|
+
const merge = manager.buildRuntimeMerge(rules);
|
|
201
|
+
return { allowWrite: merge.filesystem.allowWrite, denyWrite: merge.filesystem.denyWrite };
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// Never the thrown message (it can carry command content) — error
|
|
205
|
+
// class only, same posture as the gate's classify-error line. Without
|
|
206
|
+
// this trace a persistently-throwing merge would silently strip fs
|
|
207
|
+
// hints from all three surfaces and the only symptom would be "hints
|
|
208
|
+
// disappeared" from the census the measurement depends on.
|
|
209
|
+
logEvent({
|
|
210
|
+
source: "sandbox",
|
|
211
|
+
level: "warn",
|
|
212
|
+
event: "fs_scope_error",
|
|
213
|
+
fields: { error: err instanceof Error ? err.constructor.name : typeof err },
|
|
214
|
+
});
|
|
215
|
+
return null; // fail-soft: no scope, no hint (the network hint still rides)
|
|
216
|
+
}
|
|
217
|
+
};
|
|
182
218
|
// Optional-chained: some harnesses/mocks don't implement flag APIs; the
|
|
183
219
|
// flag simply reads as unset there. Real pi registers it normally.
|
|
184
220
|
pi.registerFlag?.("no-sandbox", {
|
|
@@ -326,7 +362,7 @@ export function registerSandbox(pi, opts) {
|
|
|
326
362
|
logShellResolutionRetry(outcome);
|
|
327
363
|
opts.onShellResolutionRetry?.(outcome);
|
|
328
364
|
};
|
|
329
|
-
const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry);
|
|
365
|
+
const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry, fsScope);
|
|
330
366
|
const registerOwnBash = () => {
|
|
331
367
|
if (!currentSettings.enabled)
|
|
332
368
|
return;
|
|
@@ -409,11 +445,22 @@ export function registerSandbox(pi, opts) {
|
|
|
409
445
|
throw new Error(`timeout:${timeout}`);
|
|
410
446
|
// Post-run hint: same advisory as the tool path, appended after
|
|
411
447
|
// the child's own output so the user sees the fix pointer inline.
|
|
448
|
+
// One diagnosis per denial: the network hint wins when both
|
|
449
|
+
// classify (network has a user knob; fs remedies are model-side).
|
|
450
|
+
// A null fsScope() (uninitialized manager, merge error) skips the
|
|
451
|
+
// fs classification entirely — an empty-roots fallback would
|
|
452
|
+
// misclassify in-roots and protected targets as write-scope.
|
|
412
453
|
const net = networkDenialHint(tail);
|
|
454
|
+
const scope = net ? null : fsScope();
|
|
455
|
+
const fs = scope ? fsDenialHint(event.command, tail, opts.cwd, scope) : null;
|
|
413
456
|
if (net) {
|
|
414
457
|
logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
|
|
415
458
|
onData(Buffer.from(`\n[sandbox] ${net.hint}`));
|
|
416
459
|
}
|
|
460
|
+
else if (fs) {
|
|
461
|
+
logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface: "user-bash" } });
|
|
462
|
+
onData(Buffer.from(`\n[sandbox] ${fs.hint}`));
|
|
463
|
+
}
|
|
417
464
|
return { exitCode };
|
|
418
465
|
}
|
|
419
466
|
finally {
|
|
@@ -825,6 +872,15 @@ function logNetworkDenialHint(net) {
|
|
|
825
872
|
return;
|
|
826
873
|
logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
|
|
827
874
|
}
|
|
875
|
+
/** Sink line for the fs-denial hint (same low-cardinality contract as the
|
|
876
|
+
* network one — the before/after escape census reads these lines). The
|
|
877
|
+
* surface field distinguishes the tool-call thrown-error / result-text /
|
|
878
|
+
* !-command call sites in the log stream. */
|
|
879
|
+
function logFsDenialHint(fs, surface) {
|
|
880
|
+
if (!fs)
|
|
881
|
+
return;
|
|
882
|
+
logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface } });
|
|
883
|
+
}
|
|
828
884
|
/** Sink line for a failed domain-grant persist — the thrown message is
|
|
829
885
|
* mutateConfigJson's own path/reason text (diagnosable, no file content),
|
|
830
886
|
* same posture as sandbox_persist_failed / rule_save_failed. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.2-staging.
|
|
3
|
+
"version": "1.1.2-staging.1370.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "2f7255058068c24ee9b070aec43e23b4fc32fb39"
|
|
62
62
|
}
|