@sema-agent/core 7.24.4 → 7.25.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/arg-summary.d.ts +0 -7
  8. package/dist/core/arg-summary.js +2 -1
  9. package/dist/core/background-agent-store.d.ts +3 -3
  10. package/dist/core/checkpoint-store.d.ts +1 -0
  11. package/dist/core/delegation-frames.d.ts +5 -0
  12. package/dist/core/gate-fold.js +85 -0
  13. package/dist/core/gate-lanes.js +23 -1
  14. package/dist/core/gate-outcome.d.ts +8 -2
  15. package/dist/core/gate-outcome.js +3 -1
  16. package/dist/core/hooks.d.ts +29 -0
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/mailbox-store.d.ts +5 -0
  19. package/dist/core/mailbox-store.js +1 -1
  20. package/dist/core/parent-spec-seat.d.ts +7 -1
  21. package/dist/core/parent-spec-seat.js +2 -0
  22. package/dist/core/permission-rule-consent.js +29 -47
  23. package/dist/core/permission-rule-provider.js +4 -3
  24. package/dist/core/permission-rule-store.d.ts +143 -7
  25. package/dist/core/permission-rule-store.js +165 -15
  26. package/dist/core/runner/contracts.d.ts +36 -2
  27. package/dist/core/runner/gate-exit.d.ts +14 -0
  28. package/dist/core/runner/gate-exit.js +4 -0
  29. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  30. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  31. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  32. package/dist/core/runner/prepare-gate-stations.js +7 -4
  33. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  34. package/dist/core/runner/prepare-hands-readface.js +25 -10
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  36. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  37. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  38. package/dist/core/runner/prepare-policy-chain.js +14 -4
  39. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  40. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  41. package/dist/core/runner/prepare-task.js +10 -9
  42. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  43. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  44. package/dist/core/runner/resume-apply.js +2 -0
  45. package/dist/core/runner/run-harness-handlers.js +1 -0
  46. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  47. package/dist/core/runner/run-leg.js +26 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  49. package/dist/core/runner/run-notification-lane.js +2 -1
  50. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  51. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  52. package/dist/core/runner/session-rule-policy.js +11 -3
  53. package/dist/core/session-policy-store.d.ts +27 -3
  54. package/dist/core/session-policy-store.js +12 -4
  55. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  56. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  57. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  58. package/dist/core/task-event.d.ts +6 -0
  59. package/dist/core/task-notification.d.ts +79 -4
  60. package/dist/core/task-notification.js +90 -1
  61. package/dist/core/task-registry-agent.js +1 -2
  62. package/dist/core/task-registry-monitor.js +2 -1
  63. package/dist/core/task-registry-shared.d.ts +53 -5
  64. package/dist/core/task-registry-shared.js +13 -2
  65. package/dist/core/task-registry.js +8 -5
  66. package/dist/core/task-spec.d.ts +12 -5
  67. package/dist/core/tool-catalog-entries.js +1 -0
  68. package/dist/core/tool-policy.js +30 -23
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/prompts/coordinator.d.ts +2 -1
  76. package/dist/stores/file/adoption/adopt.js +23 -1
  77. package/dist/stores/file/permission-rule-store.js +3 -1
  78. package/dist/stores/file/session-policy-store.d.ts +31 -0
  79. package/dist/stores/file/session-policy-store.js +87 -7
  80. package/dist/tools/fs/bash-lexer.d.ts +13 -0
  81. package/dist/tools/fs/bash-lexer.js +61 -0
  82. package/dist/tools/fs/bash-program-position.d.ts +36 -29
  83. package/dist/tools/fs/bash-program-position.js +33 -13
  84. package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
  85. package/dist/tools/fs/bash-readonly-classifier.js +81 -782
  86. package/dist/tools/fs/fs-bash.d.ts +43 -207
  87. package/dist/tools/fs/fs-bash.js +20 -341
  88. package/dist/tools/fs/index.d.ts +12 -13
  89. package/dist/tools/fs/index.js +3 -4
  90. package/dist/tools/fs/read-deny.d.ts +25 -0
  91. package/dist/tools/fs/read-deny.js +19 -0
  92. package/dist/tools/fs/safety.js +2 -6
  93. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  94. package/dist/tools/fs/shell-read-check.js +229 -0
  95. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  96. package/dist/tools/fs/shell-read-establish.js +216 -102
  97. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  98. package/dist/tools/fs/shell-read-judge.js +170 -0
  99. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  100. package/dist/tools/fs/shell-read-plan.js +88 -27
  101. package/dist/tools/monitor.d.ts +5 -5
  102. package/dist/tools/monitor.js +3 -5
  103. package/dist/tools/task-list.d.ts +4 -0
  104. package/package.json +1 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.25.0.json +671 -0
  107. package/wire-changes/core@7.25.1.json +64 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The shell read STATION — the gate input that judges where a shell command reads, before approval, on
3
+ * every shell face (`createShellReadCheck`), and the pre-spawn admission the shell tools call with the
4
+ * same establishment (`ShellReadAdmission`). ONE pipeline, one call:
5
+ *
6
+ * planShellReads(text) → establish(plan, env) → judgeRead ×N → foldShellReadFindings → decideShellRead(seat)
7
+ *
8
+ * The station is a POLICY-class member of the gate fold: a `deny_hit` refuses outright (composing under
9
+ * `deny > ask > allow`, so it outranks a hook's or a policy's ask), a mandated finding tightens a surviving
10
+ * allow to a MANDATED ask (or marks an existing ask mandated), an ordinary finding tightens to an ORDINARY
11
+ * ask (or marks the pass `probeBoundaryDeclined`), and `none` leaves the decision untouched. The three
12
+ * tools' execute legs judge nothing: what they run is what this station admitted, and the admission only
13
+ * re-establishes the SAME candidates (a symlink re-pointed while the approver was thinking) — it never
14
+ * parses text or judges a word the station did not.
15
+ */
16
+ import type { ExecutionEnv } from "../../internal/harness-types.js";
17
+ import { violationDetails } from "./safety.js";
18
+ import { type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
19
+ import type { ReadRootGrantCandidate } from "../../core/tool-spec.js";
20
+ /** The ONE mint of the containment card — every arm that refuses or asks on a containment finding (a refusal, a
21
+ * mandated card's own card, a station that could not answer, the pre-spawn re-establishment) spells it here. */
22
+ declare function containmentDetails(paths: readonly string[]): {
23
+ readonly type: "readonly_out_of_root";
24
+ readonly code: "readonly_out_of_root";
25
+ readonly paths: readonly string[];
26
+ };
27
+ /** The structured details a refusal carries — the same two wire discriminants the shell tools' own
28
+ * refusals carried before the station existed, so a consumer counting either code counts one thing: the deny
29
+ * card is the read faces' own (`violationDetails`, safety.ts), the containment card is minted above. */
30
+ export type ShellReadRefusalDetails = Extract<NonNullable<ReturnType<typeof violationDetails>>, {
31
+ code: "read_path_denied";
32
+ }> | ReturnType<typeof containmentDetails>;
33
+ /** What the station answers for ONE call. */
34
+ export interface ShellReadVerdict {
35
+ readonly disposition: ShellReadDisposition;
36
+ readonly finding: ShellReadFinding;
37
+ /** `refuse`: the refusal text (`Error (<tool>): …`, the five-segment deny sentence or the containment
38
+ * sentence). `mandated` / `ordinary`: the ACCOUNT of the finding, for the approval card's disclosure seat
39
+ * (never the ask's own message — that is the auto-mode classifier's input). Absent for `vouch`. */
40
+ readonly sentence?: string;
41
+ /** `refuse`: the refusal's structured details. `mandated` on an `outside_roots` / `unestablishable` finding: the
42
+ * same containment card, so an ask a person refuses or nobody can answer still carries WHAT was found on
43
+ * its `tool_end` (`readonly_out_of_root`), as the tool's own refusal of that finding did. */
44
+ readonly details?: ShellReadRefusalDetails;
45
+ /** `mandated` on an `outside_roots` finding: the directory whose grant would clear this ask. */
46
+ readonly readRootCandidate?: ReadRootGrantCandidate;
47
+ }
48
+ /** The station seat one tool call is judged on (the `rejudge` seat is asked for by the caller). */
49
+ export type ShellReadStationSeat = Exclude<ShellReadSeat, "rejudge">;
50
+ /**
51
+ * The ENGINE-FILLED gate input. `judge` returns `null` SYNCHRONOUSLY when the call is not this station's
52
+ * business — not a shell-running tool, no command string — and otherwise the verdict, a promise. The split
53
+ * is load-bearing for the gate: a call the station does not cover must not cost the fold a suspension
54
+ * point. `unanswered` is the verdict the fold substitutes when the wait elapsed or threw — fail-closed as
55
+ * `unestablishable` on the call's own seat.
56
+ */
57
+ export interface ShellReadCheck {
58
+ judge(toolName: string, args: unknown, cwd: string | undefined, signal: AbortSignal | undefined, opts: {
59
+ readonly toolCallId?: string;
60
+ readonly seat?: "rejudge";
61
+ }): Promise<ShellReadVerdict> | null;
62
+ unanswered(toolName: string, cause: string): ShellReadVerdict;
63
+ }
64
+ /** The pre-spawn admission the shell tools call: re-establishes what the station established for this
65
+ * call and refuses when any canonical target moved. `undefined` ⇒ spawn. A call the station never judged
66
+ * (no gate ran) has nothing to re-establish and spawns. */
67
+ export type ShellReadAdmission = (toolName: string, toolCallId: string | undefined, command: string, signal?: AbortSignal) => Promise<{
68
+ content: string;
69
+ isError: true;
70
+ details?: unknown;
71
+ } | undefined>;
72
+ export interface ShellReadStation {
73
+ readonly check: ShellReadCheck;
74
+ readonly admit: ShellReadAdmission;
75
+ }
76
+ export interface ShellReadStationOptions {
77
+ readonly env: ExecutionEnv;
78
+ /** Which tool names spawn a shell command through this leg — the station's membership. */
79
+ readonly tools: ReadonlySet<string>;
80
+ /** The trust-policy seat a tool is judged on. */
81
+ readonly seatOf: (toolName: string) => ShellReadStationSeat;
82
+ /** The boundary for a call, read per call (the tracked working directory moves). `undefined` ⇒ no
83
+ * boundary declared, nothing to be outside of — the station vouches. */
84
+ readonly boundaryOf: (toolName: string, cwd: string | undefined) => ShellReadBoundary | undefined;
85
+ /** The listed read verbs (decides whether a segment is read by verb slots or word by word). */
86
+ readonly readers?: ReadonlySet<string>;
87
+ }
88
+ /**
89
+ * The station's verdict with NO environment — the lexical reading (what an isolated sandbox gets, and what a
90
+ * pin or a lexical pre-read can compute): the same plan, the same judge, the same table, over the folded
91
+ * spellings alone. `tool` names the face in the refusal sentence.
92
+ */
93
+ export declare function judgeShellReadLexically(command: string, boundary: ShellReadBoundary, seat: ShellReadSeat, opts?: {
94
+ readonly readers?: ReadonlySet<string>;
95
+ readonly tool?: string;
96
+ }): ShellReadVerdict;
97
+ /** Mount the station for one execution environment. */
98
+ export declare function createShellReadStation(opts: ShellReadStationOptions): ShellReadStation;
99
+ export {};
@@ -0,0 +1,229 @@
1
+ import { errorResult } from "../../core/tools.js";
2
+ import { BASH_READONLY_CONFINEMENT_NOTE } from "./fs-shared.js";
3
+ import { violationDetails } from "./safety.js";
4
+ import { fitMiddle, READ_DENY_SENTENCE_PART_MAX, readDenySentence } from "./read-deny.js";
5
+ import { NOT_AUTO_ALLOWED, programPositionRefusalSentence, readProgramPosition } from "./bash-program-position.js";
6
+ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
7
+ import { planShellReads } from "./shell-read-plan.js";
8
+ import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
9
+ import { allFindings, decideShellRead, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, } from "./shell-read-judge.js";
10
+ import { wordsOf, lexShellText } from "./bash-lexer.js";
11
+ function containmentDetails(paths) {
12
+ return { type: "readonly_out_of_root", code: "readonly_out_of_root", paths };
13
+ }
14
+ const ADMISSION_MAX = 1024;
15
+ function shellCommandOf(args) {
16
+ const command = args?.command;
17
+ return typeof command === "string" ? command : undefined;
18
+ }
19
+ function declineSentence(segment) {
20
+ const lexed = lexShellText(segment.text, { mode: "lenient" });
21
+ if (!("reject" in lexed) && lexed.segments[0] !== undefined) {
22
+ const pos = readProgramPosition(wordsOf(lexed.segments[0]));
23
+ if (pos.refusal !== undefined)
24
+ return programPositionRefusalSentence(pos.refusal);
25
+ if (pos.argvFromStdin) {
26
+ const runs = pos.program?.basename ?? "the program it runs";
27
+ return `"${segment.text.trim()}" hands ${runs} its operands on its standard input, which this station does not read ${NOT_AUTO_ALLOWED}`;
28
+ }
29
+ }
30
+ return `"${segment.text.trim()}" runs a program this station cannot name ${NOT_AUTO_ALLOWED}`;
31
+ }
32
+ const ACCOUNT_PART_MAX = READ_DENY_SENTENCE_PART_MAX;
33
+ const CAUSE_SPELLING_MAX = 64;
34
+ function fitCause(cause) {
35
+ return cause.replace(/^"([^"]*)"/, (_m, spelling) => `"${fitMiddle(spelling, CAUSE_SPELLING_MAX)}"`);
36
+ }
37
+ function findingAccount(fold, roots) {
38
+ const allowed = fitMiddle(roots.length > 0 ? roots.join(", ") : "(none)", ACCOUNT_PART_MAX);
39
+ const w = fold.witness;
40
+ switch (fold.finding) {
41
+ case "outside_roots":
42
+ return `reads outside the allowed directories for this session: "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" is not under ${allowed} ${NOT_AUTO_ALLOWED}`;
43
+ case "unestablishable":
44
+ if (w?.read.cause !== undefined)
45
+ return `where this command reads could not be established: ${fitCause(w.read.cause)}, so it cannot be confirmed to read only inside the allowed directories for this session (${allowed}) ${NOT_AUTO_ALLOWED}`;
46
+ if (fold.declined?.baseMove !== undefined)
47
+ return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" moves the working directory somewhere this command's text does not settle ${NOT_AUTO_ALLOWED}`;
48
+ if (fold.declined?.unreadProgramText !== undefined)
49
+ return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}": ${fold.declined.unreadProgramText}, so where it reads cannot be established ${NOT_AUTO_ALLOWED}`;
50
+ if (fold.declined !== undefined)
51
+ return `"${fitMiddle(fold.declined.text.trim(), ACCOUNT_PART_MAX)}" runs its argument as shell text this station does not parse ${NOT_AUTO_ALLOWED}`;
52
+ return `the command's text could not be read as shell, so where it reads cannot be established ${NOT_AUTO_ALLOWED}`;
53
+ case "declined":
54
+ return fold.declined !== undefined ? declineSentence(fold.declined) : `the station could not read which program runs ${NOT_AUTO_ALLOWED}`;
55
+ case "identity_changed":
56
+ return `"${fitMiddle(w?.read.candidate.raw ?? "?", ACCOUNT_PART_MAX)}" resolves to "${fitMiddle(w?.read.target ?? "?", ACCOUNT_PART_MAX)}" now, not where it resolved when this call was judged`;
57
+ case "deny_hit":
58
+ case "none":
59
+ return "";
60
+ default: {
61
+ const exhaustive = fold.finding;
62
+ return exhaustive;
63
+ }
64
+ }
65
+ }
66
+ function outsidePathsOf(judged) {
67
+ const out = [];
68
+ for (const f of allFindings(judged)) {
69
+ if (f.kind === "outside_roots" && f.read.target !== undefined && !out.includes(f.read.target))
70
+ out.push(f.read.target);
71
+ }
72
+ return out;
73
+ }
74
+ function refusalOf(tool, fold, roots, paths) {
75
+ const w = fold.witness;
76
+ if (fold.finding === "deny_hit" && w?.deny !== undefined) {
77
+ const target = w.read.target ?? w.deny.matchedView;
78
+ const core = readDenySentence({ target, pattern: w.deny.pattern, matchedView: w.deny.matchedView });
79
+ const sentence = `Error (${tool}): ${core} ${DENY_REFUSAL_NOTE}`;
80
+ return { sentence, details: violationDetails({ code: "read_path_denied", message: sentence, target, pattern: w.deny.pattern }) };
81
+ }
82
+ const account = findingAccount(fold, roots);
83
+ const sentence = `Error (${tool}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`;
84
+ return { sentence, details: containmentDetails(paths) };
85
+ }
86
+ const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spelling and, where a filesystem can be consulted, the " +
87
+ "paths it would actually read; a deny-listed read is refused here rather than escalated.";
88
+ function verdictOf(tool, seat, fold, judged, boundary) {
89
+ const disposition = decideShellRead(fold.finding, seat);
90
+ if (disposition === "vouch")
91
+ return { disposition, finding: fold.finding };
92
+ const paths = outsidePathsOf(judged);
93
+ if (disposition === "refuse") {
94
+ const r = refusalOf(tool, fold, boundary.roots, paths);
95
+ return { disposition, finding: fold.finding, sentence: r.sentence, details: r.details };
96
+ }
97
+ const grant = fold.finding === "outside_roots" ? readRootGrantCandidateOf(judged, boundary) : undefined;
98
+ const containment = fold.finding === "outside_roots" || fold.finding === "unestablishable";
99
+ return {
100
+ disposition,
101
+ finding: fold.finding,
102
+ sentence: findingAccount(fold, boundary.roots),
103
+ ...(disposition === "mandated" && containment ? { details: containmentDetails(paths) } : {}),
104
+ ...(grant !== undefined ? { readRootCandidate: { dir: grant.dir, ...(grant.covers !== undefined ? { covers: grant.covers } : {}), clearsThisAsk: true } } : {}),
105
+ };
106
+ }
107
+ function declarationsOf(boundary, readers) {
108
+ return { readers, cwd: boundary.cwd ?? boundary.roots[0] ?? "", roots: boundary.roots, ...(boundary.homeDir !== undefined ? { homeDir: boundary.homeDir } : {}), ...(boundary.face !== undefined ? { face: boundary.face } : {}) };
109
+ }
110
+ function judgePlan(plan, established, boundary) {
111
+ return judgeEstablishedPlan(plan, established.reads, boundary, (n) => {
112
+ const nested = established.nested.get(n);
113
+ return nested === undefined ? { plan: n, segments: [] } : judgePlan(n, nested, boundary);
114
+ });
115
+ }
116
+ export function judgeShellReadLexically(command, boundary, seat, opts) {
117
+ const readers = opts?.readers ?? new Set(bashClassifyDefaultAllow());
118
+ const plan = planShellReads(command, declarationsOf(boundary, readers));
119
+ const established = establishShellReadsLexically(plan, boundary);
120
+ const judged = judgePlan(plan, established, boundary);
121
+ return verdictOf(opts?.tool ?? "Bash", seat, foldShellReadFindings(judged), judged, boundary);
122
+ }
123
+ export function createShellReadStation(opts) {
124
+ const readers = opts.readers ?? new Set(bashClassifyDefaultAllow());
125
+ const establishers = new Map();
126
+ const establisherFor = (roots) => {
127
+ const key = roots.join("\0");
128
+ let e = establishers.get(key);
129
+ if (e === undefined) {
130
+ e = createShellReadEstablisher(opts.env, roots);
131
+ establishers.set(key, e);
132
+ }
133
+ return e;
134
+ };
135
+ const admissions = new Map();
136
+ const remember = (toolCallId, a) => {
137
+ if (admissions.size >= ADMISSION_MAX) {
138
+ const oldest = admissions.keys().next().value;
139
+ if (oldest !== undefined)
140
+ admissions.delete(oldest);
141
+ }
142
+ admissions.set(toolCallId, a);
143
+ };
144
+ const applyAlsoInside = async (judged, boundary) => {
145
+ const alsoInside = boundary.alsoInside;
146
+ if (alsoInside === undefined)
147
+ return judged;
148
+ const rescued = new Set();
149
+ for (const f of allFindings(judged)) {
150
+ if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
151
+ continue;
152
+ if (await alsoInside(f.read.target))
153
+ rescued.add(f.read.target);
154
+ }
155
+ if (rescued.size === 0)
156
+ return judged;
157
+ const rewrite = (j) => ({
158
+ plan: j.plan,
159
+ segments: j.segments.map((s) => ({
160
+ segment: s.segment,
161
+ findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
162
+ nested: s.nested.map(rewrite),
163
+ })),
164
+ });
165
+ return rewrite(judged);
166
+ };
167
+ const check = {
168
+ judge(toolName, args, cwd, signal, callOpts) {
169
+ if (!opts.tools.has(toolName))
170
+ return null;
171
+ const command = shellCommandOf(args);
172
+ if (command === undefined)
173
+ return null;
174
+ const boundary = opts.boundaryOf(toolName, cwd);
175
+ if (boundary === undefined)
176
+ return null;
177
+ const seat = callOpts.seat ?? opts.seatOf(toolName);
178
+ return (async () => {
179
+ const plan = planShellReads(command, declarationsOf(boundary, readers));
180
+ const establisher = establisherFor(boundary.roots);
181
+ const established = await establisher.establish(plan, boundary, signal);
182
+ const judgedBoundary = { ...boundary, roots: established.roots };
183
+ const judged = await applyAlsoInside(judgePlan(plan, established, judgedBoundary), judgedBoundary);
184
+ const fold = foldShellReadFindings(judged);
185
+ const verdict = verdictOf(toolName, seat, fold, judged, judgedBoundary);
186
+ if (verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
187
+ remember(callOpts.toolCallId, { toolName, boundary: judgedBoundary, established, command });
188
+ return verdict;
189
+ })();
190
+ },
191
+ unanswered(toolName, cause) {
192
+ const seat = opts.seatOf(toolName);
193
+ const fold = { finding: "unestablishable" };
194
+ const disposition = decideShellRead("unestablishable", seat);
195
+ const account = `${cause}, so where this command reads could not be established ${NOT_AUTO_ALLOWED}`;
196
+ const details = containmentDetails([]);
197
+ if (disposition === "refuse")
198
+ return { disposition, finding: "unestablishable", sentence: `Error (${toolName}): ${account}. ${BASH_READONLY_CONFINEMENT_NOTE}`, details };
199
+ return { disposition, finding: fold.finding, sentence: account, ...(disposition === "mandated" ? { details } : {}) };
200
+ },
201
+ };
202
+ const admit = async (toolName, toolCallId, command, signal) => {
203
+ if (toolCallId === undefined)
204
+ return undefined;
205
+ const a = admissions.get(toolCallId);
206
+ if (a === undefined)
207
+ return undefined;
208
+ admissions.delete(toolCallId);
209
+ if (a.toolName !== toolName || a.command !== command) {
210
+ return errorResult(`Error (${toolName}): the command that would run is not the one the read station judged for this call — refused before the spawn; re-submit it as a fresh call.`, containmentDetails([]));
211
+ }
212
+ const establisher = establisherFor(a.boundary.roots);
213
+ const { changed } = await establisher.reestablish(a.established, a.boundary, signal);
214
+ if (changed.length === 0)
215
+ return undefined;
216
+ const again = await establisher.establish(a.established.plan, a.boundary, signal);
217
+ const judged = await applyAlsoInside(judgePlan(a.established.plan, again, a.boundary), a.boundary);
218
+ const fold = foldShellReadFindings(judged);
219
+ if (fold.finding === "deny_hit") {
220
+ const r = refusalOf(toolName, fold, a.boundary.roots, outsidePathsOf(judged));
221
+ return errorResult(r.sentence, r.details);
222
+ }
223
+ const moved = changed[0];
224
+ const identity = { finding: "identity_changed", witness: { read: moved, kind: "none", containment: "unknown" } };
225
+ const r = refusalOf(toolName, identity, a.boundary.roots, []);
226
+ return errorResult(r.sentence, containmentDetails(changed.map((c) => c.target).filter((t) => t !== undefined)));
227
+ };
228
+ return { check, admit };
229
+ }
@@ -1,127 +1,82 @@
1
1
  /**
2
- * ESTABLISHMENT — the filesystem-resolving half of the shell read boundary, as one value both shell
3
- * faces hold. The lexical pass (`bash-readonly-classifier.ts`) decides WHICH words a command may read;
4
- * this module decides WHERE those words land once the filesystem has answered: canonical targets, glob
5
- * expansions, and the fail-closed arm for what could not be established. It is the seam the read
6
- * judgment pipeline's `establish` step is built on; the candidate contract it will be fed from lives in
7
- * `shell-read-plan.ts`.
2
+ * ESTABLISHMENT — where a shell command's read candidates LAND once the filesystem has answered, as one
3
+ * value the gate's shell read station holds per execution environment. The plan (`shell-read-plan.ts`)
4
+ * decides WHICH words a command may read; this module decides WHERE each of them lands: the canonical
5
+ * target of a spelling (symlinks resolved, a `..` applied the way the kernel applies it), the expansion of
6
+ * a pattern (shell pathname semantics over `env.listDir`), the members of a traversal root (a bounded
7
+ * recursive enumeration, dot files included, symlinked directories not followed), and the fail-closed arm
8
+ * for what could not be established. The judge (`shell-read-judge.ts`) is downstream; nothing here decides
9
+ * allow/ask/deny.
8
10
  *
9
- * Extracted verbatim from `fs-bash.ts` (the `createCanonicalReadBoundary` family and its glob matcher):
10
- * behaviour is byte-for-byte the tool's own, and every property stated below is the property the tool
11
- * always held. Zero imports from `fs-bash.ts` (a leaf the shell faces import, never the reverse).
11
+ * Properties every consumer relies on:
12
+ * 1. A NON-EXISTENT target is not a refusal — `cat nope.txt` keeps the shell's own "No such file". The
13
+ * deepest EXISTING ancestor is canonicalized and the missing tail rejoined, so a symlinked PARENT still
14
+ * cannot carry a target out.
15
+ * 2. An unanswerable filesystem manufactures no refusal on its own: a chain no ancestor answered degrades
16
+ * to the lexical spelling on an environment that declares no authority for its resolver, and is
17
+ * `unestablishable` on one that does (`canonicalPathIsAuthoritative`).
18
+ * 3. An ISOLATED environment stays lexical, disclosed rather than silently degraded: the sandbox is the
19
+ * containment there and `canonicalPath` is an RPC into a filesystem that is not this host's
20
+ * (`boundaryIsTheSandbox`). The split is the DECLARED isolation capability, never the remote interface's
21
+ * shape — a host lane or an SSH adapter implements the same interface and is resolved on its own paths.
22
+ * 4. NO MATCH is not a refusal: an unmatched pattern is handed to the command verbatim, so an empty
23
+ * expansion falls back to the literal spelling.
24
+ * 5. What it cannot enumerate is `unestablishable`, never silently vouched: an unreadable directory, a
25
+ * pattern construct the matcher does not model, an expansion or a traversal past
26
+ * {@link MAX_GLOB_DIRS} / {@link MAX_GLOB_MATCHES}.
27
+ * 6. Cost is paid only by candidates that carry a metacharacter or a traversal, and only past the roots'
28
+ * own spelling. Inside one enumerated directory only the SYMLINK entries need their own `realpath`.
29
+ *
30
+ * NOT cached deliberately: a per-run memo keyed on the spelling would answer from a snapshot of where a
31
+ * symlink pointed EARLIER in the run, which is the one thing establishment exists to observe. The residual
32
+ * it cannot remove is the classic one — the link can be re-pointed between establishment and the shell's
33
+ * own open; the pre-spawn re-establishment (`reestablish`) narrows that window to the one inside a single
34
+ * call.
12
35
  */
13
36
  import type { ExecutionEnv } from "../../internal/harness-types.js";
14
- import type { ReadDenySeat } from "./read-deny.js";
37
+ import type { ShellReadPlan, ShellReadSegment } from "./shell-read-plan.js";
38
+ import type { EstablishedRead, ShellReadBoundary } from "./shell-read-judge.js";
15
39
  /**
16
- * How many directories one glob operand may enumerate, and how many names it may match, before
17
- * the leg gives up and refuses.
40
+ * How many directories one glob operand (or one traversal root) may enumerate, and how many names it may
41
+ * match, before the leg gives up and answers `unestablishable`.
18
42
  *
19
43
  * Sized so the everyday shapes never hit them: 256 directories covers a two-level pattern over a source
20
- * tree's top level, and 8192 names covers `ls *` in a dependency directory an order of magnitude larger than a
21
- * normal one. Past those the answer is a REFUSAL naming the bound, never a silent allow — an operand
22
- * this leg could not finish enumerating is exactly an operand whose expansion it cannot vouch for.
44
+ * tree's top level, and 8192 names covers `ls *` in a dependency directory an order of magnitude larger
45
+ * than a normal one. Past those the answer is fail-closed, never a silent allow — an operand this leg could
46
+ * not finish enumerating is exactly an operand whose expansion it cannot vouch for.
23
47
  */
24
48
  export declare const MAX_GLOB_DIRS = 256;
25
49
  export declare const MAX_GLOB_MATCHES = 8192;
26
- /**
27
- * The SYMLINK-RESOLVING half of the `bash_readonly` read boundary.
28
- *
29
- * {@link classifySimpleCommandReadBoundary} is synchronous and pure, so its containment is lexical: it
30
- * judges the written spelling, while the kernel opens the symlink-resolved target. The two diverge in
31
- * both directions, and both are closed here — this is the caller that holds an {@link ExecutionEnv}:
32
- * · LEAK — a symlink inside a root pointing out of it (`<root>/vendor` → `/etc`) has an in-root
33
- * prefix, so `cat vendor/passwd` was auto-allowed and really read `/etc/passwd`. Every candidate
34
- * the lexical pass accepted ({@link CompoundReadonlyVerdict.checkedPaths}) is canonicalized and
35
- * re-compared; one that leaves the roots is refused.
36
- * · FALSE REFUSAL — a non-canonical spelling of an in-root path (a symlinked `/tmp` → `/private/tmp`
37
- * prefix, or any aliased root) does not textually match the canonical roots. A refusal whose paths
38
- * ALL canonicalize back inside is dropped.
39
- *
40
- * Three properties this deliberately keeps:
41
- * 1. A NON-EXISTENT target must not become a boundary refusal — `cat nope.txt` has to keep producing
42
- * the shell's own "No such file", or the boundary would break ordinary use. The deepest EXISTING
43
- * ancestor is canonicalized and the missing tail rejoined (the rule safety.ts's `canonicalizeNewPath`
44
- * already uses for the structured tools), so a symlinked PARENT still cannot carry across the target out.
45
- * 2. Nothing here can manufacture a refusal out of an unanswerable filesystem: a path whose whole
46
- * ancestor chain fails to canonicalize degrades to the lexical verdict. (`/` resolves on any sane
47
- * env, so this is a defensive arm, not a routine one.)
48
- * 3. An ISOLATED env stays lexical, disclosed rather than silently degraded: the sandbox itself is the
49
- * containment there, and `canonicalPath` is an RPC into a filesystem that is not this host's. The
50
- * split is the DECLARED {@link isIsolated} capability, never the remote interface's SHAPE — an
51
- * adapter driving the operator's own machine (a host lane, an SSH host) implements that same
52
- * interface and declares no containment, so the resolving pass runs there, on the env's own paths.
53
- *
54
- * NOT cached deliberately: a per-run memo keyed on the lexical spelling would answer from a snapshot of
55
- * where a symlink pointed EARLIER in the run, which is the one thing this check exists to observe. The
56
- * cost it would save is a `realpath` on a local env. The residual it cannot remove either way is the
57
- * classic one — the link can be re-pointed between this check and the shell's own open.
58
- *
59
- * The GLOB half of the same seam, added here for the same reason (this is where the filesystem
60
- * is). A candidate carrying `*`/`?`/`[…]` is a path that does NOT exist as spelled, so property 1's
61
- * deepest-existing-ancestor rule resolved `<root>/*` to the root itself and pronounced it inside — after
62
- * which the real shell expanded the pattern and handed the command every matching entry, including one
63
- * that is a symlink out of the root. `cat vendor/secret.txt` was refused while `cat *` over the same
64
- * link was not. Such a candidate is now expanded against the filesystem FIRST (`expandGlobCandidate`,
65
- * shell-semantics matcher over `env.listDir`) and each expansion resolved individually. Its own
66
- * properties, on top of the three above:
67
- * 4. NO MATCH is not a refusal. The shell hands an unmatched pattern to the command verbatim, which then
68
- * reports its own "No such file" — so an empty expansion falls back to judging the literal spelling,
69
- * i.e. the literal-spelling answer (which also covers a file literally named `nomatch*`).
70
- * 5. What it could not enumerate, it REFUSES (`unverifiable`), never silently allows: an unreadable
71
- * directory, a pattern construct the matcher does not model, or an expansion past
72
- * {@link MAX_GLOB_DIRS}/{@link MAX_GLOB_MATCHES}. This face has no approval channel to degrade to
73
- * (see `createBashReadonlyTool`'s own note), so fail-closed is the only honest direction.
74
- * 6. Cost is paid only by candidates that actually carry a metacharacter, and only past the roots'
75
- * own spelling (a root literally containing `[` must not be re-globbed). A plain path takes the
76
- * same single `resolve` it always took. Inside one enumerated directory only the SYMLINK entries
77
- * need their own `realpath` — the directory is canonicalized once and a non-symlink child of a
78
- * canonical in-root directory is in-root by construction.
79
- */
80
- export declare function createCanonicalReadBoundary(env: ExecutionEnv, readRoots: readonly string[]): {
81
- /** What the read boundary objects to once the filesystem has answered: canonical targets OUTSIDE the roots,
82
- * the fail-closed arm (`unverifiable` — a candidate this leg could not establish, property 5), and
83
- * `denied`, the first path the wired deny judge matched. All empty/absent ⇒ no objection.
84
- *
85
- * The two input lists are NOT interchangeable, and the split is the lexical pass's answer, never a
86
- * re-reading of the text: `patterns` are the operands the shell will expand
87
- * ({@link CompoundReadonlyVerdict.undecidedPaths}), `literal` the ones it will not. Quoting separates them
88
- * and lives only in the RAW token, which these resolved paths no longer carry — `cat "link*"` reads a file
89
- * literally named `link*` and must not be re-globbed here. */
90
- escapesAfterResolution: (paths: {
91
- literal: readonly string[];
92
- patterns: readonly string[];
93
- }, ask: BoundaryQuestions, signal?: AbortSignal) => Promise<{
94
- escaping: readonly string[];
95
- unverifiable?: string;
96
- denied?: {
97
- path: string;
98
- pattern: string;
99
- };
50
+ /** The reads of one plan, established: per segment, in the plan's own order, with nested plans beside. */
51
+ export interface EstablishedPlan {
52
+ readonly plan: ShellReadPlan;
53
+ readonly reads: ReadonlyMap<ShellReadSegment, readonly EstablishedRead[]>;
54
+ readonly nested: ReadonlyMap<ShellReadPlan, EstablishedPlan>;
55
+ /** The roots the reads were judged against — canonical where the environment could say. */
56
+ readonly roots: readonly string[];
57
+ }
58
+ /** ONE establishment leg per execution environment — the filesystem half of the shell read station. */
59
+ export interface ShellReadEstablisher {
60
+ /** Whether this environment is judged lexically (an isolated sandbox — property 3). */
61
+ readonly lexicalOnly: boolean;
62
+ /** Establish every read candidate of `plan` (nested plans included) against `boundary`'s roots. */
63
+ establish(plan: ShellReadPlan, boundary: ShellReadBoundary, signal?: AbortSignal): Promise<EstablishedPlan>;
64
+ /** Establish the same plan again and report every candidate whose canonical target moved — the pre-spawn
65
+ * identity re-check. Candidates that were lexical or unestablishable are not compared: nothing was
66
+ * established to move. */
67
+ reestablish(previous: EstablishedPlan, boundary: ShellReadBoundary, signal?: AbortSignal): Promise<{
68
+ readonly changed: readonly EstablishedRead[];
100
69
  }>;
101
- /** Do ALL of `paths` canonicalize back INSIDE the roots? (false for an empty list, and where the sandbox is the containment.) */
102
- allResolveInside: (paths: readonly string[], signal?: AbortSignal) => Promise<boolean>;
103
- /** Where one spelling really lands, for a caller deciding a GRANT on something other than the roots.
104
- * `undefined` where the sandbox is the containment or the chain is unanswerable, and the
105
- * caller then judges the written spelling — what it had before. */
106
- canonicalOf: (path: string, signal?: AbortSignal) => Promise<string | undefined>;
107
- };
108
- /** The symlink-resolving second pass, as one value both shell faces hold. */
109
- export type CanonicalReadBoundary = ReturnType<typeof createCanonicalReadBoundary>;
110
- /** WHICH of the boundary's two halves still has a question once the filesystem can answer. Not a mode and not
111
- * a face: the face DECIDES these two, separately. Containment is structurally satisfied under the OPEN read
112
- * face (an out-of-roots operand rides `checkedPaths` there as an ACCEPTED candidate); the deny half judges in
113
- * BOTH faces by its seat's own contract, so a face-level exit takes it along with the other half. */
114
- export interface BoundaryQuestions {
115
- readonly deny: ReadDenySeat["denyMatch"];
116
- readonly containment: boolean;
117
- /** Per HALF, the paths this face must NOT re-judge, because the approver was asked THAT question about
118
- * THAT path and said yes. Two sets, not one, and the difference is a leak: "release this deny row" is
119
- * not "release wherever it lands" (a deny-listed name that is a symlink OUT of the roots ran, and
120
- * ADDING the deny row is what turned the refusal off), and "read outside the roots" says nothing about
121
- * the deny table the target is on. Keyed on the folded spelling the card named; empty = nothing
122
- * answered. */
123
- readonly denyAnswered: ReadonlySet<string>;
124
- readonly containmentAnswered: ReadonlySet<string>;
125
70
  }
126
- /** The empty half-answer, for a face with no approval channel to have been answered through. */
127
- export declare const NOTHING_ANSWERED: ReadonlySet<string>;
71
+ /**
72
+ * The PURE, lexical establishment of a whole plan — what an isolated sandbox gets, and what any consumer
73
+ * with no environment in hand (a unit pin, a lexical pre-read) can compute. Patterns and traversal roots
74
+ * are judged on their spelling alone here (KL-934a: the sandbox's own containment is the floor there).
75
+ */
76
+ export declare function establishShellReadsLexically(plan: ShellReadPlan, boundary: ShellReadBoundary): EstablishedPlan;
77
+ /**
78
+ * The filesystem-resolving establisher for one environment and one root set. `readRoots` are the roots
79
+ * as declared (canonical by contract); they are resolved once more per instance because the comparison is
80
+ * against `realpath` output on both sides — an aliased root would otherwise refuse every read under itself.
81
+ */
82
+ export declare function createShellReadEstablisher(env: ExecutionEnv, readRoots: readonly string[]): ShellReadEstablisher;