@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.
- package/CHANGELOG.md +100 -0
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/arg-summary.d.ts +0 -7
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +14 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +26 -0
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +3 -4
- package/dist/core/runner/session-rule-policy.js +11 -3
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-policy.js +30 -23
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-lexer.d.ts +13 -0
- package/dist/tools/fs/bash-lexer.js +61 -0
- package/dist/tools/fs/bash-program-position.d.ts +36 -29
- package/dist/tools/fs/bash-program-position.js +33 -13
- package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
- package/dist/tools/fs/bash-readonly-classifier.js +81 -782
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +88 -27
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.25.0.json +671 -0
- package/wire-changes/core@7.25.1.json +64 -0
|
@@ -3,6 +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
7
|
import { thrownText } from "../../../core/thrown-value.js";
|
|
7
8
|
import { AdoptionError, NOT_MIGRATED_BY_DESIGN, readRootAdoptionFile, writeRootAdoptionFile, } from "./marker.js";
|
|
8
9
|
const CONFIG_ACCOUNT_FILE = "adoption-configs.json";
|
|
@@ -194,6 +195,10 @@ function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
|
|
|
194
195
|
for (const file of files) {
|
|
195
196
|
if (!file.endsWith(".json"))
|
|
196
197
|
continue;
|
|
198
|
+
if (file.endsWith(SESSION_POLICY_MARK_SUFFIX)) {
|
|
199
|
+
carrySessionPolicyMark(dir, tmpDir, file, fromPrincipal, toPrincipal);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
197
202
|
const source = join(dir, file);
|
|
198
203
|
let raw;
|
|
199
204
|
try {
|
|
@@ -218,8 +223,9 @@ function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
|
|
|
218
223
|
continue;
|
|
219
224
|
if (typeof row.__sid !== "string")
|
|
220
225
|
continue;
|
|
226
|
+
const stem = sanitizeScope(JSON.stringify([row.__sid, toPrincipal]));
|
|
221
227
|
const rewritten = JSON.stringify({ ...row, __principal: toPrincipal });
|
|
222
|
-
const target = join(dir, `${
|
|
228
|
+
const target = join(dir, `${stem}.json`);
|
|
223
229
|
if (existsSync(target)) {
|
|
224
230
|
if (readFileSync(target, "utf8") === rewritten) {
|
|
225
231
|
removeIfPresent(source);
|
|
@@ -235,6 +241,22 @@ function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
|
|
|
235
241
|
}
|
|
236
242
|
return rows;
|
|
237
243
|
}
|
|
244
|
+
function carrySessionPolicyMark(dir, tmpDir, file, fromPrincipal, toPrincipal) {
|
|
245
|
+
const source = join(dir, file);
|
|
246
|
+
const mark = readSessionPolicyMark(source);
|
|
247
|
+
if (mark === null)
|
|
248
|
+
return;
|
|
249
|
+
if (mark.__principal !== fromPrincipal)
|
|
250
|
+
return;
|
|
251
|
+
if (typeof mark.__sid !== "string")
|
|
252
|
+
return;
|
|
253
|
+
const target = join(dir, `${sanitizeScope(JSON.stringify([mark.__sid, toPrincipal]))}${SESSION_POLICY_MARK_SUFFIX}`);
|
|
254
|
+
const there = readSessionPolicyMark(target);
|
|
255
|
+
const merged = there === null ? mark.__hwm : Math.max(mark.__hwm, there.__hwm);
|
|
256
|
+
const mergedGen = Math.max(mark.__gen ?? 0, there?.__gen ?? 0);
|
|
257
|
+
atomicWriteFile(tmpDir, target, JSON.stringify({ ...mark, __principal: toPrincipal, __hwm: merged, __gen: mergedGen }));
|
|
258
|
+
removeIfPresent(source);
|
|
259
|
+
}
|
|
238
260
|
function removeIfPresent(path) {
|
|
239
261
|
try {
|
|
240
262
|
unlinkSync(path);
|
|
@@ -311,7 +311,9 @@ class FileDurableRulePartition {
|
|
|
311
311
|
}
|
|
312
312
|
const next = delta.kind === "redemption-add"
|
|
313
313
|
? (assertWriteDeltaScopeDurable(delta), assertRedemptionNotQuarantined(cur.quarantined ?? [], delta), { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) })
|
|
314
|
-
:
|
|
314
|
+
: delta.kind === "tighten-add"
|
|
315
|
+
? (assertWriteDeltaScopeDurable(delta), { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) })
|
|
316
|
+
: (assertWriteDeltaScopeDurable(delta), assertDeleteDeltaCarriesNoAdd(delta), { ...cur, rev: cur.rev + 1, tombstones: [...cur.tombstones, delta.tombstone] });
|
|
315
317
|
if (!this.writeAndVerify(next)) {
|
|
316
318
|
throw new Error("the permission-rule store did not survive its own write; the change was not committed");
|
|
317
319
|
}
|
|
@@ -15,6 +15,32 @@ export interface FileSessionPolicyStoreOptions {
|
|
|
15
15
|
* Never fires on plain ENOENT. Swallow-guarded: a throwing sink cannot re-introduce the fault. */
|
|
16
16
|
onCorruptRead?: (info: SessionPolicyCorruptReadInfo) => void;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The suffix that tells a MARK file apart from a rules ROW in the shared directory — injective by
|
|
20
|
+
* construction, not by convention: `sanitizeScope` ends every row's basename with a 64-hex digest, so the
|
|
21
|
+
* character before a row's `.json` is always a hex digit and no row can be spelled `*.hwm.json`. Every sweep
|
|
22
|
+
* of this directory tests it FIRST: a mark must never be listed as a row, nor judged as crash litter.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SESSION_POLICY_MARK_SUFFIX = ".hwm.json";
|
|
25
|
+
/** What one mark file carries: the number, plus the row's attribution fields (the filename is a one-way hash,
|
|
26
|
+
* so those are the only way a sweep can tell whose mark it is). Attribution is OPTIONAL on the read side —
|
|
27
|
+
* this store always writes it, a hand-written file need not, and a mark with no owner is simply nobody's. */
|
|
28
|
+
export interface StoredHighWaterMark {
|
|
29
|
+
__hwm: number;
|
|
30
|
+
/** The lineage the key's NEXT row starts (bumped by every erase); absent = 0 (a mark written before lineages). */
|
|
31
|
+
__gen?: number;
|
|
32
|
+
__sid?: string;
|
|
33
|
+
__principal?: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The ONE reader of a mark file — the number it holds, or `null` when there is no file (the ordinary state of
|
|
37
|
+
* a key nobody erased). Every other outcome is a refusal, and that is the point: for a ROW "unreadable"
|
|
38
|
+
* degrades to "absent" and costs nothing, while for a mark absence reads as ZERO, which is precisely the
|
|
39
|
+
* rollback the erase contract forbids. Bytes that are not a mark raise `SessionPolicyError("corrupt")` naming
|
|
40
|
+
* the file; an IO failure propagates as it already does for a row. Exported because ownership adoption moves
|
|
41
|
+
* these files too, and a second, more tolerant reader over there is how the guarantee goes quietly missing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readSessionPolicyMark(path: string): StoredHighWaterMark | null;
|
|
18
44
|
export declare class FileSessionPolicyStore implements SessionPolicyStore {
|
|
19
45
|
private readonly dir;
|
|
20
46
|
private readonly onCorruptRead;
|
|
@@ -40,6 +66,11 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
|
|
|
40
66
|
* composite key, so distinct keys never collide on disk). On a local-owner-adopted root the
|
|
41
67
|
* anonymous lane resolves to the adopted principal's key (#132 — see {@link anonymousAlias}). */
|
|
42
68
|
private pathFor;
|
|
69
|
+
/** The shared, injective stem of a key's two files — the row (`<stem>.json`) and its mark
|
|
70
|
+
* (`<stem>${MARK_SUFFIX}`), so the pair can never drift onto different keys. */
|
|
71
|
+
private basenameFor;
|
|
72
|
+
private markPathFor;
|
|
73
|
+
private writeMarkFile;
|
|
43
74
|
private read;
|
|
44
75
|
getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
|
|
45
76
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
|
|
4
4
|
import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
|
|
@@ -9,6 +9,37 @@ function containSinkThenable(r) {
|
|
|
9
9
|
});
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
export const SESSION_POLICY_MARK_SUFFIX = ".hwm.json";
|
|
13
|
+
const MARK_SUFFIX = SESSION_POLICY_MARK_SUFFIX;
|
|
14
|
+
export function readSessionPolicyMark(path) {
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = readFileSync(path, "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === "ENOENT")
|
|
21
|
+
return null;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
const refuse = (fault) => {
|
|
25
|
+
throw new SessionPolicyError("corrupt", `session rules high-water mark ${fault}: ${path} — refusing rather than restart this key's rev from zero. Deleting that file is the repair, and it gives up the number it was holding.`);
|
|
26
|
+
};
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return refuse("is unparseable");
|
|
33
|
+
}
|
|
34
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
35
|
+
return refuse("is not a JSON object");
|
|
36
|
+
const mark = parsed;
|
|
37
|
+
if (typeof mark.__hwm !== "number" || !Number.isFinite(mark.__hwm))
|
|
38
|
+
return refuse("carries no numeric value");
|
|
39
|
+
if (mark.__gen !== undefined && (typeof mark.__gen !== "number" || !Number.isInteger(mark.__gen) || mark.__gen < 0))
|
|
40
|
+
return refuse("carries a lineage that is not a whole number");
|
|
41
|
+
return mark;
|
|
42
|
+
}
|
|
12
43
|
export class FileSessionPolicyStore {
|
|
13
44
|
dir;
|
|
14
45
|
onCorruptRead;
|
|
@@ -33,9 +64,19 @@ export class FileSessionPolicyStore {
|
|
|
33
64
|
this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
|
|
34
65
|
}
|
|
35
66
|
pathFor(sessionId, principal) {
|
|
67
|
+
return join(this.dir, `${this.basenameFor(sessionId, principal)}.json`);
|
|
68
|
+
}
|
|
69
|
+
basenameFor(sessionId, principal) {
|
|
36
70
|
const effective = principal ?? this.anonymousAlias;
|
|
37
71
|
const composite = JSON.stringify([sessionId, effective ?? null]);
|
|
38
|
-
return
|
|
72
|
+
return sanitizeScope(composite);
|
|
73
|
+
}
|
|
74
|
+
markPathFor(sessionId, principal) {
|
|
75
|
+
return join(this.dir, `${this.basenameFor(sessionId, principal)}${MARK_SUFFIX}`);
|
|
76
|
+
}
|
|
77
|
+
writeMarkFile(path, rev, sessionId, principal, gen) {
|
|
78
|
+
const mark = { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal };
|
|
79
|
+
atomicWriteFile(join(this.dir, "tmp"), path, JSON.stringify(mark));
|
|
39
80
|
}
|
|
40
81
|
read(sessionId, principal) {
|
|
41
82
|
let raw;
|
|
@@ -54,8 +95,12 @@ export class FileSessionPolicyStore {
|
|
|
54
95
|
return null;
|
|
55
96
|
}
|
|
56
97
|
const r = parsed;
|
|
57
|
-
if (
|
|
58
|
-
this.discloseCorrupt(sessionId, principal, "
|
|
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");
|
|
59
104
|
return null;
|
|
60
105
|
}
|
|
61
106
|
const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
|
|
@@ -88,8 +133,13 @@ export class FileSessionPolicyStore {
|
|
|
88
133
|
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
89
134
|
}
|
|
90
135
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
136
|
+
const mark = readSessionPolicyMark(this.markPathFor(sessionId, principal));
|
|
137
|
+
const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
|
|
138
|
+
const gen = prior?.gen ?? (existsSync(this.pathFor(sessionId, principal)) ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0));
|
|
139
|
+
const next = { ...clean, rev: floor + 1, gen };
|
|
140
|
+
const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
|
|
141
|
+
this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
|
|
142
|
+
atomicWriteFile(join(this.dir, "tmp"), this.pathFor(sessionId, principal), JSON.stringify({ ...next, __sid: sessionId, __principal: attributedPrincipal }));
|
|
93
143
|
return next;
|
|
94
144
|
}
|
|
95
145
|
async listBySession(sessionId) {
|
|
@@ -104,6 +154,8 @@ export class FileSessionPolicyStore {
|
|
|
104
154
|
}
|
|
105
155
|
const out = [];
|
|
106
156
|
for (const file of files) {
|
|
157
|
+
if (file.endsWith(MARK_SUFFIX))
|
|
158
|
+
continue;
|
|
107
159
|
if (!file.endsWith(".json"))
|
|
108
160
|
continue;
|
|
109
161
|
const full = join(this.dir, file);
|
|
@@ -149,6 +201,8 @@ export class FileSessionPolicyStore {
|
|
|
149
201
|
throw err;
|
|
150
202
|
}
|
|
151
203
|
for (const file of files) {
|
|
204
|
+
if (file.endsWith(MARK_SUFFIX))
|
|
205
|
+
continue;
|
|
152
206
|
if (!file.endsWith(".json"))
|
|
153
207
|
continue;
|
|
154
208
|
let raw;
|
|
@@ -163,6 +217,17 @@ export class FileSessionPolicyStore {
|
|
|
163
217
|
parsed = JSON.parse(raw);
|
|
164
218
|
}
|
|
165
219
|
catch {
|
|
220
|
+
const tornMarkPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
|
|
221
|
+
try {
|
|
222
|
+
const tornMark = readSessionPolicyMark(tornMarkPath);
|
|
223
|
+
if (tornMark !== null && tornMark.__sid === sessionId) {
|
|
224
|
+
this.writeMarkFile(tornMarkPath, tornMark.__hwm, sessionId, tornMark.__principal ?? null, (tornMark.__gen ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (!(err instanceof SessionPolicyError))
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
166
231
|
try {
|
|
167
232
|
unlinkSync(join(this.dir, file));
|
|
168
233
|
}
|
|
@@ -174,8 +239,23 @@ export class FileSessionPolicyStore {
|
|
|
174
239
|
}
|
|
175
240
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
176
241
|
continue;
|
|
177
|
-
|
|
242
|
+
const row = parsed;
|
|
243
|
+
if (row.__sid !== sessionId)
|
|
178
244
|
continue;
|
|
245
|
+
const markPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
|
|
246
|
+
let mark;
|
|
247
|
+
try {
|
|
248
|
+
mark = readSessionPolicyMark(markPath);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (!(err instanceof SessionPolicyError))
|
|
252
|
+
throw err;
|
|
253
|
+
mark = undefined;
|
|
254
|
+
}
|
|
255
|
+
if (mark !== undefined) {
|
|
256
|
+
const rowRev = typeof row.rev === "number" && Number.isFinite(row.rev) ? row.rev : 0;
|
|
257
|
+
this.writeMarkFile(markPath, Math.max(mark?.__hwm ?? 0, rowRev), sessionId, row.__principal ?? null, (mark?.__gen ?? 0) + 1);
|
|
258
|
+
}
|
|
179
259
|
try {
|
|
180
260
|
unlinkSync(join(this.dir, file));
|
|
181
261
|
}
|
|
@@ -93,6 +93,19 @@ export interface SegmentTokens {
|
|
|
93
93
|
* judges words sees "this word carries text the shell computes" rather than the computing text itself. */
|
|
94
94
|
export declare const SUBSTITUTION_PLACEHOLDER = " subst";
|
|
95
95
|
export type QuoteOpen = '"' | "'" | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Does the `$` at `at` OPEN a quote form (`$'…'` / `$"…"`)? ONE question, asked by every walker that has to
|
|
98
|
+
* answer it — the word scan here and the expansion scan in the classifier — because a `$` one of them reads
|
|
99
|
+
* as escaped and the other as bare yields two different words for the same text, and the judge then compares
|
|
100
|
+
* the wrong one. Escaped means an ODD run of backslashes in front of it: `\$'y'` is a literal `$`, while
|
|
101
|
+
* `x\\$'y'` is a literal backslash followed by a real ANSI-C quote (both read off /bin/bash). Counting the
|
|
102
|
+
* run is what a one-character peek behind cannot do.
|
|
103
|
+
*/
|
|
104
|
+
export declare function dollarOpensQuote(text: string, at: number): boolean;
|
|
105
|
+
/** The `'` that CLOSES the ANSI-C quote opened at `open`, or `undefined` when none does. `\'` does not close
|
|
106
|
+
* one — the single place a quote region honours a backslash, and a pairing walk blind to it reads the real
|
|
107
|
+
* closing quote as an OPENING one and everything after as quoted, which hides an expansion the shell performs. */
|
|
108
|
+
export declare function ansiCQuoteClose(source: string, open: number): number | undefined;
|
|
96
109
|
/**
|
|
97
110
|
* Which QUOTE holds each character of `s`, by the same pairing rule the lexer walks with, and the boolean view
|
|
98
111
|
* of it. The pairing quote characters themselves belong to the region they bound — they are not operators
|
|
@@ -4,11 +4,44 @@ function quoteStep(open, ch) {
|
|
|
4
4
|
return ch === '"' || ch === "'" ? ch : undefined;
|
|
5
5
|
return open === ch ? undefined : open;
|
|
6
6
|
}
|
|
7
|
+
export function dollarOpensQuote(text, at) {
|
|
8
|
+
const next = text[at + 1];
|
|
9
|
+
if (next !== "'" && next !== '"')
|
|
10
|
+
return false;
|
|
11
|
+
let run = 0;
|
|
12
|
+
for (let k = at - 1; k >= 0 && text[k] === "\\"; k--)
|
|
13
|
+
run += 1;
|
|
14
|
+
return run % 2 === 0;
|
|
15
|
+
}
|
|
16
|
+
export function ansiCQuoteClose(source, open) {
|
|
17
|
+
for (let i = open + 1; i < source.length; i++) {
|
|
18
|
+
if (source[i] === "\\") {
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (source[i] === "'")
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
7
27
|
export function quoteMask(s) {
|
|
8
28
|
const openedBy = new Array(s.length).fill(undefined);
|
|
9
29
|
const quoted = new Array(s.length).fill(false);
|
|
10
30
|
let open;
|
|
11
31
|
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
if (open === undefined && s[i] === "$" && s[i + 1] === "'") {
|
|
33
|
+
const close = ansiCQuoteClose(s, i + 1);
|
|
34
|
+
for (let k = i; k <= (close ?? s.length - 1); k++) {
|
|
35
|
+
openedBy[k] = "'";
|
|
36
|
+
quoted[k] = true;
|
|
37
|
+
}
|
|
38
|
+
if (close === undefined) {
|
|
39
|
+
open = "'";
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
i = close;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
12
45
|
const before = open;
|
|
13
46
|
open = quoteStep(open, s[i]);
|
|
14
47
|
const holds = before ?? open;
|
|
@@ -32,6 +65,13 @@ function tildeIsLiteral(raw) {
|
|
|
32
65
|
}
|
|
33
66
|
return false;
|
|
34
67
|
}
|
|
68
|
+
function ansiCBody(source, open) {
|
|
69
|
+
const close = ansiCQuoteClose(source, open);
|
|
70
|
+
if (close === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
const body = source.slice(open + 1, close);
|
|
73
|
+
return body.includes("\\") ? { unreadable: true, close } : { text: body, close };
|
|
74
|
+
}
|
|
35
75
|
export function wordsOf(segment) {
|
|
36
76
|
const folded = [];
|
|
37
77
|
const raw = [];
|
|
@@ -310,6 +350,27 @@ function scan(source, opts, quotesLiteral) {
|
|
|
310
350
|
i = j;
|
|
311
351
|
}
|
|
312
352
|
const ch = source[i];
|
|
353
|
+
if (!quotesLiteral && ch === "$" && !inSingle && !inDouble && dollarOpensQuote(source, i)) {
|
|
354
|
+
const q = next(i);
|
|
355
|
+
if (source[q] === '"') {
|
|
356
|
+
raw += ch;
|
|
357
|
+
started = true;
|
|
358
|
+
i = q - 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (source[q] === "'") {
|
|
362
|
+
const ansi = ansiCBody(source, q);
|
|
363
|
+
if (ansi === undefined)
|
|
364
|
+
return { reject: "an ANSI-C quoted word never closes" };
|
|
365
|
+
if ("unreadable" in ansi)
|
|
366
|
+
return { reject: "an ANSI-C quoted word carries an escape this lexer does not fold" };
|
|
367
|
+
raw += source.slice(i, ansi.close + 1);
|
|
368
|
+
folded += ansi.text;
|
|
369
|
+
started = true;
|
|
370
|
+
i = ansi.close;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
313
374
|
if (!quotesLiteral) {
|
|
314
375
|
if (ch === "'" && !inDouble) {
|
|
315
376
|
inSingle = !inSingle;
|
|
@@ -9,15 +9,32 @@
|
|
|
9
9
|
* does (an option skipped, a chain member missed) is how one face used to vouch for a program another refused.
|
|
10
10
|
*/
|
|
11
11
|
import type { SegmentTokens } from "./bash-lexer.js";
|
|
12
|
-
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
|
|
13
12
|
/** The verdict every unresolvable / out-of-root / unbounded finding ends on. The findings differ in what
|
|
14
13
|
* they found; the consequence is one consequence, and a copy that drifts reads as a second rule. */
|
|
15
14
|
export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
|
|
16
15
|
/** argv[0] as the program bash will resolve it: `/usr/bin/cat` and `cat` are one name. */
|
|
17
16
|
export declare function programBasename(word: string): string;
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The `NAME=` / `NAME+=` prefix bash reads as an ASSIGNMENT — the ONE spelling of that grammar, matched where
|
|
19
|
+
* it is WRITTEN. Both operators, because bash reads both; the match IS the prefix, so a caller holding raw
|
|
20
|
+
* text can ask whether its token carries one bare. Every walk that strips leading assignments reads this:
|
|
21
|
+
* a second copy of the grammar is how one walk came to strip `A=x` and skip `A+=x`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ASSIGNMENT_PREFIX: RegExp;
|
|
24
|
+
/**
|
|
25
|
+
* The prefix this raw token BEGINS with, or `undefined`.
|
|
26
|
+
*
|
|
27
|
+
* `name` is the VARIABLE the word assigns to, which is the part before any subscript: `A[0]=x` writes one
|
|
28
|
+
* element of `A`, so a reader asking "is `$A` settled" must not be told that `A[0]` is. `settlesWholeName` is
|
|
29
|
+
* false for exactly the two spellings that do not settle it — an append (the earlier content is not in the
|
|
30
|
+
* text) and a subscript (the other elements are not) — so a consumer of that question asks it once.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writtenAssignmentPrefix(token: string): {
|
|
33
|
+
readonly prefix: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly appends: boolean;
|
|
36
|
+
readonly settlesWholeName: boolean;
|
|
37
|
+
} | undefined;
|
|
21
38
|
/** Shell control words that stand BEFORE a command: transparent for the boundary walk (the command behind them is
|
|
22
39
|
* judged). */
|
|
23
40
|
export declare const SHELL_CONTROL_PREFIXES: ReadonlySet<string>;
|
|
@@ -26,6 +43,17 @@ export declare const SHELL_BLOCK_WORDS: ReadonlySet<string>;
|
|
|
26
43
|
/** A control word is a keyword only when bash sees it BARE: `'if'` / `"do"` / `\\!` are command names (a program
|
|
27
44
|
* looked up on PATH, whose failure changes nothing), so a quoted or escaped spelling never counts as one. */
|
|
28
45
|
export declare function isBareControlWord(toks: SegmentTokens, i: number, words: ReadonlySet<string>): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* A word bash strips as an ASSIGNMENT before it chooses the program.
|
|
48
|
+
*
|
|
49
|
+
* WHAT HAS TO BE BARE IS THE PREFIX, NOT THE WORD, and it is ONE question for BOTH operators. Bash decides this
|
|
50
|
+
* before it removes any quoting, and only the NAME and the OPERATOR take part in it: quoting any of `NAME=` /
|
|
51
|
+
* `NAME+=` makes the whole word a program name looked up on PATH (`'A=x'`, `'A+=x'`, `A\+=x`), while quoting the
|
|
52
|
+
* VALUE does not (`A+=".env"` assigns `.env` exactly as `A+=.env` does — verified against the shell). A
|
|
53
|
+
* whole-word test conflates the two and reads the quoted VALUE as a program name; a FOLDED-word test conflates
|
|
54
|
+
* them the other way and reads the quoted PROGRAM NAME as an assignment, stepping over the real program.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isLeadingAssignmentWord(toks: SegmentTokens, i: number): boolean;
|
|
29
57
|
/**
|
|
30
58
|
* Programs that turn their STANDARD INPUT into the OPERANDS of another program, and EXEC that program directly.
|
|
31
59
|
*
|
|
@@ -169,35 +197,14 @@ export declare function readProgramPosition(toks: SegmentTokens): ProgramPositio
|
|
|
169
197
|
* consumer; WHICH targets are accepted is the caller's declaration, not this reader's. */
|
|
170
198
|
export declare function argvReentryUnjudged(pos: ProgramPosition, acceptedTargets: ReadonlySet<string>): boolean;
|
|
171
199
|
/**
|
|
172
|
-
* The two ways this reader DECLINES rather than judges — a CLOSED set, because
|
|
173
|
-
*
|
|
174
|
-
*
|
|
200
|
+
* The two ways this reader DECLINES rather than judges — a CLOSED set (`docs/CLOSED-SETS.md`), because what a
|
|
201
|
+
* seat does with a declined segment is a ROW of the shell read decision table (`shell-read-judge.ts`, the
|
|
202
|
+
* `declined` row: the one row the seats disagree on), not a boolean buried in a caller.
|
|
203
|
+
* `unreadable_program_position`: the walk cannot name the program bash will run (`env -- ls`).
|
|
175
204
|
* `stream_fed_operands`: the operands arrive on a STREAM it never saw (`… | xargs cat`). Neither names a WORD.
|
|
176
205
|
*/
|
|
177
206
|
export declare const SHELL_SCAN_DECLINES: readonly ["unreadable_program_position", "stream_fed_operands"];
|
|
178
207
|
export type ShellScanDecline = (typeof SHELL_SCAN_DECLINES)[number];
|
|
179
|
-
/** The two SEAT TABLES over that set — ROW PER MEMBER behind a compile-time fence, registered in
|
|
180
|
-
* `docs/CLOSED-SETS.md` so `gate:closed-sets` is their runtime twin. The ARGUMENT for the rows is at the
|
|
181
|
-
* seats that read them (`fs-bash.ts`), which DERIVE the `Set` shape their readers take. Rows and not two
|
|
182
|
-
* sets because a derived set beside an empty literal made a third decline SILENTLY asymmetric, in the
|
|
183
|
-
* widening direction; a member with no row now reds `tsc` at both fences with the word spelled out. */
|
|
184
|
-
export declare const BOUNDARY_SEAT_READS_DECLINE: {
|
|
185
|
-
readonly unreadable_program_position: true;
|
|
186
|
-
readonly stream_fed_operands: true;
|
|
187
|
-
};
|
|
188
|
-
/** The fence over the read-boundary seat's table: `never` while every decline has a row. */
|
|
189
|
-
export type BoundarySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof BOUNDARY_SEAT_READS_DECLINE>>;
|
|
190
|
-
/** The classify doctrine's table — every row `false` BY THE ARGUMENT (stated at the seat), not by omission. */
|
|
191
|
-
export declare const CLASSIFY_SEAT_READS_DECLINE: {
|
|
192
|
-
readonly unreadable_program_position: false;
|
|
193
|
-
readonly stream_fed_operands: false;
|
|
194
|
-
};
|
|
195
|
-
/** The fence over the classify seat's table. */
|
|
196
|
-
export type ClassifySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof CLASSIFY_SEAT_READS_DECLINE>>;
|
|
197
|
-
/** A seat's table in the shape its readers take — the members it answers YES for. DERIVED, so the ROWS are
|
|
198
|
-
* the single source and a set can never say something its table does not. The parameter is TOTAL, not
|
|
199
|
-
* `Partial`: that stops a third seat being written `seatDeclineEvidence({})`, one level up from the fences. */
|
|
200
|
-
export declare function seatDeclineEvidence(table: Record<ShellScanDecline, boolean>): ReadonlySet<ShellScanDecline>;
|
|
201
208
|
/** Does a SHELL stand where this segment's program would be — as the program itself (`sh -c`), or as a launcher
|
|
202
209
|
* ROW handing its operands to one (`watch "…"`)? ONE predicate for the re-entry class, so the two spellings
|
|
203
210
|
* cannot be answered differently; which names are shells is the caller's declaration. Not a decline. */
|
|
@@ -2,13 +2,44 @@ export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
|
2
2
|
export function programBasename(word) {
|
|
3
3
|
return word.includes("/") ? (word.split("/").pop() ?? word) : word;
|
|
4
4
|
}
|
|
5
|
-
export const
|
|
5
|
+
export const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]*\])?\+?=/;
|
|
6
|
+
export function writtenAssignmentPrefix(token) {
|
|
7
|
+
const prefix = ASSIGNMENT_PREFIX.exec(token)?.[0];
|
|
8
|
+
if (prefix === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const appends = prefix.endsWith("+=");
|
|
11
|
+
const written = prefix.slice(0, prefix.length - (appends ? 2 : 1));
|
|
12
|
+
const bracket = written.indexOf("[");
|
|
13
|
+
return { prefix, name: bracket < 0 ? written : written.slice(0, bracket), appends, settlesWholeName: !appends && bracket < 0 };
|
|
14
|
+
}
|
|
6
15
|
export const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
|
|
7
16
|
export const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
|
|
8
17
|
export function isBareControlWord(toks, i, words) {
|
|
9
18
|
const word = toks.folded[i];
|
|
10
19
|
return word !== undefined && toks.raw[i] === word && words.has(word);
|
|
11
20
|
}
|
|
21
|
+
export function isLeadingAssignmentWord(toks, i) {
|
|
22
|
+
const word = toks.folded[i];
|
|
23
|
+
const raw = toks.raw[i];
|
|
24
|
+
if (word === undefined || raw === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const written = writtenAssignmentPrefix(word);
|
|
27
|
+
return written !== undefined && prefixWrittenBare(raw, written);
|
|
28
|
+
}
|
|
29
|
+
function prefixWrittenBare(raw, written) {
|
|
30
|
+
if (!raw.startsWith(written.name))
|
|
31
|
+
return false;
|
|
32
|
+
let at = written.name.length;
|
|
33
|
+
if (written.prefix[at] === "[") {
|
|
34
|
+
if (raw[at] !== "[")
|
|
35
|
+
return false;
|
|
36
|
+
const close = raw.indexOf("]", at);
|
|
37
|
+
if (close < 0)
|
|
38
|
+
return false;
|
|
39
|
+
at = close + 1;
|
|
40
|
+
}
|
|
41
|
+
return raw.startsWith(written.appends ? "+=" : "=", at);
|
|
42
|
+
}
|
|
12
43
|
export const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
|
|
13
44
|
export const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
|
|
14
45
|
const NO_DECLARED_OPTIONS = { optionArity: new Map() };
|
|
@@ -90,7 +121,7 @@ export function readProgramPosition(toks) {
|
|
|
90
121
|
return none(undefined, []);
|
|
91
122
|
const written = i;
|
|
92
123
|
const assignments = [];
|
|
93
|
-
while (i < folded.length &&
|
|
124
|
+
while (i < folded.length && isLeadingAssignmentWord(toks, i)) {
|
|
94
125
|
assignments.push(i);
|
|
95
126
|
i++;
|
|
96
127
|
}
|
|
@@ -159,17 +190,6 @@ export function argvReentryUnjudged(pos, acceptedTargets) {
|
|
|
159
190
|
return runs === undefined || !acceptedTargets.has(runs);
|
|
160
191
|
}
|
|
161
192
|
export const SHELL_SCAN_DECLINES = ["unreadable_program_position", "stream_fed_operands"];
|
|
162
|
-
export const BOUNDARY_SEAT_READS_DECLINE = {
|
|
163
|
-
unreadable_program_position: true,
|
|
164
|
-
stream_fed_operands: true,
|
|
165
|
-
};
|
|
166
|
-
export const CLASSIFY_SEAT_READS_DECLINE = {
|
|
167
|
-
unreadable_program_position: false,
|
|
168
|
-
stream_fed_operands: false,
|
|
169
|
-
};
|
|
170
|
-
export function seatDeclineEvidence(table) {
|
|
171
|
-
return new Set(SHELL_SCAN_DECLINES.filter((decline) => table[decline] === true));
|
|
172
|
-
}
|
|
173
193
|
export function programPositionRunsShell(pos, shellProgram) {
|
|
174
194
|
if (pos.refusal !== undefined)
|
|
175
195
|
return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true;
|