@sema-agent/core 7.26.0 → 7.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -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/prepare-inherited-gate.d.ts +1 -1
- package/dist/core/runner/prepare-inherited-gate.js +2 -1
- 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/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/package.json +1 -1
- package/test/export-surface.snapshot.json +51 -1
- package/wire-changes/core@7.26.1.json +605 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { thrownText } from "../thrown-value.js";
|
|
3
|
+
import { beginContract } from "./contract-harness.js";
|
|
4
|
+
export const CORRUPT_BODY_FORMS = ["not-an-object", "missing-required", "wrong-type", "torn-json"];
|
|
5
|
+
async function observe(subject) {
|
|
6
|
+
try {
|
|
7
|
+
const value = await subject.read();
|
|
8
|
+
return { threw: false, body: safeStringify(value) };
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
return { threw: true, message: thrownText(err) };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function safeStringify(value) {
|
|
15
|
+
const seen = new WeakSet();
|
|
16
|
+
try {
|
|
17
|
+
return (JSON.stringify(value, (_k, v) => {
|
|
18
|
+
if (typeof v === "object" && v !== null) {
|
|
19
|
+
if (seen.has(v))
|
|
20
|
+
return "[circular]";
|
|
21
|
+
seen.add(v);
|
|
22
|
+
}
|
|
23
|
+
return v;
|
|
24
|
+
}) ?? String(value));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function render(o) {
|
|
31
|
+
return o.threw ? `threw(${o.message})` : `answered ${o.body}`;
|
|
32
|
+
}
|
|
33
|
+
async function withSubject(hooks, fn) {
|
|
34
|
+
const subject = await hooks.open();
|
|
35
|
+
try {
|
|
36
|
+
return await fn(subject);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
subject.close?.();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function storeReadBackShapeContract(hooks) {
|
|
43
|
+
const { run, settle } = beginContract(hooks.runAssertion);
|
|
44
|
+
const where = hooks.face;
|
|
45
|
+
run(`${where}: the read face tells a present row from an absent one (positive control)`, async () => {
|
|
46
|
+
const empty = await withSubject(hooks, observe);
|
|
47
|
+
const seeded = await withSubject(hooks, async (s) => {
|
|
48
|
+
await s.seed();
|
|
49
|
+
return observe(s);
|
|
50
|
+
});
|
|
51
|
+
assert.equal(empty.threw, false, `${where}: reading an EMPTY store must answer, not throw (${render(empty)}) — absence is not damage`);
|
|
52
|
+
assert.equal(seeded.threw, false, `${where}: reading a well-formed row must answer, not throw (${render(seeded)})`);
|
|
53
|
+
const again = await withSubject(hooks, observe);
|
|
54
|
+
assert.deepEqual(again, empty, `${where}: two reads of two EMPTY stores answered differently (${render(empty)} then ${render(again)}) — this suite's oracle is "the corrupt answer differs from the empty one", which is free for a face carrying incidental data; project it away in the binding`);
|
|
55
|
+
assert.notDeepEqual(seeded, empty, `${where}: a seeded store and an empty one answered identically (${render(empty)}) — this face reports nothing a corrupt body could differ from, so every clause below would pass without meaning anything`);
|
|
56
|
+
});
|
|
57
|
+
for (const form of CORRUPT_BODY_FORMS) {
|
|
58
|
+
run(`${where}: a ${form} body is not read as an absent row`, async () => {
|
|
59
|
+
const sentinel = `sema-corrupt-sentinel-${form}`;
|
|
60
|
+
const empty = await withSubject(hooks, observe);
|
|
61
|
+
const corrupt = await withSubject(hooks, async (s) => {
|
|
62
|
+
if (s.unplantable !== undefined) {
|
|
63
|
+
assert.ok(s.unplantable.length > 0, `${where}: a subject declaring no plantable body must say WHY`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
await s.plant(form, sentinel);
|
|
67
|
+
return observe(s);
|
|
68
|
+
});
|
|
69
|
+
if (corrupt === undefined)
|
|
70
|
+
return;
|
|
71
|
+
assert.notDeepEqual(corrupt, empty, `${where}: a ${form} body was answered exactly as an absent row is (${render(empty)}) — a corrupt body must reach the caller as a refusal or as an absence that names the loss, never as the store's empty answer`);
|
|
72
|
+
if (!corrupt.threw) {
|
|
73
|
+
assert.ok(!corrupt.body.includes(sentinel), `${where}: a ${form} body was handed back through the read face (the planted marker is in the answer) — the read judged nothing`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
await settle();
|
|
78
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -142,6 +142,9 @@ export { ToolRosterEntry, ToolRoster, ToolRosterDelta, ToolRosterRenderHints, RO
|
|
|
142
142
|
export { type ToolCallFace } from "./core/tool-policy.js";
|
|
143
143
|
export { effectivePathTargetOf, pathTargetBaseOf, type ToolPathBases, type PathTargetReading } from "./core/effective-path-target.js";
|
|
144
144
|
export { pathFamilyOf, isAbsoluteForFamily, pathIdentityOf, win32NamespaceScreen, WIN32_NAMESPACE_REFUSALS, type PathFamily, type PathIdentity, type PathIdentityReading, type Win32NamespaceRefusal } from "./tools/fs/safety.js";
|
|
145
|
+
export { judgeShellRead, type ShellReadJudgeInput, type ShellReadJudgment, type ShellReadVerdict, type ShellReadStationSeat, type ShellReadAdmission, } from "./tools/fs/shell-read-check.js";
|
|
146
|
+
export { SHELL_READ_FINDINGS, SHELL_READ_SEATS, SHELL_READ_DECISION, SHELL_READ_FINDING_RANK, type ShellReadFinding, type ShellReadSeat, type ShellReadDisposition, type ShellReadBoundary, type ReadEstablishment, type EstablishedRead, type ReadFinding, } from "./tools/fs/shell-read-judge.js";
|
|
147
|
+
export { type ReadCandidate, type ShellReadPlan, type ShellReadSegment } from "./tools/fs/shell-read-plan.js";
|
|
145
148
|
export { TOOL_CONFORMANCE_VECTORS, type ToolConformanceVector } from "./core/tool-conformance.js";
|
|
146
149
|
export { applyMcpToolFaces, toolFaceProblem, APPROVAL_CARD_REQUIRED_KEYS, type McpToolFace } from "./core/runner/tool-face-overlay.js";
|
|
147
150
|
export { createStructuredProjector } from "./core/runner/tool-output-projection.js";
|
|
@@ -271,6 +274,7 @@ export { type WorkflowRunStore, type WorkflowRunSummary, summarizeWorkflowRun, i
|
|
|
271
274
|
export { FileWorkflowRunStore, type FileWorkflowRunStoreOptions } from "./stores/file/workflow-run-store.js";
|
|
272
275
|
export { workflowRunStoreContract } from "./core/workflow-run-store-contract.js";
|
|
273
276
|
export { CONTRACT_KIT_ENGINE_VERSION } from "./core/store-contracts/contract-kit-version.js";
|
|
277
|
+
export { CORRUPT_BODY_FORMS, storeReadBackShapeContract, type CorruptBodyForm, type ReadBackShapeSubject, type StoreReadBackShapeHooks, } from "./core/store-contracts/read-back-shape-contract.js";
|
|
274
278
|
export { type ContractAssertionRunner } from "./core/store-contracts/contract-harness.js";
|
|
275
279
|
export { checkpointStoreContract } from "./core/store-contracts/checkpoint-store-contract.js";
|
|
276
280
|
export { sessionRepoContract } from "./core/store-contracts/session-repo-contract.js";
|
package/dist/index.js
CHANGED
|
@@ -115,6 +115,9 @@ export { ToolRosterEntry, ToolRoster, ToolRosterDelta, ToolRosterRenderHints, RO
|
|
|
115
115
|
export {} from "./core/tool-policy.js";
|
|
116
116
|
export { effectivePathTargetOf, pathTargetBaseOf } from "./core/effective-path-target.js";
|
|
117
117
|
export { pathFamilyOf, isAbsoluteForFamily, pathIdentityOf, win32NamespaceScreen, WIN32_NAMESPACE_REFUSALS } from "./tools/fs/safety.js";
|
|
118
|
+
export { judgeShellRead, } from "./tools/fs/shell-read-check.js";
|
|
119
|
+
export { SHELL_READ_FINDINGS, SHELL_READ_SEATS, SHELL_READ_DECISION, SHELL_READ_FINDING_RANK, } from "./tools/fs/shell-read-judge.js";
|
|
120
|
+
export {} from "./tools/fs/shell-read-plan.js";
|
|
118
121
|
export { TOOL_CONFORMANCE_VECTORS } from "./core/tool-conformance.js";
|
|
119
122
|
export { applyMcpToolFaces, toolFaceProblem, APPROVAL_CARD_REQUIRED_KEYS } from "./core/runner/tool-face-overlay.js";
|
|
120
123
|
export { createStructuredProjector } from "./core/runner/tool-output-projection.js";
|
|
@@ -225,6 +228,7 @@ export { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError,
|
|
|
225
228
|
export { FileWorkflowRunStore } from "./stores/file/workflow-run-store.js";
|
|
226
229
|
export { workflowRunStoreContract } from "./core/workflow-run-store-contract.js";
|
|
227
230
|
export { CONTRACT_KIT_ENGINE_VERSION } from "./core/store-contracts/contract-kit-version.js";
|
|
231
|
+
export { CORRUPT_BODY_FORMS, storeReadBackShapeContract, } from "./core/store-contracts/read-back-shape-contract.js";
|
|
228
232
|
export {} from "./core/store-contracts/contract-harness.js";
|
|
229
233
|
export { checkpointStoreContract } from "./core/store-contracts/checkpoint-store-contract.js";
|
|
230
234
|
export { sessionRepoContract } from "./core/store-contracts/session-repo-contract.js";
|
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { adoptFilePermissionRuleStore } from "../permission-rule-adopt.js";
|
|
5
5
|
import { BootLock, atomicWriteFile, ensureDir, sanitizeScope } from "../fs-atomic.js";
|
|
6
|
-
import { SESSION_POLICY_MARK_SUFFIX, readSessionPolicyMark } from "../session-policy-store.js";
|
|
6
|
+
import { SESSION_POLICY_MARK_SUFFIX, judgeSessionPolicyRowObject, readSessionPolicyMark } from "../session-policy-store.js";
|
|
7
7
|
import { thrownText } from "../../../core/thrown-value.js";
|
|
8
8
|
import { AdoptionError, NOT_MIGRATED_BY_DESIGN, readRootAdoptionFile, writeRootAdoptionFile, } from "./marker.js";
|
|
9
9
|
const CONFIG_ACCOUNT_FILE = "adoption-configs.json";
|
|
@@ -209,16 +209,10 @@ function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
|
|
|
209
209
|
continue;
|
|
210
210
|
throw err;
|
|
211
211
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
parsed = JSON.parse(raw);
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
212
|
+
const judged = judgeSessionPolicyRowObject(raw);
|
|
213
|
+
if ("corrupt" in judged)
|
|
220
214
|
continue;
|
|
221
|
-
const row =
|
|
215
|
+
const row = judged.row;
|
|
222
216
|
if (row.__principal !== fromPrincipal)
|
|
223
217
|
continue;
|
|
224
218
|
if (typeof row.__sid !== "string")
|
|
@@ -139,7 +139,11 @@ declare class FileDurableRulePartition implements WritableDurableRulePartition {
|
|
|
139
139
|
private screenDurableRows;
|
|
140
140
|
private write;
|
|
141
141
|
list(): Promise<StoredRules>;
|
|
142
|
-
/** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`.
|
|
142
|
+
/** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. Its posture
|
|
143
|
+
* on an unreadable bucket is `list()`'s, for the reason `list()` has it: "the quarantine is empty" and
|
|
144
|
+
* "the bucket cannot be read" are different facts about rows a person is meant to go and look at, and a
|
|
145
|
+
* face that answers the first about the second is the one shape this store never renders. An ABSENT
|
|
146
|
+
* bucket is still an empty quarantine. */
|
|
143
147
|
quarantined(): Promise<QuarantinedRuleAdd[]>;
|
|
144
148
|
/**
|
|
145
149
|
* The current file, for a WRITE.
|
|
@@ -220,6 +220,8 @@ class FileDurableRulePartition {
|
|
|
220
220
|
}
|
|
221
221
|
async quarantined() {
|
|
222
222
|
const r = this.read();
|
|
223
|
+
if ("unreadable" in r)
|
|
224
|
+
throw new Error(r.unreadable);
|
|
223
225
|
return "file" in r ? this.screenDurableRows(r.file).quarantined : [];
|
|
224
226
|
}
|
|
225
227
|
current() {
|
|
@@ -307,7 +309,7 @@ class FileDurableRulePartition {
|
|
|
307
309
|
...(joined.quarantined.length > 0 ? { quarantined: joined.quarantined } : {}),
|
|
308
310
|
};
|
|
309
311
|
if (!this.writeAndVerify(landed)) {
|
|
310
|
-
throw new Error("the permission-rule store did not
|
|
312
|
+
throw new Error("the permission-rule store did not read back as the version just written; whether the change reached the bytes on disk is unobservable from here — reconcile by reading the store");
|
|
311
313
|
}
|
|
312
314
|
return { rev: cur.rev + 1, sync: report };
|
|
313
315
|
}
|
|
@@ -334,7 +336,7 @@ class FileDurableRulePartition {
|
|
|
334
336
|
}
|
|
335
337
|
}
|
|
336
338
|
if (!this.writeAndVerify(next)) {
|
|
337
|
-
throw new Error("the permission-rule store did not
|
|
339
|
+
throw new Error("the permission-rule store did not read back as the version just written; whether the change reached the bytes on disk is unobservable from here — reconcile by reading the store");
|
|
338
340
|
}
|
|
339
341
|
return { rev: cur.rev + 1 };
|
|
340
342
|
}),
|
|
@@ -452,7 +454,7 @@ class FileOrgRuleStatePersistence {
|
|
|
452
454
|
atomicPublish(this.dir, this.file, JSON.stringify({ ...body, checksum: checksumOf(body) }, null, 2));
|
|
453
455
|
const after = this.readSync();
|
|
454
456
|
if (after === undefined || canonicalize(after) !== canonicalize(state)) {
|
|
455
|
-
const why = "the org state file did not
|
|
457
|
+
const why = "the org state file did not read back as the version just written; whether the snapshot reached the bytes on disk is unobservable from here — reconcile by reading the file";
|
|
456
458
|
this.onError?.(why);
|
|
457
459
|
throw new Error(why);
|
|
458
460
|
}
|
|
@@ -31,6 +31,8 @@ export interface StoredHighWaterMark {
|
|
|
31
31
|
__gen?: number;
|
|
32
32
|
__sid?: string;
|
|
33
33
|
__principal?: string | null;
|
|
34
|
+
/** The erase's tombstone (a torn row purged with no mark): writes are refused until an operator deletes this file. */
|
|
35
|
+
__lineage?: "lost";
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* The ONE reader of a mark file — the number it holds, or `null` when there is no file (the ordinary state of
|
|
@@ -41,6 +43,34 @@ export interface StoredHighWaterMark {
|
|
|
41
43
|
* these files too, and a second, more tolerant reader over there is how the guarantee goes quietly missing.
|
|
42
44
|
*/
|
|
43
45
|
export declare function readSessionPolicyMark(path: string): StoredHighWaterMark | null;
|
|
46
|
+
/** Why one row file's bytes are not a row. `unparseable` is the ONE grade saying they are not JSON at all — the
|
|
47
|
+
* only evidence the erase sweep's crash-artifact ruling rests on. `reason` is the disclosure text, so two faces
|
|
48
|
+
* judging the SAME file name the same fault in the same words. */
|
|
49
|
+
export interface SessionPolicyRowFault {
|
|
50
|
+
grade: "unparseable" | "malformed";
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
53
|
+
/** One row file's bytes → the JSON object every face of this directory requires before reading a field off it.
|
|
54
|
+
* Split from the rules-shape judgment below because the erase sweep and the ownership rebind treat a row as
|
|
55
|
+
* bytes-with-attribution: a malformed-rules row is still erasable and still travels with its key. */
|
|
56
|
+
export declare function judgeSessionPolicyRowObject(raw: string): {
|
|
57
|
+
row: Record<string, unknown>;
|
|
58
|
+
} | {
|
|
59
|
+
corrupt: SessionPolicyRowFault;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The SHAPE of a stored row, judged ONCE for every face that serves its rules to a reader — the per-key read
|
|
63
|
+
* and the session-wide enumeration alike, so one store cannot answer two things about one file.
|
|
64
|
+
*
|
|
65
|
+
* Required: `rev` >= 1 (a rev-0 row would read as the live reader's SYNTHETIC empty seed); `gen`, when present, a
|
|
66
|
+
* whole number; every rule field a string array (a STRING `rev` poisons the CAS arithmetic). The `__sid`/`__principal`
|
|
67
|
+
* metadata is STRIPPED so the value is byte-identical to the in-memory twin's.
|
|
68
|
+
*/
|
|
69
|
+
export declare function judgeSessionPolicyRowShape(row: Record<string, unknown>): {
|
|
70
|
+
rules: StoredSessionRules;
|
|
71
|
+
} | {
|
|
72
|
+
corrupt: SessionPolicyRowFault;
|
|
73
|
+
};
|
|
44
74
|
export declare class FileSessionPolicyStore implements SessionPolicyStore {
|
|
45
75
|
private readonly dir;
|
|
46
76
|
private readonly onCorruptRead;
|
|
@@ -71,6 +101,8 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
|
|
|
71
101
|
private basenameFor;
|
|
72
102
|
private markPathFor;
|
|
73
103
|
private writeMarkFile;
|
|
104
|
+
/** The mark as given — the erase's lineage transition rewrites a mark keeping whatever attribution it had. */
|
|
105
|
+
private writeMark;
|
|
74
106
|
private read;
|
|
75
107
|
getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
|
|
76
108
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
@@ -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) {
|