@sema-agent/core 7.26.0 → 7.26.2
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 +73 -0
- package/dist/agents/verify.d.ts +16 -6
- package/dist/agents/verify.js +12 -5
- package/dist/core/checkpoint-store.d.ts +13 -10
- package/dist/core/gate-lanes.js +2 -1
- package/dist/core/gate-outcome.d.ts +54 -5
- package/dist/core/gate-outcome.js +17 -0
- package/dist/core/permission-rule-provider.js +6 -1
- package/dist/core/permission-rule-store.d.ts +36 -0
- package/dist/core/permission-rule-store.js +119 -19
- package/dist/core/permission-rule-sync.js +14 -5
- package/dist/core/runner/contracts.d.ts +48 -0
- package/dist/core/runner/decide-continuation.js +3 -1
- package/dist/core/runner/denial-limit-arms.d.ts +4 -3
- package/dist/core/runner/denial-limit-arms.js +2 -1
- package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
- package/dist/core/runner/prepare-inherited-gate.js +2 -1
- package/dist/core/runner/resume-apply.js +3 -1
- package/dist/core/runner/resume-policy-outcome.js +6 -6
- package/dist/core/runner/run-harness-handlers.js +2 -5
- package/dist/core/runner/run-identity-wiring.d.ts +6 -0
- package/dist/core/runner/run-identity-wiring.js +8 -3
- package/dist/core/runner/run-leg.js +9 -3
- package/dist/core/runner/runtask.js +2 -2
- package/dist/core/runner/session-rule-policy.d.ts +1 -1
- package/dist/core/runner/session-rule-policy.js +6 -7
- package/dist/core/session-policy-store.d.ts +18 -0
- package/dist/core/session-policy-store.js +3 -0
- package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
- package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
- package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
- package/dist/core/tool-policy.d.ts +36 -12
- package/dist/core/tool-policy.js +22 -9
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/stores/file/adoption/adopt.js +4 -10
- package/dist/stores/file/permission-rule-store.d.ts +5 -1
- package/dist/stores/file/permission-rule-store.js +5 -3
- package/dist/stores/file/session-policy-store.d.ts +32 -0
- package/dist/stores/file/session-policy-store.js +72 -50
- package/dist/tools/fs/shell-read-check.d.ts +58 -1
- package/dist/tools/fs/shell-read-check.js +47 -33
- package/dist/tools/fs/shell-read-establish.js +19 -8
- package/dist/tools/fs/shell-read-judge.d.ts +3 -0
- package/dist/tools/fs/shell-read-judge.js +3 -1
- package/dist/tools/fs/shell-read-plan.js +16 -17
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +52 -2
- package/wire-changes/core@7.26.1.json +605 -0
- package/wire-changes/core@7.26.2.json +106 -0
|
@@ -40,6 +40,34 @@ export function readSessionPolicyMark(path) {
|
|
|
40
40
|
return refuse("carries a lineage that is not a whole number");
|
|
41
41
|
return mark;
|
|
42
42
|
}
|
|
43
|
+
export function judgeSessionPolicyRowObject(raw) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { corrupt: { grade: "unparseable", reason: "unparseable JSON (torn/zero-byte write?)" } };
|
|
50
|
+
}
|
|
51
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
52
|
+
return { corrupt: { grade: "malformed", reason: "not a JSON object" } };
|
|
53
|
+
}
|
|
54
|
+
return { row: parsed };
|
|
55
|
+
}
|
|
56
|
+
export function judgeSessionPolicyRowShape(row) {
|
|
57
|
+
const bad = (reason) => ({ corrupt: { grade: "malformed", reason } });
|
|
58
|
+
if (typeof row.rev !== "number" || !Number.isFinite(row.rev) || row.rev < 1)
|
|
59
|
+
return bad("rev is not a positive number");
|
|
60
|
+
if (row.gen !== undefined && (typeof row.gen !== "number" || !Number.isInteger(row.gen) || row.gen < 0))
|
|
61
|
+
return bad("gen is not a whole number");
|
|
62
|
+
const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
|
|
63
|
+
if (!strArr(row.toolAllow) || !strArr(row.toolDeny) || !strArr(row.commandAllow) || !strArr(row.commandDeny) || !strArr(row.allowDirs)) {
|
|
64
|
+
return bad("malformed rule field");
|
|
65
|
+
}
|
|
66
|
+
const { __sid: _sid, __principal: _principal, ...clean } = row;
|
|
67
|
+
void _sid;
|
|
68
|
+
void _principal;
|
|
69
|
+
return { rules: clean };
|
|
70
|
+
}
|
|
43
71
|
export class FileSessionPolicyStore {
|
|
44
72
|
dir;
|
|
45
73
|
onCorruptRead;
|
|
@@ -75,7 +103,9 @@ export class FileSessionPolicyStore {
|
|
|
75
103
|
return join(this.dir, `${this.basenameFor(sessionId, principal)}${MARK_SUFFIX}`);
|
|
76
104
|
}
|
|
77
105
|
writeMarkFile(path, rev, sessionId, principal, gen) {
|
|
78
|
-
|
|
106
|
+
this.writeMark(path, { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal });
|
|
107
|
+
}
|
|
108
|
+
writeMark(path, mark) {
|
|
79
109
|
atomicWriteFile(join(this.dir, "tmp"), path, JSON.stringify(mark));
|
|
80
110
|
}
|
|
81
111
|
read(sessionId, principal) {
|
|
@@ -88,35 +118,17 @@ export class FileSessionPolicyStore {
|
|
|
88
118
|
return null;
|
|
89
119
|
throw err;
|
|
90
120
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
const r = parsed;
|
|
98
|
-
if (typeof r.rev !== "number" || !Number.isFinite(r.rev) || r.rev < 1) {
|
|
99
|
-
this.discloseCorrupt(sessionId, principal, "rev is not a positive number");
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
if (r.gen !== undefined && (typeof r.gen !== "number" || !Number.isInteger(r.gen) || r.gen < 0)) {
|
|
103
|
-
this.discloseCorrupt(sessionId, principal, "gen is not a whole number");
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
|
|
107
|
-
if (!strArr(r.toolAllow) || !strArr(r.toolDeny) || !strArr(r.commandAllow) || !strArr(r.commandDeny) || !strArr(r.allowDirs)) {
|
|
108
|
-
this.discloseCorrupt(sessionId, principal, "malformed rule field");
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
const { __sid: _sid, __principal: _principal, ...clean } = r;
|
|
112
|
-
void _sid;
|
|
113
|
-
void _principal;
|
|
114
|
-
return clean;
|
|
121
|
+
const judged = judgeSessionPolicyRowObject(raw);
|
|
122
|
+
if ("corrupt" in judged) {
|
|
123
|
+
this.discloseCorrupt(sessionId, principal, judged.corrupt.reason);
|
|
124
|
+
return null;
|
|
115
125
|
}
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
const shaped = judgeSessionPolicyRowShape(judged.row);
|
|
127
|
+
if ("corrupt" in shaped) {
|
|
128
|
+
this.discloseCorrupt(sessionId, principal, shaped.corrupt.reason);
|
|
118
129
|
return null;
|
|
119
130
|
}
|
|
131
|
+
return shaped.rules;
|
|
120
132
|
}
|
|
121
133
|
async getRules(sessionId, principal) {
|
|
122
134
|
return this.read(sessionId, principal);
|
|
@@ -134,8 +146,12 @@ export class FileSessionPolicyStore {
|
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
const mark = readSessionPolicyMark(this.markPathFor(sessionId, principal));
|
|
149
|
+
const rowFilePresent = prior === null && existsSync(this.pathFor(sessionId, principal));
|
|
150
|
+
if (mark?.__lineage === "lost" || (rowFilePresent && mark === null)) {
|
|
151
|
+
throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (${mark?.__lineage === "lost" ? "the erase left a lost-lineage mark" : "an unreadable row and no high-water mark"}) — refusing to restart the key's lineage by guessing; delete the mark${rowFilePresent ? " and the row file" : ""} to restart it`);
|
|
152
|
+
}
|
|
137
153
|
const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
|
|
138
|
-
const gen = prior
|
|
154
|
+
const gen = prior !== null ? (prior.gen ?? 0) : rowFilePresent ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0);
|
|
139
155
|
const next = { ...clean, rev: floor + 1, gen };
|
|
140
156
|
const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
|
|
141
157
|
this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
|
|
@@ -169,24 +185,28 @@ export class FileSessionPolicyStore {
|
|
|
169
185
|
}
|
|
170
186
|
continue;
|
|
171
187
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
const judged = judgeSessionPolicyRowObject(raw);
|
|
189
|
+
if ("corrupt" in judged) {
|
|
190
|
+
this.disclose({ sessionId, path: full, reason: judged.corrupt.reason });
|
|
191
|
+
continue;
|
|
175
192
|
}
|
|
176
|
-
|
|
177
|
-
this.disclose({ sessionId, path: full, reason: "
|
|
193
|
+
if (typeof judged.row.__sid !== "string") {
|
|
194
|
+
this.disclose({ sessionId, path: full, reason: "session metadata is not a string" });
|
|
178
195
|
continue;
|
|
179
196
|
}
|
|
180
|
-
if (
|
|
181
|
-
|
|
197
|
+
if (judged.row.__sid !== sessionId)
|
|
198
|
+
continue;
|
|
199
|
+
const shaped = judgeSessionPolicyRowShape(judged.row);
|
|
200
|
+
if ("corrupt" in shaped) {
|
|
201
|
+
this.disclose({ sessionId, path: full, reason: shaped.corrupt.reason });
|
|
182
202
|
continue;
|
|
183
203
|
}
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
204
|
+
const attributed = judged.row.__principal;
|
|
205
|
+
if (attributed !== undefined && attributed !== null && typeof attributed !== "string") {
|
|
206
|
+
this.disclose({ sessionId, path: full, reason: "principal metadata is not a string" });
|
|
186
207
|
continue;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
out.push({ principal: __principal ?? undefined, rules: rules });
|
|
208
|
+
}
|
|
209
|
+
out.push({ principal: attributed ?? undefined, rules: shaped.rules });
|
|
190
210
|
}
|
|
191
211
|
return out;
|
|
192
212
|
}
|
|
@@ -212,21 +232,25 @@ export class FileSessionPolicyStore {
|
|
|
212
232
|
catch {
|
|
213
233
|
continue;
|
|
214
234
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
catch {
|
|
235
|
+
const judged = judgeSessionPolicyRowObject(raw);
|
|
236
|
+
if ("corrupt" in judged) {
|
|
237
|
+
if (judged.corrupt.grade !== "unparseable")
|
|
238
|
+
continue;
|
|
220
239
|
const tornMarkPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
|
|
221
240
|
try {
|
|
222
241
|
const tornMark = readSessionPolicyMark(tornMarkPath);
|
|
223
|
-
if (tornMark
|
|
224
|
-
this.
|
|
242
|
+
if (tornMark === null) {
|
|
243
|
+
this.writeMark(tornMarkPath, { __hwm: 0, __gen: 0, __lineage: "lost" });
|
|
244
|
+
this.disclose({ sessionId, path: join(this.dir, file), reason: "erase purged a torn row that had no high-water mark: the key's lineage is lost, writes are refused until the mark is deleted" });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
this.writeMark(tornMarkPath, { ...tornMark, __gen: (tornMark.__gen ?? 0) + 1 });
|
|
225
248
|
}
|
|
226
249
|
}
|
|
227
250
|
catch (err) {
|
|
228
251
|
if (!(err instanceof SessionPolicyError))
|
|
229
252
|
throw err;
|
|
253
|
+
this.disclose({ sessionId, path: join(this.dir, file), reason: `erase purged a torn row whose high-water mark is corrupt (${err.message}); the mark is left for the operator` });
|
|
230
254
|
}
|
|
231
255
|
try {
|
|
232
256
|
unlinkSync(join(this.dir, file));
|
|
@@ -237,9 +261,7 @@ export class FileSessionPolicyStore {
|
|
|
237
261
|
}
|
|
238
262
|
continue;
|
|
239
263
|
}
|
|
240
|
-
|
|
241
|
-
continue;
|
|
242
|
-
const row = parsed;
|
|
264
|
+
const row = judged.row;
|
|
243
265
|
if (row.__sid !== sessionId)
|
|
244
266
|
continue;
|
|
245
267
|
const markPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* tools' execute legs judge nothing: what they run is what this station admitted, and the admission only
|
|
13
13
|
* re-establishes the SAME candidates (a symlink re-pointed while the approver was thinking) — it never
|
|
14
14
|
* parses text or judges a word the station did not.
|
|
15
|
+
*
|
|
16
|
+
* That pipeline is written ONCE (`runShellReadStation`) and has two entries: the gate input above, and
|
|
17
|
+
* {@link judgeShellRead}, the offline face. The steps between stay private — the station's working, not
|
|
18
|
+
* its answer, and a caller composing them would be a second judgment of the same question.
|
|
15
19
|
*/
|
|
16
20
|
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
17
21
|
import { violationDetails } from "./safety.js";
|
|
18
|
-
import { type
|
|
22
|
+
import { type ShellReadPlan } from "./shell-read-plan.js";
|
|
23
|
+
import { type EstablishedRead, type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
|
|
19
24
|
import type { ReadRootGrantCandidate } from "../../core/tool-spec.js";
|
|
20
25
|
/** The ONE mint of the containment card — every arm that refuses or asks on a containment finding (a refusal, a
|
|
21
26
|
* mandated card's own card, a station that could not answer, the pre-spawn re-establishment) spells it here. */
|
|
@@ -85,6 +90,58 @@ export interface ShellReadStationOptions {
|
|
|
85
90
|
/** The listed read verbs (decides whether a segment is read by verb slots or word by word). */
|
|
86
91
|
readonly readers?: ReadonlySet<string>;
|
|
87
92
|
}
|
|
93
|
+
/** What a consumer hands {@link judgeShellRead}: the command as written, the environment whose filesystem
|
|
94
|
+
* answers where its words land (symlinks resolved, patterns expanded — a `NodeExecutionEnv` over a real
|
|
95
|
+
* directory is what the gate itself runs on), the boundary it is judged against, and the trust-policy seat
|
|
96
|
+
* it is judged on. The working directory a relative operand starts from is the boundary's own `cwd`. */
|
|
97
|
+
export interface ShellReadJudgeInput {
|
|
98
|
+
readonly command: string;
|
|
99
|
+
readonly env: ExecutionEnv;
|
|
100
|
+
readonly boundary: ShellReadBoundary;
|
|
101
|
+
readonly seat: ShellReadSeat;
|
|
102
|
+
/** The listed read verbs — which words of a segment are read operands. Defaults to the classify face's. */
|
|
103
|
+
readonly readers?: ReadonlySet<string>;
|
|
104
|
+
/** The face named in a refusal sentence. Defaults to `Bash`. */
|
|
105
|
+
readonly tool?: string;
|
|
106
|
+
/** Handed to the ENVIRONMENT's own primitives (`canonicalPath`, `listDir`) while the reads are
|
|
107
|
+
* established; whether an abort stops one is the adapter's answer, and a walk short enough to finish
|
|
108
|
+
* first finishes. It is NOT a deadline and it does not reach `boundary.alsoInside`, whose signature
|
|
109
|
+
* carries no signal — see {@link judgeShellRead} on which layer owns the bound. */
|
|
110
|
+
readonly signal?: AbortSignal;
|
|
111
|
+
}
|
|
112
|
+
/** What {@link judgeShellRead} answers about ONE command. */
|
|
113
|
+
export interface ShellReadJudgment {
|
|
114
|
+
/** What the station read out of the command's text before any filesystem was consulted. */
|
|
115
|
+
readonly plan: ShellReadPlan;
|
|
116
|
+
/** Every read candidate the station established, in the plan's own order, nested plans included — each
|
|
117
|
+
* carrying how far it got (`EstablishedRead.established`) and the target it was judged on. */
|
|
118
|
+
readonly establishment: readonly EstablishedRead[];
|
|
119
|
+
readonly verdict: ShellReadVerdict;
|
|
120
|
+
/** The directory-scoped grant offer THIS STATION is willing to make — the verdict's candidate through the
|
|
121
|
+
* same validator the gate applies, so a candidate that validator drops is absent here as it is there. It
|
|
122
|
+
* is the station's offer, not the composed card's: over an ask another layer already raised, the fold
|
|
123
|
+
* adds the mandate and nothing else, so the offer does not land there. */
|
|
124
|
+
readonly offer?: ReadRootGrantCandidate;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The station's judgment of ONE command, OUTSIDE a run — the consumer face of everything above. Same plan,
|
|
128
|
+
* same establishment, same judge, same table, same verdict the gate fold reads: this is
|
|
129
|
+
* {@link createShellReadStation}'s own derivation with the admission bookkeeping left out, so a caller
|
|
130
|
+
* holding a command, an environment and a boundary gets the decision the gate would reach for them.
|
|
131
|
+
*
|
|
132
|
+
* An ENVIRONMENT question, not a text question: a symlink that leaves the roots, a pattern that expands
|
|
133
|
+
* onto a deny row, a traversal root that cannot be enumerated are answered by `env`, not guessed from the
|
|
134
|
+
* spelling. {@link judgeShellReadLexically} is the no-environment reading and not a substitute — it is
|
|
135
|
+
* LAXER where only a filesystem sees the escape (an in-root symlink pointing out vouches) and answers a
|
|
136
|
+
* pattern on its spelling alone, so the two diverge in both directions.
|
|
137
|
+
*
|
|
138
|
+
* It carries NO deadline. Bounding an environment-facing wait and substituting a fail-closed verdict is the
|
|
139
|
+
* gate FOLD's policy on the gate's one deadline seat; the station has never held one on either entry, and a
|
|
140
|
+
* second default here would decide the same number twice and turn a slow-but-correct filesystem's true
|
|
141
|
+
* answer into a fail-closed one unasked. A consumer wanting that posture races its own deadline and reads
|
|
142
|
+
* the row the fold reads: `SHELL_READ_DECISION.unestablishable[seat]`.
|
|
143
|
+
*/
|
|
144
|
+
export declare function judgeShellRead(input: ShellReadJudgeInput): Promise<ShellReadJudgment>;
|
|
88
145
|
/**
|
|
89
146
|
* The station's verdict with NO environment — the lexical reading (what an isolated sandbox gets, and what a
|
|
90
147
|
* pin or a lexical pre-read can compute): the same plan, the same judge, the same table, over the folded
|
|
@@ -7,6 +7,7 @@ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
|
|
|
7
7
|
import { planShellReads } from "./shell-read-plan.js";
|
|
8
8
|
import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
|
|
9
9
|
import { allFindings, decideShellRead, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, } from "./shell-read-judge.js";
|
|
10
|
+
import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js";
|
|
10
11
|
import { wordsOf, lexShellText } from "./bash-lexer.js";
|
|
11
12
|
function containmentDetails(paths) {
|
|
12
13
|
return { type: "readonly_out_of_root", code: "readonly_out_of_root", paths };
|
|
@@ -113,6 +114,48 @@ function judgePlan(plan, established, boundary) {
|
|
|
113
114
|
return nested === undefined ? { plan: n, segments: [] } : judgePlan(n, nested, boundary);
|
|
114
115
|
});
|
|
115
116
|
}
|
|
117
|
+
async function applyAlsoInside(judged, boundary) {
|
|
118
|
+
const alsoInside = boundary.alsoInside;
|
|
119
|
+
if (alsoInside === undefined)
|
|
120
|
+
return judged;
|
|
121
|
+
const rescued = new Set();
|
|
122
|
+
for (const f of allFindings(judged)) {
|
|
123
|
+
if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
|
|
124
|
+
continue;
|
|
125
|
+
if (await alsoInside(f.read.target))
|
|
126
|
+
rescued.add(f.read.target);
|
|
127
|
+
}
|
|
128
|
+
if (rescued.size === 0)
|
|
129
|
+
return judged;
|
|
130
|
+
const rewrite = (j) => ({
|
|
131
|
+
plan: j.plan,
|
|
132
|
+
segments: j.segments.map((s) => ({
|
|
133
|
+
segment: s.segment,
|
|
134
|
+
findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
|
|
135
|
+
nested: s.nested.map(rewrite),
|
|
136
|
+
})),
|
|
137
|
+
});
|
|
138
|
+
return rewrite(judged);
|
|
139
|
+
}
|
|
140
|
+
async function runShellReadStation(tool, command, declared, seat, readers, establisher, signal) {
|
|
141
|
+
const plan = planShellReads(command, declarationsOf(declared, readers));
|
|
142
|
+
const established = await establisher.establish(plan, declared, signal);
|
|
143
|
+
const boundary = { ...declared, roots: established.roots };
|
|
144
|
+
const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
|
|
145
|
+
const verdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
|
|
146
|
+
return { plan, established, judged, boundary, verdict };
|
|
147
|
+
}
|
|
148
|
+
export async function judgeShellRead(input) {
|
|
149
|
+
const readers = input.readers ?? new Set(bashClassifyDefaultAllow());
|
|
150
|
+
const run = await runShellReadStation(input.tool ?? "Bash", input.command, input.boundary, input.seat, readers, createShellReadEstablisher(input.env, input.boundary.roots), input.signal);
|
|
151
|
+
const offer = normalizeReadRootCandidate(run.verdict.readRootCandidate);
|
|
152
|
+
return {
|
|
153
|
+
plan: run.plan,
|
|
154
|
+
establishment: allFindings(run.judged).map((f) => f.read),
|
|
155
|
+
verdict: run.verdict,
|
|
156
|
+
...(offer !== undefined ? { offer } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
116
159
|
export function judgeShellReadLexically(command, boundary, seat, opts) {
|
|
117
160
|
const readers = opts?.readers ?? new Set(bashClassifyDefaultAllow());
|
|
118
161
|
const plan = planShellReads(command, declarationsOf(boundary, readers));
|
|
@@ -141,29 +184,6 @@ export function createShellReadStation(opts) {
|
|
|
141
184
|
}
|
|
142
185
|
admissions.set(toolCallId, a);
|
|
143
186
|
};
|
|
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
187
|
const check = {
|
|
168
188
|
judge(toolName, args, cwd, signal, callOpts) {
|
|
169
189
|
if (!opts.tools.has(toolName))
|
|
@@ -176,16 +196,10 @@ export function createShellReadStation(opts) {
|
|
|
176
196
|
return null;
|
|
177
197
|
const seat = callOpts.seat ?? opts.seatOf(toolName);
|
|
178
198
|
return (async () => {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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;
|
|
199
|
+
const run = await runShellReadStation(toolName, command, boundary, seat, readers, establisherFor(boundary.roots), signal);
|
|
200
|
+
if (run.verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
|
|
201
|
+
remember(callOpts.toolCallId, { toolName, boundary: run.boundary, established: run.established, command });
|
|
202
|
+
return run.verdict;
|
|
189
203
|
})();
|
|
190
204
|
},
|
|
191
205
|
unanswered(toolName, cause) {
|
|
@@ -281,7 +281,9 @@ export function createShellReadEstablisher(env, readRoots) {
|
|
|
281
281
|
return { paths: frontier, canonical: canonicalOfMatch };
|
|
282
282
|
};
|
|
283
283
|
const enumerateTraversal = async (rootCanonical, signal) => {
|
|
284
|
-
const
|
|
284
|
+
const found = [];
|
|
285
|
+
const listedDirs = new Set();
|
|
286
|
+
let root;
|
|
285
287
|
const stack = [{ dir: rootCanonical, spell: rootCanonical }];
|
|
286
288
|
const visited = new Set([rootCanonical]);
|
|
287
289
|
let dirsUsed = 0;
|
|
@@ -291,18 +293,23 @@ export function createShellReadEstablisher(env, readRoots) {
|
|
|
291
293
|
return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify what a recursive read would open` };
|
|
292
294
|
const listed = await env.listDir(dir, signal);
|
|
293
295
|
if (!listed.ok) {
|
|
296
|
+
if (dir === rootCanonical && listed.error.code === "not_directory")
|
|
297
|
+
root = "file";
|
|
294
298
|
if (listed.error.code === "not_directory" || listed.error.code === "not_found")
|
|
295
299
|
continue;
|
|
296
300
|
return { unverifiable: `"${dir}" cannot be enumerated to verify what a recursive read would open (${listed.error.code})` };
|
|
297
301
|
}
|
|
302
|
+
listedDirs.add(dir);
|
|
303
|
+
if (dir === rootCanonical)
|
|
304
|
+
root = "directory";
|
|
298
305
|
for (const entry of listed.value) {
|
|
299
|
-
if (
|
|
306
|
+
if (found.length >= MAX_GLOB_MATCHES)
|
|
300
307
|
return { unverifiable: `"${rootCanonical}" holds more than ${MAX_GLOB_MATCHES} entries, too many to enumerate to verify what a recursive read would open` };
|
|
301
308
|
const path = joinUnder(spell, entry.name);
|
|
302
309
|
const canonicalPath = joinUnder(dir, entry.name);
|
|
303
310
|
if (entry.kind === "symlink") {
|
|
304
311
|
const canon = await env.canonicalPath(canonicalPath, signal);
|
|
305
|
-
|
|
312
|
+
found.push({ path, ...(canon.ok ? { canonical: canon.value } : {}), symlink: true });
|
|
306
313
|
if (canon.ok && !visited.has(canon.value)) {
|
|
307
314
|
visited.add(canon.value);
|
|
308
315
|
stack.push({ dir: canon.value, spell: path });
|
|
@@ -316,10 +323,11 @@ export function createShellReadEstablisher(env, readRoots) {
|
|
|
316
323
|
}
|
|
317
324
|
continue;
|
|
318
325
|
}
|
|
319
|
-
|
|
326
|
+
found.push({ path, canonical: canonicalPath, symlink: false });
|
|
320
327
|
}
|
|
321
328
|
}
|
|
322
|
-
|
|
329
|
+
const members = found.map(({ path, canonical, symlink }) => ({ path, ...(canonical !== undefined ? { canonical } : {}), node: (symlink && canonical !== undefined && listedDirs.has(canonical) ? "directory" : "file") }));
|
|
330
|
+
return { ...(root !== undefined ? { root } : {}), members };
|
|
323
331
|
};
|
|
324
332
|
const canonicalReadOf = async (candidate, spelling, spellings, established, origin, canonicalKnown, signal) => {
|
|
325
333
|
const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(spelling, signal);
|
|
@@ -338,16 +346,19 @@ export function createShellReadEstablisher(env, readRoots) {
|
|
|
338
346
|
for (const candidate of judgedCandidatesOf(segment)) {
|
|
339
347
|
const traversal = candidate.provenance === "traversal_root";
|
|
340
348
|
const land = async (read, spellings, folded) => {
|
|
341
|
-
|
|
342
|
-
|
|
349
|
+
if (!traversal || (read.established !== "canonical" && read.established !== "expansion") || read.target === undefined) {
|
|
350
|
+
out.push(read);
|
|
343
351
|
return;
|
|
352
|
+
}
|
|
344
353
|
const walkedTree = await enumerateTraversal(read.target, signal);
|
|
345
354
|
if ("unverifiable" in walkedTree) {
|
|
355
|
+
out.push(read);
|
|
346
356
|
out.push({ candidate, established: "unestablishable", spellings, origin: folded, cause: walkedTree.unverifiable });
|
|
347
357
|
return;
|
|
348
358
|
}
|
|
359
|
+
out.push({ ...read, ...(walkedTree.root !== undefined ? { node: walkedTree.root } : {}) });
|
|
349
360
|
for (const m of walkedTree.members)
|
|
350
|
-
out.push(await canonicalReadOf(candidate, m.path, [m.path], "expansion", folded, m.canonical, signal));
|
|
361
|
+
out.push({ ...(await canonicalReadOf(candidate, m.path, [m.path], "expansion", folded, m.canonical, signal)), node: m.node });
|
|
351
362
|
};
|
|
352
363
|
for (const lexical of lexicalReadsOf(candidate, boundary)) {
|
|
353
364
|
if (lexical.established !== "lexical") {
|
|
@@ -133,6 +133,9 @@ export interface EstablishedRead {
|
|
|
133
133
|
readonly spellings: readonly string[];
|
|
134
134
|
/** The pattern or traversal root an `expansion` came from, as folded. */
|
|
135
135
|
readonly origin?: string;
|
|
136
|
+
/** What the environment said the target IS, where a read asked — only a traversal asks (it lists the root and
|
|
137
|
+
* every member it walks; a link's answer is its target's). Absent = nothing asked; a plain read never lists. */
|
|
138
|
+
readonly node?: "directory" | "file";
|
|
136
139
|
/** Why the candidate could not be established. */
|
|
137
140
|
readonly cause?: string;
|
|
138
141
|
}
|
|
@@ -135,7 +135,9 @@ export function readRootGrantCandidateOf(judged, boundary) {
|
|
|
135
135
|
return undefined;
|
|
136
136
|
if (paths.some(hasWin32AliasedComponent))
|
|
137
137
|
return undefined;
|
|
138
|
-
const readsDirectory = new Set(outside
|
|
138
|
+
const readsDirectory = new Set(outside
|
|
139
|
+
.filter((f) => (f.read.candidate.provenance === "traversal_root" && f.read.node !== "file") || f.read.candidate.provenance === "cd_target")
|
|
140
|
+
.map((f) => f.read.target));
|
|
139
141
|
const dirOf = (path) => (readsDirectory.has(path) ? path : parentDirLexically(path));
|
|
140
142
|
let candidate = dirOf(paths[0]);
|
|
141
143
|
for (const path of paths.slice(1)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lexShellText, substitutionsInHeredocBody, wordsOf, literalTildeSpelling, SUBSTITUTION_PLACEHOLDER, } from "./bash-lexer.js";
|
|
2
|
-
import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, } from "./bash-program-position.js";
|
|
2
|
+
import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, writtenAssignmentPrefix, } from "./bash-program-position.js";
|
|
3
3
|
import { ASSIGNMENT_VALUE_NOT_PATHS, INTERPRETER_INLINE_PROGRAMS, NO_PATH_OPERAND_COMMANDS, POLL_LOOP_MAX_BEATS, RECURSIVE_READ_FORMS, SHELL_REENTRY_PROGRAM_RE, UNMODELLED_BASE_MOVERS, argvProgramOf, assignmentWordPayload, attachedOptionPayloads, foldHomeVariableSpelling, resolveOperandLexically, hasUnquotedExpansionMetachar, hasUnquotedGlobMetachar, inlineProgramClause, interpreterKey, isPathShapedToken, readVerbOptionSlot, sedScriptSlotFiles, segmentSelectsRecursiveRead, slotValueCandidates, suppliesFirstOperandSlot, verbFirstOperandSlot, verbOptionsArePositionIndependent, } from "./bash-readonly-classifier.js";
|
|
4
4
|
import { isAbsolutePathForm } from "./safety.js";
|
|
5
5
|
export const READ_CANDIDATE_PROVENANCES = [
|
|
@@ -241,8 +241,9 @@ function segmentFacts(segment, p, decl, nestedPlan) {
|
|
|
241
241
|
const basename = program?.basename;
|
|
242
242
|
const listed = basename !== undefined && (decl.readers.has(basename) || basename === "cd");
|
|
243
243
|
const verb = program === undefined ? undefined : { name: program.name, basename: program.basename, listed };
|
|
244
|
+
const envOpens = basename !== undefined && !NO_PATH_OPERAND_COMMANDS.has(basename);
|
|
244
245
|
for (const k of pos.assignments)
|
|
245
|
-
pushAssignment(reads, toks, k, "env_assignment");
|
|
246
|
+
pushAssignment(reads, toks.folded[k], toks.raw[k], "env_assignment", envOpens);
|
|
246
247
|
for (const l of pos.launchers) {
|
|
247
248
|
const raw = toks.raw[l.index];
|
|
248
249
|
const folded = toks.folded[l.index];
|
|
@@ -286,7 +287,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
|
|
|
286
287
|
continue;
|
|
287
288
|
}
|
|
288
289
|
if (assignmentWordPayload(word, toks.raw[j]) !== undefined) {
|
|
289
|
-
pushAssignment(reads,
|
|
290
|
+
pushAssignment(reads, word, toks.raw[j], "env_assignment", envOpens);
|
|
290
291
|
continue;
|
|
291
292
|
}
|
|
292
293
|
reads.push(bare(raw, word, "unlisted_word"));
|
|
@@ -463,15 +464,17 @@ function redirectFact(r) {
|
|
|
463
464
|
return { op: r.op, direction: "in" };
|
|
464
465
|
return { op: r.op, direction: "out", target: bare(r.target.raw, r.target.folded, "write_redirect_target") };
|
|
465
466
|
}
|
|
466
|
-
function pushAssignment(reads,
|
|
467
|
-
const
|
|
468
|
-
const
|
|
469
|
-
if (payload === undefined)
|
|
467
|
+
function pushAssignment(reads, folded, raw, provenance, opensValue = true) {
|
|
468
|
+
const payload = assignmentWordPayload(folded, raw);
|
|
469
|
+
const prefix = writtenAssignmentPrefix(folded)?.prefix;
|
|
470
|
+
if (payload === undefined || prefix === undefined)
|
|
470
471
|
return;
|
|
471
|
-
|
|
472
|
-
|
|
472
|
+
const v = { spelled: folded.slice(prefix.length), raw: raw.startsWith(prefix) ? raw.slice(prefix.length) : raw, provenance };
|
|
473
|
+
const unsettled = provenance !== "env_assignment" ? { ...v, expands: "unresolvable" } : v.spelled.startsWith("~") && !v.spelled.includes(":") ? bare(v.raw, v.spelled, provenance) : undefined;
|
|
474
|
+
if (payload.kind === "unresolved" && opensValue && unsettled !== undefined)
|
|
475
|
+
reads.push(unsettled);
|
|
473
476
|
if (payload.value !== undefined && payload.value !== SUBSTITUTION_PLACEHOLDER)
|
|
474
|
-
reads.push(bare(
|
|
477
|
+
reads.push(bare(raw, payload.value, provenance));
|
|
475
478
|
}
|
|
476
479
|
function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
|
|
477
480
|
const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks);
|
|
@@ -565,9 +568,9 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
|
|
|
565
568
|
}
|
|
566
569
|
reads.push(bare(raw, word, j === argvStart ? "program_path" : provenance));
|
|
567
570
|
if (j < argvStart)
|
|
568
|
-
pushAssignment(reads,
|
|
571
|
+
pushAssignment(reads, word, raw, "env_assignment", slotVerb !== undefined && !noPathOperands);
|
|
569
572
|
else if (!declaredNotPaths)
|
|
570
|
-
pushAssignment(reads,
|
|
573
|
+
pushAssignment(reads, word, raw, provenance);
|
|
571
574
|
}
|
|
572
575
|
if (slotVerb !== undefined && argv !== undefined && segmentSelectsRecursiveRead(slotVerb, toks.folded.slice(argvStart + 1))) {
|
|
573
576
|
const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
|
|
@@ -740,11 +743,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
|
|
|
740
743
|
positional.push({ word: t, raw: rawArgs[k] });
|
|
741
744
|
push(k, t, "operand");
|
|
742
745
|
if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
|
|
743
|
-
|
|
744
|
-
if (payload?.kind === "unresolved")
|
|
745
|
-
reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
|
|
746
|
-
if (payload?.value !== undefined)
|
|
747
|
-
push(k, payload.value, "operand");
|
|
746
|
+
pushAssignment(reads, t, rawArgs[k], "operand");
|
|
748
747
|
}
|
|
749
748
|
}
|
|
750
749
|
for (let k = 0; k < args.length; k++) {
|