@sema-agent/core 7.23.4 → 7.24.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/CHANGELOG.md +133 -0
- package/dist/agents/list-agents-tool.d.ts +8 -1
- package/dist/agents/list-agents-tool.js +26 -3
- package/dist/agents/observer.js +5 -8
- package/dist/agents/subagent.js +2 -2
- package/dist/agents/team.js +9 -0
- package/dist/brain/anthropic.js +3 -2
- package/dist/brain/circuit-breaker.js +3 -3
- package/dist/brain/degrading.js +11 -14
- package/dist/brain/errors.d.ts +35 -18
- package/dist/brain/errors.js +49 -31
- package/dist/brain/failover.js +2 -3
- package/dist/brain/open-responses.js +6 -5
- package/dist/brain/openai.js +6 -5
- package/dist/brain/route-adjudicator.d.ts +50 -15
- package/dist/brain/route-adjudicator.js +112 -20
- package/dist/brain/route-conformance.js +9 -0
- package/dist/brain/stream-engine.js +18 -10
- package/dist/core/arg-summary.js +17 -6
- package/dist/core/auto-compaction.d.ts +42 -1
- package/dist/core/auto-compaction.js +21 -1
- package/dist/core/auto-mode.js +2 -8
- package/dist/core/checkpoint-store.d.ts +4 -6
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/exec-gate.js +3 -5
- package/dist/core/file-history-store.js +11 -10
- package/dist/core/hooks.d.ts +7 -0
- package/dist/core/mcp.js +7 -5
- package/dist/core/memory-engine/distiller.d.ts +7 -4
- package/dist/core/memory-engine/distiller.js +11 -3
- package/dist/core/memory.js +1 -0
- package/dist/core/permission-rule-model.js +8 -5
- package/dist/core/runner/assemble-result.js +12 -19
- package/dist/core/runner/contracts.d.ts +1 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
- package/dist/core/runner/prepare-delegation-surface.js +1 -0
- package/dist/core/runner/prepare-file-history.js +19 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
- package/dist/core/runner/prepare-hands-readface.js +4 -0
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-leg.js +2 -13
- package/dist/core/runner/run-recovery-lanes.js +4 -20
- package/dist/core/runner/run-settle-and-teardown.js +2 -16
- package/dist/core/runner/run-turn-boundary.js +4 -20
- package/dist/core/runner/runtask.js +3 -5
- package/dist/core/runner/stream-settle-backstop.js +5 -5
- package/dist/core/runner/tool-end-body.js +5 -4
- package/dist/core/runner/tool-output-projection.js +3 -2
- package/dist/core/runner/turn-attachments.d.ts +0 -20
- package/dist/core/runner/turn-attachments.js +41 -36
- package/dist/core/session-store.js +2 -4
- package/dist/core/session.d.ts +4 -1
- package/dist/core/session.js +2 -1
- package/dist/core/task-event.d.ts +19 -9
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/thrown-value.d.ts +52 -11
- package/dist/core/thrown-value.js +35 -1
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-errors.d.ts +6 -0
- package/dist/core/tool-errors.js +6 -14
- package/dist/core/tool-model-gate.js +3 -15
- package/dist/core/tools.js +15 -16
- package/dist/core/untrusted-text.d.ts +6 -0
- package/dist/core/untrusted-text.js +1 -0
- package/dist/core/workflow-run-store-contract.js +13 -0
- package/dist/core/workflow-run-store.d.ts +15 -0
- package/dist/core/workflow-run-store.js +1 -0
- package/dist/engine/llm/types.d.ts +4 -4
- package/dist/engine/loop/agent-loop.js +10 -24
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow.js +44 -23
- package/dist/stores/file/file-history-store.js +12 -11
- package/dist/tools/fs/bash-program-position.d.ts +23 -0
- package/dist/tools/fs/bash-program-position.js +11 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
- package/dist/tools/fs/bash-readonly-classifier.js +31 -10
- package/dist/tools/fs/fs-bash.d.ts +30 -1
- package/dist/tools/fs/fs-bash.js +123 -54
- package/dist/tools/fs/read-deny.d.ts +14 -0
- package/dist/tools/fs/read-deny.js +5 -0
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/fs/safety.js +2 -1
- package/dist/tools/monitor.d.ts +5 -0
- package/dist/tools/monitor.js +7 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +7 -3
- package/wire-changes/core@7.23.5.json +133 -0
- package/wire-changes/core@7.24.0.json +249 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* does (an option skipped, a chain member missed) is how one face used to vouch for a program another refused.
|
|
10
10
|
*/
|
|
11
11
|
import type { SegmentTokens } from "./bash-lexer.js";
|
|
12
|
+
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
|
|
12
13
|
/** The verdict every unresolvable / out-of-root / unbounded finding ends on. The findings differ in what
|
|
13
14
|
* they found; the consequence is one consequence, and a copy that drifts reads as a second rule. */
|
|
14
15
|
export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
|
|
@@ -170,6 +171,28 @@ export declare function argvReentryUnjudged(pos: ProgramPosition, acceptedTarget
|
|
|
170
171
|
*/
|
|
171
172
|
export declare const SHELL_SCAN_DECLINES: readonly ["unreadable_program_position", "stream_fed_operands"];
|
|
172
173
|
export type ShellScanDecline = (typeof SHELL_SCAN_DECLINES)[number];
|
|
174
|
+
/** The two SEAT TABLES over that set — ROW PER MEMBER behind a compile-time fence, registered in
|
|
175
|
+
* `docs/CLOSED-SETS.md` so `gate:closed-sets` is their runtime twin. The ARGUMENT for the rows is at the
|
|
176
|
+
* seats that read them (`fs-bash.ts`), which DERIVE the `Set` shape their readers take. Rows and not two
|
|
177
|
+
* sets because a derived set beside an empty literal made a third decline SILENTLY asymmetric, in the
|
|
178
|
+
* widening direction; a member with no row now reds `tsc` at both fences with the word spelled out. */
|
|
179
|
+
export declare const BOUNDARY_SEAT_READS_DECLINE: {
|
|
180
|
+
readonly unreadable_program_position: true;
|
|
181
|
+
readonly stream_fed_operands: true;
|
|
182
|
+
};
|
|
183
|
+
/** The fence over the read-boundary seat's table: `never` while every decline has a row. */
|
|
184
|
+
export type BoundarySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof BOUNDARY_SEAT_READS_DECLINE>>;
|
|
185
|
+
/** The classify doctrine's table — every row `false` BY THE ARGUMENT (stated at the seat), not by omission. */
|
|
186
|
+
export declare const CLASSIFY_SEAT_READS_DECLINE: {
|
|
187
|
+
readonly unreadable_program_position: false;
|
|
188
|
+
readonly stream_fed_operands: false;
|
|
189
|
+
};
|
|
190
|
+
/** The fence over the classify seat's table. */
|
|
191
|
+
export type ClassifySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof CLASSIFY_SEAT_READS_DECLINE>>;
|
|
192
|
+
/** A seat's table in the shape its readers take — the members it answers YES for. DERIVED, so the ROWS are
|
|
193
|
+
* the single source and a set can never say something its table does not. The parameter is TOTAL, not
|
|
194
|
+
* `Partial`: that stops a third seat being written `seatDeclineEvidence({})`, one level up from the fences. */
|
|
195
|
+
export declare function seatDeclineEvidence(table: Record<ShellScanDecline, boolean>): ReadonlySet<ShellScanDecline>;
|
|
173
196
|
/** Does a SHELL stand where this segment's program would be — as the program itself (`sh -c`), or as a launcher
|
|
174
197
|
* ROW handing its operands to one (`watch "…"`)? ONE predicate for the re-entry class, so the two spellings
|
|
175
198
|
* cannot be answered differently; which names are shells is the caller's declaration. Not a decline. */
|
|
@@ -159,6 +159,17 @@ export function argvReentryUnjudged(pos, acceptedTargets) {
|
|
|
159
159
|
return runs === undefined || !acceptedTargets.has(runs);
|
|
160
160
|
}
|
|
161
161
|
export const SHELL_SCAN_DECLINES = ["unreadable_program_position", "stream_fed_operands"];
|
|
162
|
+
export const BOUNDARY_SEAT_READS_DECLINE = {
|
|
163
|
+
unreadable_program_position: true,
|
|
164
|
+
stream_fed_operands: true,
|
|
165
|
+
};
|
|
166
|
+
export const CLASSIFY_SEAT_READS_DECLINE = {
|
|
167
|
+
unreadable_program_position: false,
|
|
168
|
+
stream_fed_operands: false,
|
|
169
|
+
};
|
|
170
|
+
export function seatDeclineEvidence(table) {
|
|
171
|
+
return new Set(SHELL_SCAN_DECLINES.filter((decline) => table[decline] === true));
|
|
172
|
+
}
|
|
162
173
|
export function programPositionRunsShell(pos, shellProgram) {
|
|
163
174
|
if (pos.refusal !== undefined)
|
|
164
175
|
return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true;
|
|
@@ -76,11 +76,13 @@ export interface LeadingCommandNameOptions {
|
|
|
76
76
|
* Read a shell operator that sits inside a QUOTED region as ordinary argument TEXT rather than as an
|
|
77
77
|
* operator, so `grep -E "a|b" f.log` parses as the single simple command it is.
|
|
78
78
|
*
|
|
79
|
-
* OPT-IN, and
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
79
|
+
* OPT-IN, and the two callers that set it share one premise: the whole command has ALREADY been refused
|
|
80
|
+
* for backslashes / an expanding `$` / backticks / subshells / line breaks, so they are inside
|
|
81
|
+
* {@link quoteMask}'s exactness envelope — the compound classify face
|
|
82
|
+
* ({@link classifyCompoundReadonlyDetailed}) and the persisted permission-RULE lane (`RULE_LANE_FLOOR`),
|
|
83
|
+
* whose segments come out of that same gate ({@link splitShellCompoundSegments}). The `bash_readonly`
|
|
84
|
+
* DECLARATION face does NOT set it: its contract is "a single simple command with no operator characters
|
|
85
|
+
* at all", and it runs on raw tool input with no whole-string gate in front of it.
|
|
84
86
|
*
|
|
85
87
|
* Fail-closed on unbalanced quoting: with a region left open, the mask is not trusted and every
|
|
86
88
|
* operator character counts, exactly as without this option.
|
|
@@ -617,8 +619,29 @@ export interface OutOfRootReadGateVerdict {
|
|
|
617
619
|
readonly paths: readonly string[];
|
|
618
620
|
/** Did the deployment's DENY judge speak about an operand this command names? The other half of the same
|
|
619
621
|
* question, over the very same readers, in a second pass WITH the deny judge — which is what lets the
|
|
620
|
-
* containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing.
|
|
621
|
-
|
|
622
|
+
* containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing.
|
|
623
|
+
*
|
|
624
|
+
* It carries the FINDING, not a boolean: this is the arm a command the grammar gate refused WHOLE
|
|
625
|
+
* (`cat .env > out.txt`, a subshell, a substitution) reaches, and there the compound verdict carries no
|
|
626
|
+
* deny fields at all — so a caller disclosing the hit has nowhere else to read the path and the pattern
|
|
627
|
+
* from, and said nothing. The second pass already computed both; throwing them away was the whole bug. */
|
|
628
|
+
readonly denied?: {
|
|
629
|
+
readonly path: string;
|
|
630
|
+
readonly pattern: string;
|
|
631
|
+
};
|
|
632
|
+
/** The reader operands this pass RESOLVED and placed inside the roots, plus the expansions it parsed but
|
|
633
|
+
* cannot enumerate — the candidates an execute-time recheck must canonicalize.
|
|
634
|
+
*
|
|
635
|
+
* Published because this pass reads SHAPES the compound walk hands nothing out for: a launcher whose
|
|
636
|
+
* program it cannot name (`env -- cat f`, `sudo -u root cat f`), a stream-fed one (`… | xargs cat f`).
|
|
637
|
+
* There the compound verdict carries no candidates at all, so the recheck had nothing to resolve and an
|
|
638
|
+
* in-root symlink to a deny-listed target RAN after an approval — while the same read spelled without
|
|
639
|
+
* the launcher was refused. Same operands, same judge, one more pass allowed to hand them over.
|
|
640
|
+
*
|
|
641
|
+
* NOT the out-of-root ones: those ride {@link paths}, they are what the boundary RAISED, and a leg that
|
|
642
|
+
* re-refused them would be a second adjudicator of a decision already made. */
|
|
643
|
+
readonly checked: readonly string[];
|
|
644
|
+
readonly undecided: readonly string[];
|
|
622
645
|
}
|
|
623
646
|
/** Test seam for the lockstep pin that the launcher table (the ONE launcher truth, in the program-position
|
|
624
647
|
* reader's module) stays a SUBSET of the rule lane's curated `BARE_INTERPRETER_NAMES`. Exported for that one
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sensitivePathDenyList } from "./read-deny.js";
|
|
1
2
|
import { hasWin32AliasedComponent, isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
2
3
|
import { lexShellText, literalTildeSpelling, quoteMask, substitutionsInHeredocBody, SUBSTITUTION_PLACEHOLDER, wordsOf, } from "./bash-lexer.js";
|
|
3
4
|
import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRunsShell, shellScanDeclineOf, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
|
|
@@ -19,11 +20,16 @@ function hasBareShellOperator(s, quotedOperatorsAreText) {
|
|
|
19
20
|
if (!mask.balanced)
|
|
20
21
|
return SHELL_OPERATORS.test(s);
|
|
21
22
|
for (let i = 0; i < s.length; i++) {
|
|
22
|
-
|
|
23
|
+
const ch = s[i];
|
|
24
|
+
if (!SHELL_OPERATORS.test(ch))
|
|
25
|
+
continue;
|
|
26
|
+
const literal = SHELL_EXPANDS_IN_DOUBLE_QUOTES.test(ch) ? mask.openedBy[i] === "'" : mask.quoted[i];
|
|
27
|
+
if (!literal)
|
|
23
28
|
return true;
|
|
24
29
|
}
|
|
25
30
|
return false;
|
|
26
31
|
}
|
|
32
|
+
const SHELL_EXPANDS_IN_DOUBLE_QUOTES = /[$`]/;
|
|
27
33
|
export function parseLeadingCommandName(command, options) {
|
|
28
34
|
const trimmed = command.trim();
|
|
29
35
|
if (!trimmed)
|
|
@@ -2169,7 +2175,7 @@ function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reac
|
|
|
2169
2175
|
const denied = boundary.denyMatch?.(finding.path);
|
|
2170
2176
|
if (denied != null) {
|
|
2171
2177
|
return {
|
|
2172
|
-
reason: `a command operand resolves to "${finding.path}", which matches
|
|
2178
|
+
reason: `a command operand resolves to "${finding.path}", which matches ${sensitivePathDenyList(denied)} ${NOT_AUTO_ALLOWED}`,
|
|
2173
2179
|
readDenied: true,
|
|
2174
2180
|
readDeniedPath: finding.path,
|
|
2175
2181
|
readDeniedPattern: denied,
|
|
@@ -2301,7 +2307,7 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
|
|
|
2301
2307
|
}
|
|
2302
2308
|
return evaluateReadBoundary([toks], boundary, true);
|
|
2303
2309
|
}
|
|
2304
|
-
const NOT_GATED = { gated: false, paths: [] };
|
|
2310
|
+
const NOT_GATED = { gated: false, paths: [], checked: [], undecided: [] };
|
|
2305
2311
|
const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}]/;
|
|
2306
2312
|
const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
|
|
2307
2313
|
export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
|
|
@@ -2359,18 +2365,33 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
|
|
|
2359
2365
|
if (readers.length === 0)
|
|
2360
2366
|
return NOT_GATED;
|
|
2361
2367
|
if (baseMoved)
|
|
2362
|
-
return { gated: true, paths: [] };
|
|
2368
|
+
return { gated: true, paths: [], checked: [], undecided: [] };
|
|
2363
2369
|
const verdict = evaluateReadBoundary(readers, containmentOnly, false, readerReach);
|
|
2364
|
-
const
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2370
|
+
const denyPass = boundary.denyMatch === undefined ? undefined : evaluateReadBoundary(readers, boundary, false, readerReach);
|
|
2371
|
+
const denied = denyPass?.readDenied === true && denyPass.readDeniedPath !== undefined && denyPass.readDeniedPattern !== undefined
|
|
2372
|
+
? { path: denyPass.readDeniedPath, pattern: denyPass.readDeniedPattern }
|
|
2373
|
+
: undefined;
|
|
2374
|
+
const resolvedOperands = {
|
|
2375
|
+
checked: [...(verdict.checkedPaths ?? []), ...(verdict.checkedSpellings ?? [])],
|
|
2376
|
+
undecided: [...(verdict.undecidedPaths ?? []), ...(verdict.undecidedSpellings ?? [])],
|
|
2377
|
+
};
|
|
2378
|
+
if (verdict.outOfRootRead === true) {
|
|
2379
|
+
return { gated: true, paths: verdict.outOfRootPaths ?? [], ...resolvedOperands, ...(denied !== undefined ? { denied } : {}) };
|
|
2380
|
+
}
|
|
2381
|
+
if (denied !== undefined)
|
|
2382
|
+
return { gated: false, paths: [], ...resolvedOperands, denied };
|
|
2383
|
+
return resolvedOperands.checked.length === 0 && resolvedOperands.undecided.length === 0 ? NOT_GATED : { gated: false, paths: [], ...resolvedOperands };
|
|
2368
2384
|
};
|
|
2369
2385
|
const direct = judge(command);
|
|
2370
|
-
if (direct.gated || direct.denied
|
|
2386
|
+
if (direct.gated || direct.denied !== undefined)
|
|
2371
2387
|
return direct;
|
|
2372
2388
|
const shape = POLL_LOOP_SHAPE.exec(command.trim());
|
|
2373
|
-
|
|
2389
|
+
if (shape === null)
|
|
2390
|
+
return direct;
|
|
2391
|
+
const loop = judge(shape[3]);
|
|
2392
|
+
return loop.gated || loop.denied !== undefined
|
|
2393
|
+
? loop
|
|
2394
|
+
: { ...loop, checked: [...direct.checked, ...loop.checked], undecided: [...direct.undecided, ...loop.undecided] };
|
|
2374
2395
|
}
|
|
2375
2396
|
export function classifyCompoundReadonly(command, allow, boundary) {
|
|
2376
2397
|
return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
|
|
@@ -66,6 +66,10 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
|
|
|
66
66
|
* ONE rule, TWO TABLES: which {@link ShellScanDecline} a SEAT reads as read evidence. A decline names no word,
|
|
67
67
|
* so it is never the boundary DECLARING anything about a path.
|
|
68
68
|
*
|
|
69
|
+
* BOTH tables are ROW-PER-MEMBER objects behind a compile-time fence ({@link BOUNDARY_SEAT_READS_DECLINE} /
|
|
70
|
+
* {@link CLASSIFY_SEAT_READS_DECLINE}); the sets below are DERIVED from those rows and the ARGUMENT for the
|
|
71
|
+
* rows is this block. "Empty by the argument" is a row saying `false`, not a literal nobody can judge.
|
|
72
|
+
*
|
|
69
73
|
* The READ-BOUNDARY seat is what the default/off doctrine and a bypass mode mount ALONE: there "not mandated"
|
|
70
74
|
* is VOUCHED, so a decline must count as evidence or a read nobody judged runs silently (`<out-of-root> |
|
|
71
75
|
* xargs cat` at zero asks — a closed P0). It keeps both. The CLASSIFY seat composes the same boundary with the
|
|
@@ -87,8 +91,13 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
|
|
|
87
91
|
* DID read naming a path outside the roots still gates the call ({@link classifyOutOfRootReadGate}).
|
|
88
92
|
*/
|
|
89
93
|
export declare const BOUNDARY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
|
|
90
|
-
/** The classify doctrine's table — see above; empty
|
|
94
|
+
/** The classify doctrine's table in the shape its readers take — see above; empty because every row says `false`. */
|
|
91
95
|
export declare const CLASSIFY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
|
|
96
|
+
export declare function fitMiddle(s: string, budget: number): string;
|
|
97
|
+
/** `s` shortened to `budget` by removing its MIDDLE, marked — cut through the repo's surrogate-safe
|
|
98
|
+
* primitive, never a raw `slice` (a raw cut leaves half an astral code point on a card and in a durable
|
|
99
|
+
* row). Under a dozen characters the HEAD is taken. */
|
|
100
|
+
export declare function elideMiddle(s: string, budget: number): string;
|
|
92
101
|
/**
|
|
93
102
|
* The READ-BOUNDARY seat of the full shell: a `ToolSpec.reversibilityProbe` that answers exactly ONE
|
|
94
103
|
* question — does the deployment's read boundary object to where this command reads? — and nothing about
|
|
@@ -292,6 +301,26 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
|
|
|
292
301
|
* Absent ⇒ historic bare tag. */
|
|
293
302
|
reminderMark?: string;
|
|
294
303
|
}): AgentTool;
|
|
304
|
+
type ShellCanonicalRefusal = {
|
|
305
|
+
content: string;
|
|
306
|
+
isError: true;
|
|
307
|
+
details?: unknown;
|
|
308
|
+
};
|
|
309
|
+
/** What a face contributes to the shared refusal cards: the TOOL refusing, and the three prose slots. */
|
|
310
|
+
export interface ShellFaceProse {
|
|
311
|
+
readonly tool: string;
|
|
312
|
+
readonly subject: string;
|
|
313
|
+
readonly verdictPhrase: string;
|
|
314
|
+
readonly note: string;
|
|
315
|
+
}
|
|
316
|
+
export type ShellCanonicalRecheck = (command: string, signal?: AbortSignal) => Promise<ShellCanonicalRefusal | undefined>;
|
|
317
|
+
/**
|
|
318
|
+
* THE execute-time canonical recheck, mounted by every face that spawns a shell command — the full `Bash`
|
|
319
|
+
* tool and `Monitor`, whose approval-time seats judge the WRITTEN spelling while this re-judges where the
|
|
320
|
+
* kernel takes those words. The SAME leg, not a second one; the contract below is the leg's own. */
|
|
321
|
+
export declare function createShellCanonicalRecheck(env: ExecutionEnv, readBoundary: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined) | undefined, face?: ShellFaceProse): ShellCanonicalRecheck;
|
|
322
|
+
/** The `Monitor` face's prose — the full shell's with its own tool name: one leg, one finding, two tools. */
|
|
323
|
+
export declare const MONITOR_ESCAPE_PROSE: ShellFaceProse;
|
|
295
324
|
/**
|
|
296
325
|
* `bash_readonly` (effect:read) — a restricted shell for the verifier read-only boundary: a single
|
|
297
326
|
* allowlisted, bare command with no shell operators. effect:read lets it survive the verifier's
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -7,15 +7,16 @@ import { RollingTailBuffer } from "../../core/exec-output-tail.js";
|
|
|
7
7
|
import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESSAGE, TASK_STOP_MISSING_ID_MESSAGE, TASK_STOP_PARAMS, resolveTaskIdArg, ENV_DIRECT_TASK_TOOL_CAPS, composeTaskOutputDescription, composeTaskOutputParams, composeTaskStopDescription, } from "../../core/task-tool-shape.js";
|
|
8
8
|
import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
9
9
|
import { delimitUntrusted } from "../../core/untrusted-text.js";
|
|
10
|
+
import { sliceHeadSafe, sliceTailSafe } from "../../core/surrogate-safe-slice.js";
|
|
10
11
|
import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
|
|
11
12
|
import { imageMagicMatches, normalizeAbsPathLexically, violationDetails, withinAnyRoot } from "./safety.js";
|
|
12
13
|
import { boundaryIsTheSandbox, canonicalPathIsAuthoritative, hasDestroy, isIsolated } from "../../core/remote-env.js";
|
|
13
14
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
14
15
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
|
|
15
|
-
import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
|
|
16
|
+
import { PROBE_CAUSE_PATH_MAX, PROBE_REASON_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
|
|
16
17
|
import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
|
|
17
|
-
import { NOT_AUTO_ALLOWED,
|
|
18
|
-
import { readDenySeat } from "./read-deny.js";
|
|
18
|
+
import { BOUNDARY_SEAT_READS_DECLINE, CLASSIFY_SEAT_READS_DECLINE, NOT_AUTO_ALLOWED, seatDeclineEvidence, } from "./bash-program-position.js";
|
|
19
|
+
import { readDenySeat, sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
|
|
19
20
|
import { toolFace } from "../../core/tool-catalog-entries.js";
|
|
20
21
|
import { thrownText } from "../../core/thrown-value.js";
|
|
21
22
|
const RECURSIVE_CAUSE_MAX_PATHS = 3;
|
|
@@ -35,13 +36,13 @@ export function bashReversibilityProbe(allow, boundary) {
|
|
|
35
36
|
const backgrounded = isBackgroundShellCall(args);
|
|
36
37
|
const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
|
|
37
38
|
if (backgrounded)
|
|
38
|
-
return { reversible: false, ...boundaryGate(detailed) };
|
|
39
|
+
return { reversible: false, ...boundaryGate(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
|
|
39
40
|
if (detailed.reason === undefined) {
|
|
40
41
|
if (boundaryUnread(detailed)) {
|
|
41
|
-
const
|
|
42
|
-
if (cause !== undefined)
|
|
43
|
-
return { reversible: false, ...boundaryGate(detailed),
|
|
44
|
-
return { reversible: false, ...outOfRootGate() };
|
|
42
|
+
const account = probeAccountOf(command, allowSet, resolved, detailed);
|
|
43
|
+
if (account.cause !== undefined)
|
|
44
|
+
return { reversible: false, ...boundaryGate(detailed), ...account };
|
|
45
|
+
return { reversible: false, ...outOfRootGate(), ...account };
|
|
45
46
|
}
|
|
46
47
|
return { reversible: true };
|
|
47
48
|
}
|
|
@@ -51,9 +52,8 @@ export function bashReversibilityProbe(allow, boundary) {
|
|
|
51
52
|
const seatOffer = (verdict) => (offers ? readRootCandidateOf(verdict) : {});
|
|
52
53
|
const loopScan = refusedCommandScan(command, allowSet, resolved, loop, CLASSIFY_SEAT_DECLINE_EVIDENCE);
|
|
53
54
|
if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop, loopScan))
|
|
54
|
-
return { reversible: false, mandated: true, ...seatOffer(loop) };
|
|
55
|
-
|
|
56
|
-
return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...(refusedCause !== undefined ? { cause: refusedCause } : {}) };
|
|
55
|
+
return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, loop), ...seatOffer(loop) };
|
|
56
|
+
return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
|
|
57
57
|
};
|
|
58
58
|
return (args) => {
|
|
59
59
|
const command = shellCommandOf(args);
|
|
@@ -90,8 +90,8 @@ function shellCommandOf(args) {
|
|
|
90
90
|
function isBackgroundShellCall(args) {
|
|
91
91
|
return args?.run_in_background === true;
|
|
92
92
|
}
|
|
93
|
-
export const BOUNDARY_SEAT_DECLINE_EVIDENCE =
|
|
94
|
-
export const CLASSIFY_SEAT_DECLINE_EVIDENCE =
|
|
93
|
+
export const BOUNDARY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(BOUNDARY_SEAT_READS_DECLINE);
|
|
94
|
+
export const CLASSIFY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(CLASSIFY_SEAT_READS_DECLINE);
|
|
95
95
|
function readBoundaryMandate(command, allowSet, resolved, verdict, declineEvidence) {
|
|
96
96
|
if (resolved === undefined)
|
|
97
97
|
return {};
|
|
@@ -99,7 +99,7 @@ function readBoundaryMandate(command, allowSet, resolved, verdict, declineEviden
|
|
|
99
99
|
if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict, scan))
|
|
100
100
|
return { mandated: true };
|
|
101
101
|
const named = classifyOutOfRootReadGate(command, allowSet, resolved);
|
|
102
|
-
if (named.gated || named.denied
|
|
102
|
+
if (named.gated || named.denied !== undefined)
|
|
103
103
|
return { mandated: true };
|
|
104
104
|
return declinedRung(scan);
|
|
105
105
|
}
|
|
@@ -122,7 +122,7 @@ function unjudgedRead(command, allowSet, resolved, verdict, scan) {
|
|
|
122
122
|
if (resolved === undefined)
|
|
123
123
|
return false;
|
|
124
124
|
const named = classifyOutOfRootReadGate(command, allowSet, resolved);
|
|
125
|
-
return named.gated || named.denied
|
|
125
|
+
return named.gated || named.denied !== undefined;
|
|
126
126
|
}
|
|
127
127
|
function recursiveReadCause(detailed) {
|
|
128
128
|
const recursive = detailed.recursiveReadPaths;
|
|
@@ -136,17 +136,65 @@ function recursiveReadCause(detailed) {
|
|
|
136
136
|
...(others.length > 0 ? { further: operandFamily(others) } : {}),
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
function denyHitReason(finding) {
|
|
140
|
+
if (finding === undefined)
|
|
141
|
+
return {};
|
|
142
|
+
const { path, pattern } = finding;
|
|
143
|
+
const composed = (target, pat) => `reading "${target}" matches ${sensitivePathDenyList(pat)}.`;
|
|
144
|
+
const room = PROBE_REASON_MAX - composed("", "").length;
|
|
145
|
+
const patternRoom = Math.min(pattern.length, Math.max(Math.floor(room / 2), room - path.length));
|
|
146
|
+
return { reason: composed(fitMiddle(path, room - patternRoom), fitMiddle(pattern, patternRoom)) };
|
|
147
|
+
}
|
|
148
|
+
export function fitMiddle(s, budget) {
|
|
149
|
+
return s.length <= budget ? s : elideMiddle(s, budget);
|
|
150
|
+
}
|
|
151
|
+
export function elideMiddle(s, budget) {
|
|
152
|
+
if (budget < 12)
|
|
153
|
+
return sliceHeadSafe(s, Math.max(0, budget));
|
|
154
|
+
const keep = budget - 1;
|
|
155
|
+
const head = Math.ceil(keep / 2);
|
|
156
|
+
return `${sliceHeadSafe(s, head)}\u2026${sliceTailSafe(s, keep - head)}`;
|
|
157
|
+
}
|
|
158
|
+
function denyFindingOf(command, allowSet, resolved, verdict) {
|
|
159
|
+
if (verdict.readDenied === true && verdict.readDeniedPath !== undefined && verdict.readDeniedPattern !== undefined) {
|
|
160
|
+
return { path: verdict.readDeniedPath, pattern: verdict.readDeniedPattern };
|
|
161
|
+
}
|
|
162
|
+
return resolved === undefined ? undefined : classifyOutOfRootReadGate(command, allowSet, resolved).denied;
|
|
163
|
+
}
|
|
164
|
+
function recursiveDenyReason(resolved, verdict) {
|
|
165
|
+
const roots = verdict.recursiveReadPaths;
|
|
166
|
+
const entries = resolved?.denyEntries;
|
|
167
|
+
if (roots === undefined || roots.length === 0 || entries === undefined || entries.length === 0)
|
|
168
|
+
return {};
|
|
169
|
+
const composed = (root, rows) => `a recursive read form traverses "${root}", which this walk does not enumerate, past ${sensitivePathDenyList(rows)}.`;
|
|
170
|
+
const root = roots[0] ?? "";
|
|
171
|
+
const label = (rows) => rows.length < entries.length ? [...rows, `+${entries.length - rows.length} more`] : rows;
|
|
172
|
+
let rows = entries.slice(0, MAX_DENY_ROWS_NAMED);
|
|
173
|
+
while (rows.length > 1 && PROBE_REASON_MAX - composed("", label(rows)).length < MIN_TRAVERSAL_ROOT_ROOM)
|
|
174
|
+
rows = rows.slice(0, -1);
|
|
175
|
+
let shown = label(rows);
|
|
176
|
+
if (PROBE_REASON_MAX - composed("", shown).length < MIN_TRAVERSAL_ROOT_ROOM) {
|
|
177
|
+
const over = MIN_TRAVERSAL_ROOT_ROOM - (PROBE_REASON_MAX - composed("", shown).length);
|
|
178
|
+
shown = label([fitMiddle(rows[0] ?? "", Math.max((rows[0] ?? "").length - over, 8))]);
|
|
179
|
+
}
|
|
180
|
+
return { reason: composed(fitMiddle(root, Math.max(PROBE_REASON_MAX - composed("", shown).length, 0)), shown) };
|
|
181
|
+
}
|
|
182
|
+
const MAX_DENY_ROWS_NAMED = 3;
|
|
183
|
+
const MIN_TRAVERSAL_ROOT_ROOM = 24;
|
|
184
|
+
function probeAccountOf(command, allowSet, resolved, verdict) {
|
|
185
|
+
const cause = recursiveReadCause(verdict);
|
|
186
|
+
const hit = denyHitReason(denyFindingOf(command, allowSet, resolved, verdict));
|
|
187
|
+
return { ...(hit.reason !== undefined ? hit : recursiveDenyReason(resolved, verdict)), ...(cause !== undefined ? { cause } : {}) };
|
|
188
|
+
}
|
|
139
189
|
export function bashReadBoundaryProbe(boundary) {
|
|
140
190
|
const allowSet = new Set(bashClassifyDefaultAllow());
|
|
141
191
|
const judge = (command, resolved, offers) => {
|
|
142
192
|
const seatVerdict = (verdict) => {
|
|
143
193
|
if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict, refusedCommandScan(command, allowSet, resolved, verdict, BOUNDARY_SEAT_DECLINE_EVIDENCE))) {
|
|
144
|
-
|
|
145
|
-
return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
|
|
194
|
+
return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, verdict) };
|
|
146
195
|
}
|
|
147
196
|
if (boundaryUndecided(verdict)) {
|
|
148
|
-
|
|
149
|
-
return { reversible: false, ...(cause !== undefined ? { cause } : {}) };
|
|
197
|
+
return { reversible: false, ...probeAccountOf(command, allowSet, resolved, verdict) };
|
|
150
198
|
}
|
|
151
199
|
if (verdict.reason === undefined)
|
|
152
200
|
return { reversible: true };
|
|
@@ -165,7 +213,7 @@ export function bashReadBoundaryProbe(boundary) {
|
|
|
165
213
|
if (mandated !== undefined)
|
|
166
214
|
return { ...mandated, ...offerFrom(detailed), ...offerFrom(loop) };
|
|
167
215
|
if (readBoundaryMandate(command, allowSet, resolved, detailed, BOUNDARY_SEAT_DECLINE_EVIDENCE).mandated === true)
|
|
168
|
-
return { reversible: false, mandated: true };
|
|
216
|
+
return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, detailed) };
|
|
169
217
|
return compoundVerdict ?? loopVerdict ?? { reversible: true };
|
|
170
218
|
};
|
|
171
219
|
return (args) => {
|
|
@@ -763,30 +811,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
763
811
|
const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
|
|
764
812
|
const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
|
|
765
813
|
const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
|
|
766
|
-
const
|
|
767
|
-
let canonicalForRoots;
|
|
768
|
-
const canonicalBoundaryFor = (roots) => {
|
|
769
|
-
const key = roots.join("\u0000");
|
|
770
|
-
if (canonicalForRoots?.key !== key)
|
|
771
|
-
canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
|
|
772
|
-
return canonicalForRoots.boundary;
|
|
773
|
-
};
|
|
774
|
-
const canonicalEscape = async (command, signal) => {
|
|
775
|
-
const resolvedBoundary = typeof taskOpts.readBoundary === "function" ? taskOpts.readBoundary() : taskOpts.readBoundary;
|
|
776
|
-
if (resolvedBoundary === undefined)
|
|
777
|
-
return undefined;
|
|
778
|
-
const ask = { deny: resolvedBoundary.denyMatch, containment: resolvedBoundary.face !== "open" };
|
|
779
|
-
if (ask.deny === undefined && !ask.containment)
|
|
780
|
-
return undefined;
|
|
781
|
-
const faces = [
|
|
782
|
-
classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
|
|
783
|
-
classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
|
|
784
|
-
];
|
|
785
|
-
const literal = [...new Set(faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? [])]))];
|
|
786
|
-
const asked = new Set(faces.flatMap((v) => v.outOfRootPaths ?? []));
|
|
787
|
-
const patterns = [...new Set(faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]))].filter((p) => !asked.has(normalizeAbsPathLexically(p)));
|
|
788
|
-
return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, ask, signal);
|
|
789
|
-
};
|
|
814
|
+
const canonicalEscape = createShellCanonicalRecheck(env, taskOpts.readBoundary);
|
|
790
815
|
return defineTool({
|
|
791
816
|
name: "Bash",
|
|
792
817
|
...toolFace("bash"),
|
|
@@ -1259,13 +1284,15 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1259
1284
|
if (deny === undefined || denied !== undefined)
|
|
1260
1285
|
return false;
|
|
1261
1286
|
const folded = normalizeAbsPathLexically(p);
|
|
1287
|
+
if (ask.denyAnswered.has(folded))
|
|
1288
|
+
return false;
|
|
1262
1289
|
const pattern = deny(folded);
|
|
1263
1290
|
if (pattern === null)
|
|
1264
1291
|
return false;
|
|
1265
1292
|
denied = { path: folded, pattern };
|
|
1266
1293
|
return true;
|
|
1267
1294
|
};
|
|
1268
|
-
const judge = async (p, canonicalKnown) => {
|
|
1295
|
+
const judge = async (p, canonicalKnown, origin) => {
|
|
1269
1296
|
if (denyHit(p))
|
|
1270
1297
|
return;
|
|
1271
1298
|
const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(p, signal);
|
|
@@ -1280,6 +1307,8 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1280
1307
|
}
|
|
1281
1308
|
if (denyHit(real.value))
|
|
1282
1309
|
return;
|
|
1310
|
+
if (ask.containmentAnswered.has(normalizeAbsPathLexically(origin ?? p)))
|
|
1311
|
+
return;
|
|
1283
1312
|
if (ask.containment && !withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
|
|
1284
1313
|
escaping.push(real.value);
|
|
1285
1314
|
};
|
|
@@ -1300,7 +1329,7 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1300
1329
|
for (const t of expanded.paths) {
|
|
1301
1330
|
if (denied !== undefined)
|
|
1302
1331
|
break;
|
|
1303
|
-
await judge(t, expanded.canonical.get(t));
|
|
1332
|
+
await judge(t, expanded.canonical.get(t), p);
|
|
1304
1333
|
}
|
|
1305
1334
|
}
|
|
1306
1335
|
else
|
|
@@ -1331,18 +1360,56 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1331
1360
|
},
|
|
1332
1361
|
};
|
|
1333
1362
|
}
|
|
1363
|
+
const NOTHING_ANSWERED = new Set();
|
|
1364
|
+
export function createShellCanonicalRecheck(env, readBoundary, face) {
|
|
1365
|
+
const recheckReaders = new Set(bashClassifyDefaultAllow());
|
|
1366
|
+
let canonicalForRoots;
|
|
1367
|
+
const canonicalBoundaryFor = (roots) => {
|
|
1368
|
+
const key = roots.join("\u0000");
|
|
1369
|
+
if (canonicalForRoots?.key !== key)
|
|
1370
|
+
canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
|
|
1371
|
+
return canonicalForRoots.boundary;
|
|
1372
|
+
};
|
|
1373
|
+
return async (command, signal) => {
|
|
1374
|
+
const resolvedBoundary = typeof readBoundary === "function" ? readBoundary() : readBoundary;
|
|
1375
|
+
if (resolvedBoundary === undefined)
|
|
1376
|
+
return undefined;
|
|
1377
|
+
const containmentAsked = resolvedBoundary.face !== "open";
|
|
1378
|
+
if (resolvedBoundary.denyMatch === undefined && !containmentAsked)
|
|
1379
|
+
return undefined;
|
|
1380
|
+
const faces = [
|
|
1381
|
+
classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
|
|
1382
|
+
classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
|
|
1383
|
+
];
|
|
1384
|
+
const named = classifyOutOfRootReadGate(command, recheckReaders, resolvedBoundary);
|
|
1385
|
+
const literalAll = [
|
|
1386
|
+
...new Set([
|
|
1387
|
+
...faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? []), ...(v.outOfRootPaths ?? [])]),
|
|
1388
|
+
...named.checked,
|
|
1389
|
+
...named.paths,
|
|
1390
|
+
]),
|
|
1391
|
+
];
|
|
1392
|
+
const folded = (ps) => new Set(ps.map((p) => normalizeAbsPathLexically(p)));
|
|
1393
|
+
const answered = {
|
|
1394
|
+
containment: folded([...faces.flatMap((v) => v.outOfRootPaths ?? []), ...named.paths]),
|
|
1395
|
+
deny: folded([...faces.flatMap((v) => (v.readDeniedPath !== undefined ? [v.readDeniedPath] : [])), ...(named.denied !== undefined ? [named.denied.path] : [])]),
|
|
1396
|
+
};
|
|
1397
|
+
const patterns = [...new Set([...faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]), ...named.undecided])];
|
|
1398
|
+
return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal: literalAll, patterns }, resolvedBoundary.roots, face ?? FULL_SHELL_FACE_ESCAPE_PROSE, { deny: resolvedBoundary.denyMatch, containment: containmentAsked, denyAnswered: answered.deny, containmentAnswered: answered.containment }, signal);
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1334
1401
|
async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, ask, signal) {
|
|
1335
1402
|
const resolved = await canonical.escapesAfterResolution(candidates, ask, signal);
|
|
1336
1403
|
if (resolved.denied !== undefined) {
|
|
1337
|
-
return readDenyRefusal(resolved.denied.path, resolved.denied.pattern);
|
|
1404
|
+
return readDenyRefusal(resolved.denied.path, resolved.denied.pattern, face.tool);
|
|
1338
1405
|
}
|
|
1339
1406
|
if (resolved.unverifiable !== undefined) {
|
|
1340
|
-
return readContainmentRefusal(`Error (
|
|
1407
|
+
return readContainmentRefusal(`Error (${face.tool}): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
|
|
1341
1408
|
face.note);
|
|
1342
1409
|
}
|
|
1343
1410
|
if (resolved.escaping.length > 0) {
|
|
1344
1411
|
const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
|
|
1345
|
-
return readContainmentRefusal(`Error (
|
|
1412
|
+
return readContainmentRefusal(`Error (${face.tool}): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
|
|
1346
1413
|
face.note, resolved.escaping);
|
|
1347
1414
|
}
|
|
1348
1415
|
return undefined;
|
|
@@ -1352,22 +1419,24 @@ const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spell
|
|
|
1352
1419
|
function readContainmentRefusal(content, paths = []) {
|
|
1353
1420
|
return errorResult(content, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths });
|
|
1354
1421
|
}
|
|
1355
|
-
function readDenyRefusal(target, pattern) {
|
|
1356
|
-
const content = `Error (
|
|
1357
|
-
|
|
1358
|
-
`applies regardless of the containment roots. ${DENY_REFUSAL_NOTE}`;
|
|
1422
|
+
function readDenyRefusal(target, pattern, tool = "Bash") {
|
|
1423
|
+
const content = `Error (${tool}): reading "${target}" is refused: the target matches ${sensitivePathDenyList(pattern)}. ` +
|
|
1424
|
+
`${SENSITIVE_PATH_DENY_SCOPE} ${DENY_REFUSAL_NOTE}`;
|
|
1359
1425
|
return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target, pattern }));
|
|
1360
1426
|
}
|
|
1361
1427
|
const READONLY_FACE_ESCAPE_PROSE = {
|
|
1428
|
+
tool: "Bash",
|
|
1362
1429
|
subject: "a path this command reads",
|
|
1363
1430
|
verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
|
|
1364
1431
|
note: BASH_READONLY_CONFINEMENT_NOTE,
|
|
1365
1432
|
};
|
|
1366
1433
|
const FULL_SHELL_FACE_ESCAPE_PROSE = {
|
|
1434
|
+
tool: "Bash",
|
|
1367
1435
|
subject: "a path this command names",
|
|
1368
1436
|
verdictPhrase: "",
|
|
1369
1437
|
note: "The read boundary judged this command's written spelling before the call was approved, and execution is past the approval step — a target reached only through symlink resolution is refused here rather than escalated.",
|
|
1370
1438
|
};
|
|
1439
|
+
export const MONITOR_ESCAPE_PROSE = { ...FULL_SHELL_FACE_ESCAPE_PROSE, tool: "Monitor" };
|
|
1371
1440
|
export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
|
|
1372
1441
|
const timeoutCaps = resolveBashTimeoutCaps(opts);
|
|
1373
1442
|
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
|
|
@@ -1439,7 +1508,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
|
|
|
1439
1508
|
return readContainmentRefusal(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, boundary.outOfRootPaths ?? []);
|
|
1440
1509
|
}
|
|
1441
1510
|
}
|
|
1442
|
-
const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? []), ...releasedByGrant], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: denySeat.denyMatch, containment: true }, ctx.signal);
|
|
1511
|
+
const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? []), ...releasedByGrant], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: denySeat.denyMatch, containment: true, denyAnswered: NOTHING_ANSWERED, containmentAnswered: NOTHING_ANSWERED }, ctx.signal);
|
|
1443
1512
|
if (escape !== undefined)
|
|
1444
1513
|
return escape;
|
|
1445
1514
|
const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
|
|
@@ -75,6 +75,20 @@ export interface ReadDenySeat {
|
|
|
75
75
|
* ONCE per mount and reuse the result — the returned closure is then the function OBJECT every face mounted
|
|
76
76
|
* from that matcher shares, which makes "the same judge" checkable rather than argued. */
|
|
77
77
|
export declare function readDenySeat(matcher: ReadDenyMatcher | undefined): ReadDenySeat;
|
|
78
|
+
/** The ONE naming of the deny table and the pattern that matched. Every face composes its own SUBJECT and
|
|
79
|
+
* VERB in front of it and its own DISPOSITION behind it (the verb is not shared: one face names the
|
|
80
|
+
* target, another the REQUESTED SPELLING under a symlink alias). What must not fork is the naming — a
|
|
81
|
+
* reader comparing two surfaces about one finding must not be reading two findings. A LIST of patterns is
|
|
82
|
+
* the same naming in the plural, for the one finding that names no single row: a traversal that goes PAST
|
|
83
|
+
* the judge names the table IN FORCE, never a match it did not make. Four composers: the
|
|
84
|
+
* structured read faces' refusal (`safety.ts`), the shell faces' refusal card (`readDenyRefusal`), the
|
|
85
|
+
* lexical walk's verdict sentence (`bash-readonly-classifier.ts`), and the full shell's MANDATED ask card,
|
|
86
|
+
* which is approvable and so states the finding without claiming a refusal. NOT composers, each stating
|
|
87
|
+
* its own fact: the compaction lane's withheld-attachment HTML attribute, and the traversal notes'
|
|
88
|
+
* withheld-COUNT lines, which name the table and a pattern LIST. */
|
|
89
|
+
export declare function sensitivePathDenyList(pattern: string | readonly string[]): string;
|
|
90
|
+
/** The deny table's REACH: a deny row holds INSIDE the containment roots, so no root grant answers it. */
|
|
91
|
+
export declare const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
|
|
78
92
|
/**
|
|
79
93
|
* The closed set of built-in tiers. A tier is the deployment's selection unit
|
|
80
94
|
* ({@link ReadDenyBuiltinConfig.tiers}); every {@link READ_FACE_BUILTIN_DENY_TABLE} row belongs to
|
|
@@ -4,6 +4,11 @@ export function readDenySeat(matcher) {
|
|
|
4
4
|
return { denyEntries: entries };
|
|
5
5
|
return { denyMatch: (p) => matcher.matchPath(p)?.pattern ?? null, denyEntries: entries };
|
|
6
6
|
}
|
|
7
|
+
export function sensitivePathDenyList(pattern) {
|
|
8
|
+
const rows = typeof pattern === "string" ? [pattern] : pattern;
|
|
9
|
+
return `the sensitive-path read deny list (pattern${rows.length === 1 ? "" : "s"} ${rows.map((p) => `"${p}"`).join(", ")})`;
|
|
10
|
+
}
|
|
11
|
+
export const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
|
|
7
12
|
export const READ_DENY_BUILTIN_TIERS = ["credentials", "shell-history", "browser", "wallet", "agent-config"];
|
|
8
13
|
export const READ_FACE_BUILTIN_DENY_TABLE = [
|
|
9
14
|
{ pattern: ".ssh", tier: "credentials" },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
|
|
1
2
|
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
2
3
|
import { protectivePathTargetOf, type ToolCallLike } from "../../core/tool-registry.js";
|
|
3
4
|
/**
|
|
@@ -320,7 +321,6 @@ export declare const WIN32_NAMESPACE_REFUSAL_TEXT: {
|
|
|
320
321
|
};
|
|
321
322
|
/** The fence over the text table: `never` while every refusal word has a row. */
|
|
322
323
|
export type NamespaceRefusalTextCoversEveryWord = AssertAllKeysHandled<Exclude<Win32NamespaceRefusal, keyof typeof WIN32_NAMESPACE_REFUSAL_TEXT>>;
|
|
323
|
-
type AssertAllKeysHandled<T extends never> = T;
|
|
324
324
|
/**
|
|
325
325
|
* The Win32 namespace screen — run on every model-spelled target BEFORE the family join, at every site
|
|
326
326
|
* (the fs throat, the effective-target reader, the rule side, the glob splitter), so one spelling is
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
|
|
2
3
|
import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js";
|
|
3
4
|
import { pathTargetValue, protectivePathTargetOf } from "../../core/tool-registry.js";
|
|
4
5
|
import { withGateTimeout, DEFAULT_GATE_AWAIT_TIMEOUT_MS } from "../../core/gate-bounded-await.js";
|
|
@@ -468,7 +469,7 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
468
469
|
ok: false,
|
|
469
470
|
violation: {
|
|
470
471
|
code: "read_path_denied",
|
|
471
|
-
message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged}
|
|
472
|
+
message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} ${sensitivePathDenyList(hit.pattern)}. ${SENSITIVE_PATH_DENY_SCOPE}`,
|
|
472
473
|
target: key,
|
|
473
474
|
pattern: hit.pattern,
|
|
474
475
|
},
|