@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.
- package/CHANGELOG.md +150 -0
- package/dist/agents/cumulative-stats.d.ts +26 -0
- package/dist/agents/cumulative-stats.js +56 -0
- package/dist/agents/observer.d.ts +11 -7
- package/dist/agents/observer.js +2 -4
- package/dist/agents/subagent.d.ts +4 -0
- package/dist/agents/subagent.js +1 -1
- package/dist/agents/verify.d.ts +27 -3
- package/dist/agents/verify.js +7 -2
- package/dist/core/governance-codes.js +14 -0
- package/dist/core/hooks.js +1 -1
- package/dist/core/lsp-diagnostics.d.ts +19 -17
- package/dist/core/lsp-diagnostics.js +11 -5
- package/dist/core/mcp.d.ts +214 -5
- package/dist/core/mcp.js +315 -30
- package/dist/core/memory-engine/consolidation.d.ts +378 -0
- package/dist/core/memory-engine/consolidation.js +342 -0
- package/dist/core/memory-engine/dual-root.js +3 -0
- package/dist/core/memory-engine/engine.d.ts +237 -4
- package/dist/core/memory-engine/engine.js +1111 -4
- package/dist/core/memory-engine/export-bundle.js +9 -0
- package/dist/core/memory-engine/file-backend.js +27 -1
- package/dist/core/memory-engine/frontmatter.d.ts +20 -1
- package/dist/core/memory-engine/frontmatter.js +111 -0
- package/dist/core/memory-engine/index.d.ts +4 -2
- package/dist/core/memory-engine/index.js +3 -1
- package/dist/core/memory-engine/memory-backend-contract.js +131 -0
- package/dist/core/memory-engine/sync-client.js +26 -0
- package/dist/core/memory-engine/tools.d.ts +9 -0
- package/dist/core/memory-engine/tools.js +57 -13
- package/dist/core/memory-engine/types.d.ts +99 -0
- package/dist/core/memory-recall.js +4 -3
- package/dist/core/memory.d.ts +33 -3
- package/dist/core/memory.js +6 -4
- package/dist/core/permission-rule-model.d.ts +140 -21
- package/dist/core/permission-rule-model.js +76 -17
- package/dist/core/permission-rule-org.d.ts +4 -3
- package/dist/core/permission-rule-org.js +12 -3
- package/dist/core/permission-rules.d.ts +22 -0
- package/dist/core/permission-rules.js +60 -6
- package/dist/core/reminder-disclosure.d.ts +29 -4
- package/dist/core/reminder-disclosure.js +60 -12
- package/dist/core/runner/prepare-memory.js +7 -2
- package/dist/core/runner/prepare-task.d.ts +31 -1
- package/dist/core/runner/prepare-task.js +31 -14
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/runner/runtask.js +12 -10
- package/dist/core/runner/session-rule-policy.js +5 -3
- package/dist/core/runner/synthetic-tools.js +4 -2
- package/dist/core/runner/turn-attachments.d.ts +16 -6
- package/dist/core/runner/turn-attachments.js +34 -20
- package/dist/core/tool-policy.d.ts +18 -0
- package/dist/core/tool-policy.js +19 -8
- package/dist/core/types.d.ts +89 -6
- package/dist/core/untrusted-egress.js +12 -2
- package/dist/core/untrusted-text.d.ts +189 -3
- package/dist/core/untrusted-text.js +416 -6
- package/dist/engine/loop/types.d.ts +7 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/orchestration/workflow-types.d.ts +16 -6
- package/dist/orchestration/workflow-types.js +10 -4
- package/dist/orchestration/workflow.js +32 -6
- package/dist/prompts/default.d.ts +12 -2
- package/dist/stores/file/background-agent-store.js +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +6 -2
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/fs-atomic.d.ts +151 -10
- package/dist/stores/file/fs-atomic.js +208 -32
- package/dist/stores/file/index.d.ts +26 -3
- package/dist/stores/file/index.js +25 -2
- package/dist/stores/file/shared-ledger.d.ts +40 -5
- package/dist/stores/file/shared-ledger.js +24 -8
- package/dist/stores/file/workflow-run-store.d.ts +8 -1
- package/dist/stores/file/workflow-run-store.js +1 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
- package/dist/tools/fs/bash-readonly-classifier.js +58 -47
- package/dist/tools/fs/index.d.ts +3 -1
- package/package.json +1 -1
- 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.
|
|
49
|
-
*
|
|
50
|
-
* is the
|
|
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
|
-
|
|
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
|
-
/**
|
|
82
|
-
*
|
|
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
|
-
/**
|
|
97
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
this.
|
|
25
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
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)
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -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
|
|
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). */
|