@sema-agent/core 7.24.4 → 7.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/arg-summary.d.ts +0 -7
  8. package/dist/core/arg-summary.js +2 -1
  9. package/dist/core/background-agent-store.d.ts +3 -3
  10. package/dist/core/checkpoint-store.d.ts +1 -0
  11. package/dist/core/delegation-frames.d.ts +5 -0
  12. package/dist/core/gate-fold.js +85 -0
  13. package/dist/core/gate-lanes.js +23 -1
  14. package/dist/core/gate-outcome.d.ts +8 -2
  15. package/dist/core/gate-outcome.js +3 -1
  16. package/dist/core/hooks.d.ts +29 -0
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/mailbox-store.d.ts +5 -0
  19. package/dist/core/mailbox-store.js +1 -1
  20. package/dist/core/parent-spec-seat.d.ts +7 -1
  21. package/dist/core/parent-spec-seat.js +2 -0
  22. package/dist/core/permission-rule-consent.js +29 -47
  23. package/dist/core/permission-rule-provider.js +4 -3
  24. package/dist/core/permission-rule-store.d.ts +143 -7
  25. package/dist/core/permission-rule-store.js +165 -15
  26. package/dist/core/runner/contracts.d.ts +36 -2
  27. package/dist/core/runner/gate-exit.d.ts +14 -0
  28. package/dist/core/runner/gate-exit.js +4 -0
  29. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  30. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  31. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  32. package/dist/core/runner/prepare-gate-stations.js +7 -4
  33. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  34. package/dist/core/runner/prepare-hands-readface.js +25 -10
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  36. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  37. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  38. package/dist/core/runner/prepare-policy-chain.js +14 -4
  39. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  40. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  41. package/dist/core/runner/prepare-task.js +10 -9
  42. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  43. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  44. package/dist/core/runner/resume-apply.js +2 -0
  45. package/dist/core/runner/run-harness-handlers.js +1 -0
  46. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  47. package/dist/core/runner/run-leg.js +26 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  49. package/dist/core/runner/run-notification-lane.js +2 -1
  50. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  51. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  52. package/dist/core/runner/session-rule-policy.js +11 -3
  53. package/dist/core/session-policy-store.d.ts +27 -3
  54. package/dist/core/session-policy-store.js +12 -4
  55. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  56. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  57. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  58. package/dist/core/task-event.d.ts +6 -0
  59. package/dist/core/task-notification.d.ts +79 -4
  60. package/dist/core/task-notification.js +90 -1
  61. package/dist/core/task-registry-agent.js +1 -2
  62. package/dist/core/task-registry-monitor.js +2 -1
  63. package/dist/core/task-registry-shared.d.ts +53 -5
  64. package/dist/core/task-registry-shared.js +13 -2
  65. package/dist/core/task-registry.js +8 -5
  66. package/dist/core/task-spec.d.ts +12 -5
  67. package/dist/core/tool-catalog-entries.js +1 -0
  68. package/dist/core/tool-policy.js +30 -23
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/prompts/coordinator.d.ts +2 -1
  76. package/dist/stores/file/adoption/adopt.js +23 -1
  77. package/dist/stores/file/permission-rule-store.js +3 -1
  78. package/dist/stores/file/session-policy-store.d.ts +31 -0
  79. package/dist/stores/file/session-policy-store.js +87 -7
  80. package/dist/tools/fs/bash-lexer.d.ts +13 -0
  81. package/dist/tools/fs/bash-lexer.js +61 -0
  82. package/dist/tools/fs/bash-program-position.d.ts +36 -29
  83. package/dist/tools/fs/bash-program-position.js +33 -13
  84. package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
  85. package/dist/tools/fs/bash-readonly-classifier.js +81 -782
  86. package/dist/tools/fs/fs-bash.d.ts +43 -207
  87. package/dist/tools/fs/fs-bash.js +20 -341
  88. package/dist/tools/fs/index.d.ts +12 -13
  89. package/dist/tools/fs/index.js +3 -4
  90. package/dist/tools/fs/read-deny.d.ts +25 -0
  91. package/dist/tools/fs/read-deny.js +19 -0
  92. package/dist/tools/fs/safety.js +2 -6
  93. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  94. package/dist/tools/fs/shell-read-check.js +229 -0
  95. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  96. package/dist/tools/fs/shell-read-establish.js +216 -102
  97. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  98. package/dist/tools/fs/shell-read-judge.js +170 -0
  99. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  100. package/dist/tools/fs/shell-read-plan.js +88 -27
  101. package/dist/tools/monitor.d.ts +5 -5
  102. package/dist/tools/monitor.js +3 -5
  103. package/dist/tools/task-list.d.ts +4 -0
  104. package/package.json +1 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.25.0.json +671 -0
  107. package/wire-changes/core@7.25.1.json +64 -0
@@ -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, `${sanitizeScope(JSON.stringify([row.__sid, toPrincipal]))}.json`);
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
- : (assertWriteDeltaScopeDurable(delta), assertDeleteDeltaCarriesNoAdd(delta), { ...cur, rev: cur.rev + 1, tombstones: [...cur.tombstones, delta.tombstone] });
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 join(this.dir, `${sanitizeScope(composite)}.json`);
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 (r.rev !== undefined && (typeof r.rev !== "number" || !Number.isFinite(r.rev))) {
58
- this.discloseCorrupt(sessionId, principal, "non-numeric rev");
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 next = { ...clean, rev: (prior?.rev ?? 0) + 1 };
92
- atomicWriteFile(join(this.dir, "tmp"), this.pathFor(sessionId, principal), JSON.stringify({ ...next, __sid: sessionId, __principal: (principal ?? this.anonymousAlias) ?? null }));
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
- if (parsed.__sid !== sessionId)
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
- /** A leading `NAME=value` word: an assignment bash applies to the command's environment rather than a
19
- * program name. Same shape the exit-code reader in `fs-bash.ts` skips for the same reason. */
20
- export declare const ASSIGNMENT_WORD: RegExp;
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 which of them a seat reads as
173
- * evidence is a table each seat declares against (`fs-bash.ts`, where the argument is), not a boolean buried
174
- * in a caller. `unreadable_program_position`: the walk cannot name the program bash will run (`env -- ls`).
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 ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
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 && ASSIGNMENT_WORD.test(folded[i])) {
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;