@sema-agent/core 5.53.0 → 5.55.0

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 (80) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/subagent.d.ts +4 -0
  7. package/dist/agents/subagent.js +1 -1
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/governance-codes.js +14 -0
  11. package/dist/core/hooks.js +1 -1
  12. package/dist/core/lsp-diagnostics.d.ts +19 -17
  13. package/dist/core/lsp-diagnostics.js +11 -5
  14. package/dist/core/mcp.d.ts +214 -5
  15. package/dist/core/mcp.js +315 -30
  16. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  17. package/dist/core/memory-engine/consolidation.js +342 -0
  18. package/dist/core/memory-engine/dual-root.js +3 -0
  19. package/dist/core/memory-engine/engine.d.ts +237 -4
  20. package/dist/core/memory-engine/engine.js +1111 -4
  21. package/dist/core/memory-engine/export-bundle.js +9 -0
  22. package/dist/core/memory-engine/file-backend.js +27 -1
  23. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  24. package/dist/core/memory-engine/frontmatter.js +111 -0
  25. package/dist/core/memory-engine/index.d.ts +4 -2
  26. package/dist/core/memory-engine/index.js +3 -1
  27. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  28. package/dist/core/memory-engine/sync-client.js +26 -0
  29. package/dist/core/memory-engine/tools.d.ts +9 -0
  30. package/dist/core/memory-engine/tools.js +57 -13
  31. package/dist/core/memory-engine/types.d.ts +99 -0
  32. package/dist/core/memory-recall.js +4 -3
  33. package/dist/core/memory.d.ts +33 -3
  34. package/dist/core/memory.js +6 -4
  35. package/dist/core/permission-rule-model.d.ts +140 -21
  36. package/dist/core/permission-rule-model.js +76 -17
  37. package/dist/core/permission-rule-org.d.ts +4 -3
  38. package/dist/core/permission-rule-org.js +12 -3
  39. package/dist/core/permission-rules.d.ts +22 -0
  40. package/dist/core/permission-rules.js +60 -6
  41. package/dist/core/reminder-disclosure.d.ts +29 -4
  42. package/dist/core/reminder-disclosure.js +60 -12
  43. package/dist/core/runner/prepare-memory.js +7 -2
  44. package/dist/core/runner/prepare-task.d.ts +31 -1
  45. package/dist/core/runner/prepare-task.js +31 -14
  46. package/dist/core/runner/runtask.d.ts +8 -1
  47. package/dist/core/runner/runtask.js +12 -10
  48. package/dist/core/runner/session-rule-policy.js +5 -3
  49. package/dist/core/runner/synthetic-tools.js +4 -2
  50. package/dist/core/runner/turn-attachments.d.ts +16 -6
  51. package/dist/core/runner/turn-attachments.js +34 -20
  52. package/dist/core/tool-policy.d.ts +18 -0
  53. package/dist/core/tool-policy.js +19 -8
  54. package/dist/core/types.d.ts +89 -6
  55. package/dist/core/untrusted-egress.js +12 -2
  56. package/dist/core/untrusted-text.d.ts +189 -3
  57. package/dist/core/untrusted-text.js +416 -6
  58. package/dist/engine/loop/types.d.ts +7 -0
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +2 -2
  61. package/dist/orchestration/workflow-types.d.ts +16 -6
  62. package/dist/orchestration/workflow-types.js +10 -4
  63. package/dist/orchestration/workflow.js +32 -6
  64. package/dist/prompts/default.d.ts +12 -2
  65. package/dist/stores/file/background-agent-store.js +1 -0
  66. package/dist/stores/file/checkpoint-store.d.ts +6 -2
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/fs-atomic.d.ts +151 -10
  69. package/dist/stores/file/fs-atomic.js +208 -32
  70. package/dist/stores/file/index.d.ts +26 -3
  71. package/dist/stores/file/index.js +25 -2
  72. package/dist/stores/file/shared-ledger.d.ts +40 -5
  73. package/dist/stores/file/shared-ledger.js +24 -8
  74. package/dist/stores/file/workflow-run-store.d.ts +8 -1
  75. package/dist/stores/file/workflow-run-store.js +1 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
  77. package/dist/tools/fs/bash-readonly-classifier.js +58 -47
  78. package/dist/tools/fs/index.d.ts +3 -1
  79. package/package.json +1 -1
  80. package/test/export-surface.snapshot.json +30 -1
@@ -22,6 +22,7 @@ export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResu
22
22
  export { FileUsageWindowStore } from "./usage-window-store.js";
23
23
  export { FileStrategyStore, type FileStrategyStoreOptions } from "./strategy-store.js";
24
24
  export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
25
+ export { FileStoreLockError, type FileStoreLockErrorCode } from "./fs-atomic.js";
25
26
  export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
26
27
  export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, type AdoptionErrorCode, type AdoptionSource, type AdoptionReport, type AdoptionReceipt, type AdoptionLegReport, type AffectedDeploymentConfig, type RootAdoptionFile, } from "./adoption/marker.js";
27
28
  export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdoptionQuarantine, readAdoptionStatus, type AdoptionStatus, type AdoptLocalDataRootOptions, type AdoptLocalDataRootResult, type AdoptionCarriageLeg, type AdoptionCarriageLegContext, type AdoptionConfigWitnessReceipt, } from "./adoption/adopt.js";
@@ -45,9 +46,13 @@ export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdopt
45
46
  *
46
47
  * **Single-instance-per-data-dir (§2.4 / §7 decision 4):** the constructor takes a coarse boot `flock` on
47
48
  * `root/LOCK`. A second `FileStorageBackend` over the same dir FAILS FAST ("another instance owns this data
48
- * dir"); a stale lock from a dead PID is pruned. This is the ONLY file lock here — the once-only checkpoint
49
- * CAS is in-process (one event loop), so there is no per-operation lock. Cross-process correct concurrency
50
- * is the Pg/TiDB backend's job, by design.
49
+ * dir"); a stale lock from a dead PID is pruned. The once-only checkpoint CAS is in-process (one event
50
+ * loop), so there is still no per-operation lock. What this backend's lock does NOT cover — and never did,
51
+ * despite what the store docs used to say — is a store constructed DIRECTLY off the root export (or one
52
+ * this backend does not construct at all, like `FileWorkflowRunStore`); those now take their own
53
+ * per-directory writer fence at construction ({@link import("./fs-atomic.js").acquireStoreDirLock}), so
54
+ * the single-writer premise holds however the stores were assembled. Serving many writers CONCURRENTLY
55
+ * (rather than refusing the second) is the Pg/TiDB backend's job, by design.
51
56
  */
52
57
  export interface FileStorageBackendOptions {
53
58
  /** Data root. Default `$AGENT_DATA_DIR ?? ~/.ai-agent`, NFC-normalized + realpath-canonicalized. */
@@ -165,6 +170,24 @@ export declare class FileStorageBackend {
165
170
  private readonly fileSessions;
166
171
  private readonly fileWorkflowJournal;
167
172
  constructor(opts?: FileStorageBackendOptions);
173
+ /**
174
+ * The data-dir boot lock could not be handed back — a root that turned read-only under a running
175
+ * process, an unreadable lock file. Teardown here is best-effort by contract, but best-effort must not
176
+ * mean SILENT: a lock file naming a LIVE pid refuses this data root to every other process, and only
177
+ * somebody saying so out loud makes that operator-actionable. (The release's own answer is the seat for
178
+ * this — a discarded boolean was the backend never learning that the fence it took at construction was
179
+ * never given back.)
180
+ *
181
+ * The wording states what a FAILED release actually establishes and no more: the removal could not be
182
+ * proven. A repeat teardown replays that same verdict without re-reading the directory (the release is
183
+ * one-shot), so this must not assert the file's CURRENT contents — only the consequence while it is
184
+ * there, and the ways out. In particular it must NOT promise that exiting clears it: the two conditions
185
+ * that produce this verdict are an UNREADABLE lock (never pruned by anyone — a stale-lock reclaim has to
186
+ * read the pid it is reclaiming) and a directory the removal cannot write; neither is undone by the
187
+ * owner exiting, and "it will be cleaned up automatically" is the one sentence that turns a five-second
188
+ * `chmod` into an outage.
189
+ */
190
+ private discloseRetainedBootLock;
168
191
  /** Release file handles + the boot lock (best-effort). Call on shutdown so a successor can acquire. */
169
192
  dispose(): Promise<void>;
170
193
  }
@@ -25,6 +25,7 @@ export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResu
25
25
  export { FileUsageWindowStore } from "./usage-window-store.js";
26
26
  export { FileStrategyStore } from "./strategy-store.js";
27
27
  export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
28
+ export { FileStoreLockError } from "./fs-atomic.js";
28
29
  export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
29
30
  export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, } from "./adoption/marker.js";
30
31
  export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdoptionQuarantine, readAdoptionStatus, } from "./adoption/adopt.js";
@@ -56,6 +57,7 @@ export class FileStorageBackend {
56
57
  assertAdoptionBootGate(this.root, "FileStorageBackend");
57
58
  this.lock = new BootLock(join(this.root, "LOCK"));
58
59
  this.lock.acquire();
60
+ const built = [];
59
61
  try {
60
62
  const corruptRead = opts.onCorruptRead !== undefined ? { onCorruptRead: opts.onCorruptRead } : undefined;
61
63
  const repo = new FileSessionRepo(this.root, corruptRead);
@@ -63,18 +65,23 @@ export class FileStorageBackend {
63
65
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget", durability: "durable", placements: { subagent: { durability: "durable" } } });
64
66
  this.sessionStore = this.ttl;
65
67
  this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
68
+ built.push(() => this.fileCheckpoints.close());
66
69
  this.checkpointStore = this.fileCheckpoints;
67
70
  this.fileMemory = new FileMemoryStore(this.root, opts.embedder ? { embedder: opts.embedder } : {});
71
+ built.push(() => this.fileMemory.close());
68
72
  this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
69
73
  this.toolResultStore = new FileToolResultStore(this.root);
70
74
  this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
71
75
  this.fileSnapshotStore = new FileFileSnapshotStore(this.root, opts.snapshotBounds, corruptRead);
72
76
  this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
77
+ built.push(() => this.fileWorkflowJournal.dispose());
73
78
  this.workflowJournalStore = this.fileWorkflowJournal;
74
79
  this.usageWindowStore = new FileUsageWindowStore(this.root);
75
80
  this.fileAgentRows = new FileBackgroundAgentStore(this.root);
81
+ built.push(() => this.fileAgentRows.close());
76
82
  this.backgroundAgentStore = this.fileAgentRows;
77
83
  this.fileMailbox = new FileMailboxStore(this.root, corruptRead ?? {});
84
+ built.push(() => this.fileMailbox.close());
78
85
  this.mailboxStore = this.fileMailbox;
79
86
  this.rosterStore = new FileRosterStore(join(this.root, "roster.json"), corruptRead ?? {});
80
87
  const strategiesRoot = join(this.root, "strategies");
@@ -89,10 +96,25 @@ export class FileStorageBackend {
89
96
  this.consolidationLock = createFileConsolidationLock(join(this.root, "consolidation-locks"));
90
97
  }
91
98
  catch (err) {
92
- this.lock.release();
99
+ for (const undo of built.reverse()) {
100
+ try {
101
+ undo();
102
+ }
103
+ catch {
104
+ }
105
+ }
106
+ if (!this.lock.release())
107
+ this.discloseRetainedBootLock();
93
108
  throw err;
94
109
  }
95
110
  }
111
+ discloseRetainedBootLock() {
112
+ console.warn(`[sema] file store: this backend could not hand back its boot lock at ${join(this.root, "LOCK")} — the ` +
113
+ `removal could not be proven, so a lock file naming this live process (pid ${process.pid}) may still be ` +
114
+ `there. While it is, another process opening this data root is refused by name; a later open of this root ` +
115
+ `in THIS process finishes the cleanup or refuses by name. After this process exits the file is reclaimed ` +
116
+ `as a stale lock ONLY if it is readable and its directory writable — otherwise it has to be removed by hand.`);
117
+ }
96
118
  async dispose() {
97
119
  try {
98
120
  await this.ttl.dispose();
@@ -122,6 +144,7 @@ export class FileStorageBackend {
122
144
  }
123
145
  catch {
124
146
  }
125
- this.lock.release();
147
+ if (!this.lock.release())
148
+ this.discloseRetainedBootLock();
126
149
  }
127
150
  }
@@ -35,6 +35,17 @@
35
35
  * compensation was the closest of the four and still wrong twice: it does not cover an unwritable
36
36
  * FILE (the reopen throws too), and its reopen error REPLACES the fault that caused the swap. The
37
37
  * answer is AppendLog's lazy reopen — {@link LedgerCore.compact}.
38
+ * · **(2026-08-22, cross-process defect probe) — one authority per directory needs one WRITER per
39
+ * directory, and that half was never enforced.** RB-55/59/134 closed the IN-PROCESS shape (two
40
+ * instances, two private replays, two winners); two OS PROCESSES over one directory reproduced it
41
+ * verbatim (a human approval and its refusal each reported as the winner) — and worse, the one that
42
+ * never loaded the other's rows ERASED them at its next {@link LedgerCore.compact} (the snapshot is
43
+ * rewritten from the in-memory map, and the live ledger is then truncated). The class docs all
44
+ * credited a fence they did not hold: `root/LOCK` belongs to `FileStorageBackend`, which never
45
+ * constructs the workflow-run store at all and which a directly-constructed store (all three are root
46
+ * exports) never goes through. So the fence now lives WITH the authority: {@link LedgerCore.bootstrap}
47
+ * takes `dir/LOCK` ({@link acquireStoreDirLock}) and the last {@link LedgerCore.release} drops it —
48
+ * exclusive across processes, refcount-joined in-process, exactly like the authority it guards.
38
49
  * · **RB-150 / RB-167 — compaction is HOUSEKEEPING.** It runs after the operation is durable AND
39
50
  * applied, so its failure must never be reported as the operation's failure: a committed
40
51
  * once-only CAS surfaced as "this did not happen" is the worst possible direction (the caller
@@ -57,6 +68,9 @@ export interface LedgerPaths {
57
68
  }
58
69
  /** The per-store half: how a SNAPSHOT row is keyed, and how one LEDGER event folds into the map. */
59
70
  export interface LedgerModel<Row, Ev> {
71
+ /** What this ledger IS, in an operator's words ("checkpoint ledger") — the noun the directory-fence
72
+ * refusal names, so the message points at the store that is actually in use rather than at a path. */
73
+ label: string;
60
74
  keyOf(row: Row): string;
61
75
  apply(rows: Map<string, Row>, ev: Ev): void;
62
76
  }
@@ -77,9 +91,21 @@ export declare class LedgerCore<Row, Ev> {
77
91
  private readonly locks;
78
92
  private events;
79
93
  private handle;
94
+ /** This directory's cross-process writer fence, held for as long as the authority exists. */
95
+ private fence;
96
+ /** Set when the last holder left: this object is no longer the directory's authority (see compact). */
97
+ private released;
80
98
  constructor(key: string, paths: LedgerPaths, model: LedgerModel<Row, Ev>, table: Map<string, LedgerCore<Row, Ev>>);
81
- /** Rebuild the authority: the snapshot (the compacted base) first, then the live ledger's events in
82
- * order (last-writer-wins), then open the append handle. Only the FIRST instance runs it. */
99
+ /**
100
+ * Rebuild the authority: take the directory's cross-process writer fence, then the snapshot (the
101
+ * compacted base), then the live ledger's events in order (last-writer-wins), then open the append
102
+ * handle. Only the FIRST instance over the directory runs it.
103
+ *
104
+ * The fence comes FIRST because everything after it assumes what the fence establishes: the replayed
105
+ * map is treated as THE authority for the directory (writes are decided against memory, and
106
+ * compaction rewrites the on-disk state FROM memory). Replaying first and locking after would be a
107
+ * window in which a second process's view is already stale.
108
+ */
83
109
  bootstrap(): void;
84
110
  /**
85
111
  * Serialize an op behind any in-flight op on the same key — the in-process per-key async mutex (the
@@ -93,11 +119,20 @@ export declare class LedgerCore<Row, Ev> {
93
119
  * memory and disk disagreeing. Then the best-effort housekeeping compaction (RB-150/RB-167).
94
120
  */
95
121
  commit(ev: Ev, fsync: boolean, compactEvery: number): void;
96
- /** Rewrite the snapshot from the authoritative map (atomic) and truncate the live ledger. After
97
- * this a replay reads the whole state from the snapshot alone the round-trip is identical. */
122
+ /**
123
+ * Rewrite the snapshot from the authoritative map (atomic) and truncate the live ledger. After
124
+ * this a replay reads the whole state from the snapshot alone — the round-trip is identical.
125
+ *
126
+ * REFUSED once this authority has been RELEASED. Compaction is the one operation that does not touch
127
+ * the append fd (it rewrites whole files), so a store object kept alive past its `close()` could still
128
+ * run it — rewriting the directory from a map that stopped being the authority, over rows a SUCCESSOR
129
+ * (here or in another process, holding the fence this core gave back) has since written. That is the
130
+ * same erasure the directory fence exists to prevent, arriving through a stale reference instead of a
131
+ * second process, so it is refused at the same door rather than trusted to caller discipline.
132
+ */
98
133
  compact(): void;
99
134
  /** Drop one instance's share. The LAST holder evicts the authority — identity-checked, so a stale
100
- * holder can never revoke a REBUILT entry — and closes the append fd. */
135
+ * holder can never revoke a REBUILT entry — retires it (see {@link compact}) and closes the append fd. */
101
136
  release(): void;
102
137
  /** Test/inspection: events appended since the last compaction. */
103
138
  get pendingEvents(): number;
@@ -1,4 +1,4 @@
1
- import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords } from "./fs-atomic.js";
1
+ import { acquireStoreDirLock, AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, } from "./fs-atomic.js";
2
2
  export class LedgerCore {
3
3
  key;
4
4
  paths;
@@ -9,6 +9,8 @@ export class LedgerCore {
9
9
  locks = new Map();
10
10
  events = 0;
11
11
  handle;
12
+ fence;
13
+ released = false;
12
14
  constructor(key, paths, model, table) {
13
15
  this.key = key;
14
16
  this.paths = paths;
@@ -16,13 +18,21 @@ export class LedgerCore {
16
18
  this.table = table;
17
19
  }
18
20
  bootstrap() {
19
- for (const row of readJsonlRecords(this.paths.snapshotPath))
20
- this.rows.set(this.model.keyOf(row), row);
21
- const events = readJsonlRecords(this.paths.ledgerPath);
22
- this.events = events.length;
23
- for (const ev of events)
24
- this.model.apply(this.rows, ev);
25
- this.handle = new AppendLog(this.paths.ledgerPath);
21
+ this.fence = acquireStoreDirLock(this.paths.dir, { label: this.model.label });
22
+ try {
23
+ for (const row of readJsonlRecords(this.paths.snapshotPath))
24
+ this.rows.set(this.model.keyOf(row), row);
25
+ const events = readJsonlRecords(this.paths.ledgerPath);
26
+ this.events = events.length;
27
+ for (const ev of events)
28
+ this.model.apply(this.rows, ev);
29
+ this.handle = new AppendLog(this.paths.ledgerPath);
30
+ }
31
+ catch (e) {
32
+ this.fence.release();
33
+ this.fence = undefined;
34
+ throw e;
35
+ }
26
36
  }
27
37
  withLock(key, fn) {
28
38
  const prev = this.locks.get(key) ?? Promise.resolve();
@@ -48,6 +58,9 @@ export class LedgerCore {
48
58
  }
49
59
  }
50
60
  compact() {
61
+ if (this.released) {
62
+ throw new Error("file ledger: this authority was released (its store was closed) — refusing to compact a directory it no longer owns");
63
+ }
51
64
  const lines = [...this.rows.values()].map((r) => JSON.stringify(r)).join("\n");
52
65
  atomicWriteFile(this.paths.tmpDir, this.paths.snapshotPath, lines.length ? `${lines}\n` : "");
53
66
  this.log.closeForSwap();
@@ -60,7 +73,10 @@ export class LedgerCore {
60
73
  return;
61
74
  if (this.table.get(this.key) === this)
62
75
  this.table.delete(this.key);
76
+ this.released = true;
63
77
  this.handle?.close();
78
+ this.fence?.release();
79
+ this.fence = undefined;
64
80
  }
65
81
  get pendingEvents() {
66
82
  return this.events;
@@ -35,6 +35,13 @@ export declare class FileWorkflowRunStore implements WorkflowRunStore {
35
35
  compactNow(): void;
36
36
  /** Test/inspection helper: number of stored runs. */
37
37
  get size(): number;
38
- /** Release the append handle (best-effort). The boot LOCK is released by the backend factory. */
38
+ /**
39
+ * Release the append handle (best-effort). The LAST holder over the directory also drops its
40
+ * cross-process writer fence (`<dir>/LOCK`), so a successor process can open the same run ledger.
41
+ * There is no OTHER door: this store is never constructed by `FileStorageBackend`, so nothing else
42
+ * ever hands its fence back — the doc line that used to credit "the backend factory" here named a
43
+ * release that could not happen under any assembly, for a lock this store did not hold in the first
44
+ * place (see the class note above).
45
+ */
39
46
  close(): void;
40
47
  }
@@ -4,6 +4,7 @@ import { WorkflowRunStoreError, isTerminalWorkflowStatus, nextWorkflowRunOnUpdat
4
4
  import { SharedLedgerTable } from "./shared-ledger.js";
5
5
  import { assertAdoptionBootGate } from "./adoption/marker.js";
6
6
  const runLedgers = new SharedLedgerTable({
7
+ label: "workflow-run ledger",
7
8
  keyOf: (run) => run.id,
8
9
  apply: (runs, ev) => {
9
10
  if (ev.t === "delete") {
@@ -40,6 +40,29 @@ export interface LeadingCommandNameOptions {
40
40
  * operator character counts, exactly as without this option.
41
41
  */
42
42
  quotedOperatorsAreText?: boolean;
43
+ /**
44
+ * Read a PATH-PREFIXED `argv[0]` (`./gradlew`, `/usr/bin/git`, `bin/tool`) as an ordinary command
45
+ * name instead of refusing it.
46
+ *
47
+ * OPT-IN, and used by ONE family of callers: the persisted permission-RULE lane
48
+ * ({@link import("../../core/permission-rule-model.js").parseAllowRuleText} and its matcher), where
49
+ * both sides of the comparison are literal text a person read on an approval card. The refusal this
50
+ * option lifts exists for the argv[0]-NAME filters (the read-only allowlist, the coarse
51
+ * command-name policy, the skill specifier): those compare a bare token against a name set, and a
52
+ * path prefix is how a caller reaches a program the set never vetted. A rule lane compares the whole
53
+ * command line instead, so `./gradlew` there is not a way past a name set — it IS the name that was
54
+ * approved, and refusing it made the single most ordinary build command in a repository unable to
55
+ * carry a standing approval at all.
56
+ *
57
+ * What it does NOT lift: the argv[0] metacharacter refusal (quotes/braces/globs/`~` still make the
58
+ * parsed token differ from the program bash runs), the leading env-assignment refusal, and every
59
+ * shell operator. And it does not make a path-prefixed name equal its basename anywhere — the rule
60
+ * lane matches on the text as written, so `./gradlew` and `gradlew` stay two different commands. The
61
+ * one place a basename IS taken is the rule lane's own interpreter refusal, which must read
62
+ * `/usr/bin/node` as `node` (see
63
+ * {@link import("../../core/permission-rule-model.js").BARE_INTERPRETER_NAMES}).
64
+ */
65
+ pathPrefixedNameIsText?: boolean;
43
66
  }
44
67
  /**
45
68
  * The SINGLE fail-closed simple-command parser shared by `bash_readonly` ({@link coarseReadonlyCheck}), the
@@ -48,6 +71,8 @@ export interface LeadingCommandNameOptions {
48
71
  * `argv[0]` command NAME of a SINGLE simple command, rejecting anything that could chain past or escape an
49
72
  * argv[0]-name filter: shell operators (pipes / redirects / `;` / `&&` / `$(…)` / subshells / backticks /
50
73
  * newlines / backslash), a path-prefixed command (`/usr/bin/foo`), or a leading env-assignment (`FOO=bar cmd`).
74
+ * The path-prefix refusal is the one arm a caller may lift, opt-in and for the rule lane only — see
75
+ * {@link LeadingCommandNameOptions.pathPrefixedNameIsText} for why it is sound exactly there.
51
76
  *
52
77
  * Returns `{ name }` for a parseable single bare command, or `{ reject }` with a human reason otherwise. It
53
78
  * does NOT inspect ARGUMENTS for write flags or consult any allowlist — that is the caller's job (the
@@ -66,6 +91,52 @@ export declare function parseLeadingCommandName(command: string, options?: Leadi
66
91
  * design/154: this `effect:"read"` DECLARATION face deliberately stays strict-single-command; only the
67
92
  * classify face ({@link classifyCompoundReadonly} via {@link import("./fs-bash.js").bashReversibilityProbe}) segments compounds. */
68
93
  export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined;
94
+ /** What {@link splitShellCompoundSegments} produces: the connector-delimited segments, plus which of
95
+ * them a PIPE fed (the read-only classifier's stdin arm needs that distinction; a rule lane does not). */
96
+ export interface ShellCompoundSegments {
97
+ segments: string[];
98
+ /** Parallel to {@link segments}: was this segment preceded by `|` (rather than `;`/`&&`/`||`/nothing)? */
99
+ pipeFed: boolean[];
100
+ }
101
+ /**
102
+ * Split a compound command into the segments bash would run, or refuse the whole string.
103
+ *
104
+ * THE one segmentation in this repository. Extracted from {@link classifyCompoundReadonlyDetailed}
105
+ * (whose behaviour it reproduces exactly) when a SECOND face needed segments — the persisted
106
+ * permission-rule lane, which must judge every segment of a compound against the deny/ask rules rather
107
+ * than reading the whole string as one unmatched blob. The same argument that keeps ONE
108
+ * {@link parseLeadingCommandName} applies with more force here: a second splitter would drift, and
109
+ * drift in a segmentation is drift in what a deny rule is even looking at.
110
+ *
111
+ * Three steps, in this order and load-bearing:
112
+ * 1. WHOLE-STRING hard reject of {@link SHELL_SEGMENT_HARD_REJECT} — redirection, substitution,
113
+ * subshells, escapes, line breaks. This is what makes step 3's quote mask EXACT: with those
114
+ * characters gone, `'`/`"` pairing is the entirety of quoting, so the mask's regions are exactly
115
+ * bash's quoted regions and the unquoted connectors are exactly bash's command boundaries. Callers
116
+ * that tolerate a narrow redirection subset (the read-only classify face and its two data-free
117
+ * spellings) remove those words BEFORE calling; nothing here restores them.
118
+ * 2. one trailing `;` is stripped — a no-op terminator, not an empty command. OPTIONAL, because it is
119
+ * the one step whose answer depends on what the caller is asking. A face that asks "which programs
120
+ * does this run" wants it stripped (`ls;` runs `ls`, nothing else). A face that asks "is this ONE
121
+ * command" must NOT strip it: `"keep"` makes `ls;` two segments, the second empty, so the caller's
122
+ * own empty-command rule puts the whole string outside its lane — which is what keeps a statement
123
+ * like "this rule form never matches a command containing a connector" literally true instead of
124
+ * true-except-for-one-spelling.
125
+ * 3. the quote-aware connector scan over `;` `&&` `||` `|`. A lone `&` is REFUSED rather than split
126
+ * on: it backgrounds a process that outlives the command, which is not a combinator any face here
127
+ * is willing to reason about. Unbalanced quoting disables the mask, which degrades to the
128
+ * quote-blind scan — strictly MORE segments, each of which the caller must still vet, i.e. the
129
+ * fail-closed direction.
130
+ *
131
+ * Empty segments are NOT refused here (`;;`, a leading `;`, a trailing `&&`) — they come back as empty
132
+ * strings, and every caller's per-segment vetting rejects them through its own empty-command rule. The
133
+ * split does not judge segments; it says where they are.
134
+ */
135
+ export declare function splitShellCompoundSegments(source: string, options?: {
136
+ trailingTerminator?: "strip" | "keep";
137
+ }): ShellCompoundSegments | {
138
+ reject: string;
139
+ };
69
140
  /**
70
141
  * RB-412 — the READ-BOUNDARY face of the read-only classification (opt-in).
71
142
  *
@@ -45,8 +45,9 @@ export function parseLeadingCommandName(command, options) {
45
45
  };
46
46
  }
47
47
  const first = trimmed.split(/\s+/)[0];
48
- if (first.includes("/"))
48
+ if (first.includes("/") && options?.pathPrefixedNameIsText !== true) {
49
49
  return { reject: "the command must be a bare name resolved via PATH (no path prefix)" };
50
+ }
50
51
  if (first.includes("="))
51
52
  return { reject: "leading environment-variable assignments are not allowed" };
52
53
  if (/["'{}*?[\]~]/.test(first))
@@ -62,6 +63,55 @@ export function coarseReadonlyCheck(command, allow, options) {
62
63
  return undefined;
63
64
  }
64
65
  const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
66
+ export function splitShellCompoundSegments(source, options) {
67
+ if (SHELL_SEGMENT_HARD_REJECT.test(source)) {
68
+ return { reject: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
69
+ }
70
+ const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
71
+ const segments = [];
72
+ const pipeFed = [false];
73
+ const mask = quoteMask(scanned);
74
+ const quoted = (i) => mask.balanced && mask.quoted[i] === true;
75
+ let cur = "";
76
+ for (let i = 0; i < scanned.length; i++) {
77
+ const c = scanned[i];
78
+ if (quoted(i)) {
79
+ cur += c;
80
+ }
81
+ else if (c === "&") {
82
+ if (scanned[i + 1] === "&") {
83
+ segments.push(cur);
84
+ cur = "";
85
+ pipeFed.push(false);
86
+ i++;
87
+ }
88
+ else
89
+ return { reject: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
90
+ }
91
+ else if (c === "|") {
92
+ if (scanned[i + 1] === "|") {
93
+ segments.push(cur);
94
+ cur = "";
95
+ pipeFed.push(false);
96
+ i++;
97
+ }
98
+ else {
99
+ segments.push(cur);
100
+ cur = "";
101
+ pipeFed.push(true);
102
+ }
103
+ }
104
+ else if (c === ";") {
105
+ segments.push(cur);
106
+ cur = "";
107
+ pipeFed.push(false);
108
+ }
109
+ else
110
+ cur += c;
111
+ }
112
+ segments.push(cur);
113
+ return { segments, pipeFed };
114
+ }
65
115
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
66
116
  function stripExemptRedirections(command) {
67
117
  if (!command.includes(">"))
@@ -532,52 +582,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
532
582
  if (!trimmed)
533
583
  return { reason: "empty command" };
534
584
  const redirectionStripped = stripExemptRedirections(trimmed);
535
- if (SHELL_SEGMENT_HARD_REJECT.test(redirectionStripped)) {
536
- return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
537
- }
538
- const source = redirectionStripped.endsWith(";") ? redirectionStripped.slice(0, -1) : redirectionStripped;
539
- const segments = [];
540
- const pipeFed = [false];
541
- const mask = quoteMask(source);
542
- const quoted = (i) => mask.balanced && mask.quoted[i] === true;
543
- let cur = "";
544
- for (let i = 0; i < source.length; i++) {
545
- const c = source[i];
546
- if (quoted(i)) {
547
- cur += c;
548
- }
549
- else if (c === "&") {
550
- if (source[i + 1] === "&") {
551
- segments.push(cur);
552
- cur = "";
553
- pipeFed.push(false);
554
- i++;
555
- }
556
- else
557
- return { reason: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
558
- }
559
- else if (c === "|") {
560
- if (source[i + 1] === "|") {
561
- segments.push(cur);
562
- cur = "";
563
- pipeFed.push(false);
564
- i++;
565
- }
566
- else {
567
- segments.push(cur);
568
- cur = "";
569
- pipeFed.push(true);
570
- }
571
- }
572
- else if (c === ";") {
573
- segments.push(cur);
574
- cur = "";
575
- pipeFed.push(false);
576
- }
577
- else
578
- cur += c;
579
- }
580
- segments.push(cur);
585
+ const terminatorStripped = redirectionStripped.endsWith(";")
586
+ ? redirectionStripped.slice(0, -1)
587
+ : redirectionStripped;
588
+ const split = splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
589
+ if ("reject" in split)
590
+ return { reason: split.reject };
591
+ const { segments, pipeFed } = split;
581
592
  for (const segment of segments) {
582
593
  const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
583
594
  if (reason !== undefined)
@@ -163,7 +163,9 @@ export interface HandsToolkitOptions {
163
163
  reminderMark?: string;
164
164
  /** design/319 (B ticket) — the per-run trailer/defuse trigger counters (observation seat, G9②):
165
165
  * the Read text/notebook/PDF-text disclosure trailers bump `read.*` / `notebook.*` / `pdf.*`
166
- * keys here (appended / marked / bare_throttled). Threaded by prepare-task, which folds the
166
+ * keys here. The form suffixes are enumerated once, on the consumer-facing contract
167
+ * (`TaskResult.stats.mechanisms.reminderDisclosures` in core/types.ts) — this map is the same
168
+ * open record and is not a second, narrower registry of them. Threaded by prepare-task, which folds the
167
169
  * non-zero result into `TaskResult.stats.mechanisms.reminderDisclosures`; a library-direct
168
170
  * mount may pass its own object or omit it (counting off — and the whole disclosure pipeline
169
171
  * is off anyway when `reminderMark` is absent). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.53.0",
3
+ "version": "5.55.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",