@sema-agent/core 7.16.0 → 7.17.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 (207) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/agents/builtin-agents.d.ts +3 -3
  3. package/dist/agents/cross-session-envelope.d.ts +0 -1
  4. package/dist/agents/cross-session-envelope.js +0 -1
  5. package/dist/agents/peer-notices.d.ts +0 -9
  6. package/dist/agents/peer-notices.js +0 -63
  7. package/dist/agents/subagent.js +7 -15
  8. package/dist/brain/request-params.d.ts +2 -2
  9. package/dist/config/defaults.d.ts +0 -7
  10. package/dist/config/defaults.js +0 -2
  11. package/dist/core/artifact-host.d.ts +218 -0
  12. package/dist/core/artifact-host.js +92 -0
  13. package/dist/core/ask-origin.d.ts +29 -3
  14. package/dist/core/ask-origin.js +17 -9
  15. package/dist/core/ask-question.d.ts +25 -0
  16. package/dist/core/ask-question.js +4 -0
  17. package/dist/core/ask-unresolvable-notice.d.ts +30 -21
  18. package/dist/core/ask-unresolvable-notice.js +16 -8
  19. package/dist/core/auto-compaction.d.ts +10 -10
  20. package/dist/core/auto-mode-arming.d.ts +2 -2
  21. package/dist/core/background-agent-store.d.ts +5 -5
  22. package/dist/core/background-shell.d.ts +1 -1
  23. package/dist/core/checkpoint-execution-record.d.ts +0 -3
  24. package/dist/core/checkpoint-execution-record.js +0 -4
  25. package/dist/core/checkpoint-store.d.ts +71 -99
  26. package/dist/core/checkpoint-store.js +9 -16
  27. package/dist/core/context-edit.d.ts +36 -104
  28. package/dist/core/context-edit.js +37 -60
  29. package/dist/core/effective-path-target.d.ts +68 -8
  30. package/dist/core/effective-path-target.js +42 -30
  31. package/dist/core/engine-notice.d.ts +46 -32
  32. package/dist/core/env-budget.d.ts +31 -0
  33. package/dist/core/env-budget.js +39 -0
  34. package/dist/core/exec-output-tail.d.ts +5 -5
  35. package/dist/core/file-history-store.js +3 -6
  36. package/dist/core/fs-write-gate-policy.d.ts +1 -1
  37. package/dist/core/fs-write-gate-policy.js +3 -3
  38. package/dist/core/gate-lanes.js +9 -15
  39. package/dist/core/governance-codes.d.ts +1 -1
  40. package/dist/core/governance-codes.js +2 -0
  41. package/dist/core/hands-band.d.ts +1 -1
  42. package/dist/core/hooks.d.ts +18 -17
  43. package/dist/core/lsp-session.d.ts +1 -1
  44. package/dist/core/lsp.d.ts +1 -1
  45. package/dist/core/lsp.js +5 -4
  46. package/dist/core/mcp.d.ts +5 -5
  47. package/dist/core/mcp.js +4 -48
  48. package/dist/core/memory-engine/engine.js +2 -1
  49. package/dist/core/memory-engine/layout.d.ts +18 -6
  50. package/dist/core/memory-engine/layout.js +40 -21
  51. package/dist/core/memory-recall.d.ts +0 -8
  52. package/dist/core/memory.d.ts +1 -1
  53. package/dist/core/oracle-isolation.js +2 -2
  54. package/dist/core/parent-spec-seat.d.ts +66 -0
  55. package/dist/core/parent-spec-seat.js +47 -0
  56. package/dist/core/permission-rule-model.d.ts +11 -6
  57. package/dist/core/permission-rule-model.js +53 -37
  58. package/dist/core/permission-rules.js +8 -3
  59. package/dist/core/physical-path.d.ts +37 -0
  60. package/dist/core/physical-path.js +30 -0
  61. package/dist/core/protocol-naming.d.ts +2 -2
  62. package/dist/core/remote-env.d.ts +1 -1
  63. package/dist/core/retired-keys.d.ts +19 -0
  64. package/dist/core/retired-keys.js +15 -0
  65. package/dist/core/runner/active-skill-scope.js +3 -3
  66. package/dist/core/runner/assemble-result.d.ts +15 -13
  67. package/dist/core/runner/assemble-result.js +5 -4
  68. package/dist/core/runner/compaction-call-options.d.ts +25 -89
  69. package/dist/core/runner/contracts.d.ts +11 -10
  70. package/dist/core/runner/gate-exit.js +15 -1
  71. package/dist/core/runner/halt-attribution.d.ts +35 -0
  72. package/dist/core/runner/halt-attribution.js +16 -0
  73. package/dist/core/runner/memory-consolidation.d.ts +0 -2
  74. package/dist/core/runner/memory-consolidation.js +0 -1
  75. package/dist/core/runner/prepare-artifact.d.ts +52 -0
  76. package/dist/core/runner/prepare-artifact.js +63 -0
  77. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  78. package/dist/core/runner/prepare-caps-and-workflow.js +2 -3
  79. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  80. package/dist/core/runner/prepare-config-doors.js +5 -2
  81. package/dist/core/runner/prepare-context-lane.d.ts +2 -2
  82. package/dist/core/runner/prepare-context-lane.js +2 -13
  83. package/dist/core/runner/prepare-file-history.d.ts +1 -1
  84. package/dist/core/runner/prepare-file-history.js +3 -52
  85. package/dist/core/runner/prepare-hands-readface.d.ts +4 -7
  86. package/dist/core/runner/prepare-hands-readface.js +3 -2
  87. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -2
  88. package/dist/core/runner/prepare-inherited-gate.js +7 -8
  89. package/dist/core/runner/prepare-memory.d.ts +4 -6
  90. package/dist/core/runner/prepare-memory.js +4 -2
  91. package/dist/core/runner/prepare-policy-chain.d.ts +3 -3
  92. package/dist/core/runner/prepare-policy-chain.js +15 -30
  93. package/dist/core/runner/prepare-prompt-assembly.js +1 -1
  94. package/dist/core/runner/prepare-question-face.js +2 -1
  95. package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
  96. package/dist/core/runner/prepare-suspend-saga.js +0 -2
  97. package/dist/core/runner/prepare-task.js +12 -11
  98. package/dist/core/runner/prepare-wiring-manifest.js +9 -7
  99. package/dist/core/runner/resume-admission.js +2 -1
  100. package/dist/core/runner/resume-internals-and-config.js +4 -0
  101. package/dist/core/runner/retired-runner-deps.d.ts +3 -0
  102. package/dist/core/runner/retired-runner-deps.js +7 -0
  103. package/dist/core/runner/run-harness-handlers.js +5 -3
  104. package/dist/core/runner/run-settle-and-teardown.d.ts +1 -1
  105. package/dist/core/runner/run-settle-and-teardown.js +3 -1
  106. package/dist/core/runner/session-file-state-replay.js +3 -3
  107. package/dist/core/runner/session-rule-policy.js +3 -4
  108. package/dist/core/runner/stream-halt-verbs.js +1 -1
  109. package/dist/core/runner/stream-steer-verb.js +1 -4
  110. package/dist/core/runner/workspace-path.js +3 -0
  111. package/dist/core/runner-deps.d.ts +30 -49
  112. package/dist/core/safe-notify.d.ts +1 -1
  113. package/dist/core/sensitive-path-policy.d.ts +1 -1
  114. package/dist/core/sensitive-path-policy.js +11 -12
  115. package/dist/core/session.d.ts +2 -2
  116. package/dist/core/skill-tool-specifier.d.ts +1 -1
  117. package/dist/core/skills-directory.d.ts +1 -1
  118. package/dist/core/skills-directory.js +4 -3
  119. package/dist/core/spec-contract.js +5 -4
  120. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  121. package/dist/core/strategy-store.d.ts +8 -11
  122. package/dist/core/task-event.d.ts +20 -17
  123. package/dist/core/task-limits.d.ts +1 -1
  124. package/dist/core/task-notification.d.ts +5 -5
  125. package/dist/core/task-registry-agent.d.ts +5 -5
  126. package/dist/core/task-registry-monitor.d.ts +2 -2
  127. package/dist/core/task-registry-shared.d.ts +32 -28
  128. package/dist/core/task-registry-shared.js +4 -13
  129. package/dist/core/task-registry.d.ts +8 -8
  130. package/dist/core/task-result.d.ts +37 -37
  131. package/dist/core/task-spec.d.ts +5 -5
  132. package/dist/core/task-stream.d.ts +16 -11
  133. package/dist/core/tool-catalog-entries.d.ts +1 -0
  134. package/dist/core/tool-catalog-entries.js +6 -1
  135. package/dist/core/tool-conformance.d.ts +0 -2
  136. package/dist/core/tool-conformance.js +1 -3
  137. package/dist/core/tool-errors.d.ts +5 -0
  138. package/dist/core/tool-errors.js +1 -0
  139. package/dist/core/tool-policy.d.ts +66 -22
  140. package/dist/core/tool-policy.js +31 -5
  141. package/dist/core/tool-result-budget.d.ts +1 -1
  142. package/dist/core/tool-result-store.d.ts +11 -11
  143. package/dist/core/tool-spec.d.ts +34 -38
  144. package/dist/core/tools.d.ts +2 -2
  145. package/dist/core/trace.d.ts +12 -15
  146. package/dist/core/untrusted-text.d.ts +1 -1
  147. package/dist/core/wiring-manifest.d.ts +4 -3
  148. package/dist/core/workflow-journal-store.d.ts +11 -11
  149. package/dist/engine/execution-env/node-execution-env.d.ts +3 -2
  150. package/dist/engine/execution-env/node-execution-env.js +8 -5
  151. package/dist/engine/harness/agent-harness.d.ts +47 -4
  152. package/dist/engine/harness/agent-harness.js +52 -9
  153. package/dist/engine/loop/agent-loop.js +15 -8
  154. package/dist/engine/loop/types.d.ts +5 -0
  155. package/dist/index.d.ts +20 -14
  156. package/dist/index.js +16 -10
  157. package/dist/internal/harness.d.ts +1 -0
  158. package/dist/orchestration/run-spec.d.ts +3 -0
  159. package/dist/orchestration/run-spec.js +3 -3
  160. package/dist/orchestration/run-workflow-tool.d.ts +12 -6
  161. package/dist/orchestration/run-workflow-tool.js +23 -5
  162. package/dist/orchestration/workflow-primitives.d.ts +4 -2
  163. package/dist/orchestration/workflow-primitives.js +1 -4
  164. package/dist/orchestration/workflow-script-store.js +9 -25
  165. package/dist/orchestration/workflow-types.d.ts +34 -4
  166. package/dist/orchestration/workflow-types.js +2 -0
  167. package/dist/orchestration/workflow.d.ts +39 -7
  168. package/dist/orchestration/workflow.js +237 -113
  169. package/dist/prompt-assembly/assemble.d.ts +2 -2
  170. package/dist/prompt-assembly/assemble.js +9 -9
  171. package/dist/prompt-assembly/composer.d.ts +6 -7
  172. package/dist/prompt-assembly/composer.js +10 -10
  173. package/dist/prompt-assembly/epoch.js +1 -1
  174. package/dist/prompt-assembly/event-registry.d.ts +0 -21
  175. package/dist/prompt-assembly/packs/sema-default.js +30 -30
  176. package/dist/prompt-assembly/types.d.ts +14 -14
  177. package/dist/prompts/coordinator.d.ts +0 -13
  178. package/dist/prompts/coordinator.js +0 -3
  179. package/dist/stores/cc/task-list-store.js +2 -10
  180. package/dist/stores/file/checkpoint-store.js +1 -5
  181. package/dist/stores/file/file-history-store.js +2 -2
  182. package/dist/stores/file/fs-atomic.d.ts +15 -18
  183. package/dist/stores/file/fs-atomic.js +4 -14
  184. package/dist/stores/file/mailbox-store.d.ts +7 -11
  185. package/dist/stores/file/mailbox-store.js +4 -11
  186. package/dist/stores/file/permission-rule-store.d.ts +4 -35
  187. package/dist/stores/file/permission-rule-store.js +4 -148
  188. package/dist/tools/artifact/artifact-text.d.ts +62 -0
  189. package/dist/tools/artifact/artifact-text.js +68 -0
  190. package/dist/tools/artifact/artifact-tool.d.ts +52 -0
  191. package/dist/tools/artifact/artifact-tool.js +351 -0
  192. package/dist/tools/artifact/local-stub.d.ts +26 -0
  193. package/dist/tools/artifact/local-stub.js +297 -0
  194. package/dist/tools/fs/bash-readonly-classifier.d.ts +90 -8
  195. package/dist/tools/fs/bash-readonly-classifier.js +294 -70
  196. package/dist/tools/fs/fs-bash.d.ts +14 -10
  197. package/dist/tools/fs/fs-bash.js +50 -33
  198. package/dist/tools/fs/fs-search-tools.js +5 -2
  199. package/dist/tools/fs/fs-shared.d.ts +2 -0
  200. package/dist/tools/fs/fs-shared.js +3 -11
  201. package/dist/tools/fs/index.d.ts +4 -0
  202. package/dist/tools/fs/index.js +4 -1
  203. package/dist/tools/fs/safety.d.ts +117 -1
  204. package/dist/tools/fs/safety.js +131 -31
  205. package/dist/tools/fs/search.js +8 -6
  206. package/package.json +2 -1
  207. package/test/export-surface.snapshot.json +119 -39
@@ -0,0 +1,297 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import {} from "../../core/artifact-host.js";
5
+ import { physicalPathOf } from "../../core/physical-path.js";
6
+ import { acquireStoreDirLock } from "../../stores/file/fs-atomic.js";
7
+ import { normalizePublishedPath } from "./artifact-tool.js";
8
+ export const EVAL_STUB_URL_PREFIX = "eval-stub://artifact/";
9
+ export const LOCAL_STUB_MAX_FILE_BYTES = 16 * 1024 * 1024;
10
+ export const LOCAL_STUB_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
11
+ const LEDGER_FILE = "ledger.json";
12
+ const TMP_DIR = ".tmp";
13
+ const LOCK_FILE = "LOCK";
14
+ const RESERVED_NAMES = new Set([LEDGER_FILE, TMP_DIR, LOCK_FILE].map((n) => n.toLowerCase()));
15
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
16
+ const ATTEMPT_WINDOW = 512;
17
+ const emptyLedger = () => ({ v: 1, artifacts: {}, attempts: {} });
18
+ const isRow = (x, key) => {
19
+ if (x === null || typeof x !== "object")
20
+ return false;
21
+ const r = x;
22
+ if (typeof r.slug !== "string" || r.slug !== key || !SLUG_RE.test(r.slug) || r.slug.startsWith(".") || RESERVED_NAMES.has(r.slug.toLowerCase()))
23
+ return false;
24
+ if (typeof r.url !== "string" || !Number.isSafeInteger(r.version) || r.version < 1 || !Array.isArray(r.files))
25
+ return false;
26
+ return r.files.every((f) => f !== null && typeof f === "object" && typeof f.path === "string" && normalizePublishedPath(f.path).ok && Number.isSafeInteger(f.bytes));
27
+ };
28
+ function parseLedger(text, path) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch {
34
+ throw new Error(`artifact stub ledger is corrupt (not JSON): ${path}`);
35
+ }
36
+ const l = parsed;
37
+ if (l === null || typeof l !== "object" || l.v !== 1 || l.artifacts === null || typeof l.artifacts !== "object" || l.attempts === null || typeof l.attempts !== "object") {
38
+ throw new Error(`artifact stub ledger is corrupt (unexpected shape): ${path}`);
39
+ }
40
+ for (const [key, row] of Object.entries(l.artifacts))
41
+ if (!isRow(row, key))
42
+ throw new Error(`artifact stub ledger is corrupt (bad artifact row): ${path}`);
43
+ return { v: 1, artifacts: { ...l.artifacts }, attempts: { ...l.attempts } };
44
+ }
45
+ function fsyncDirBestEffort(dir) {
46
+ try {
47
+ const fd = openSync(dir, "r");
48
+ try {
49
+ fsyncSync(fd);
50
+ }
51
+ finally {
52
+ closeSync(fd);
53
+ }
54
+ }
55
+ catch {
56
+ }
57
+ }
58
+ function fsyncTreeBestEffort(dir) {
59
+ for (const entry of readdirSync(dir, { withFileTypes: true }))
60
+ if (entry.isDirectory())
61
+ fsyncTreeBestEffort(join(dir, entry.name));
62
+ fsyncDirBestEffort(dir);
63
+ }
64
+ function writeFsync(path, bytes) {
65
+ const fd = openSync(path, "w", 0o600);
66
+ try {
67
+ writeFileSync(fd, bytes);
68
+ fsyncSync(fd);
69
+ }
70
+ finally {
71
+ closeSync(fd);
72
+ }
73
+ }
74
+ function slugBase(fileName) {
75
+ const dot = fileName.lastIndexOf(".");
76
+ const stem = (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
77
+ return stem.length > 0 ? stem : "artifact";
78
+ }
79
+ export class LocalArtifactStub {
80
+ dir;
81
+ #mint = new Map();
82
+ #chain = Promise.resolve();
83
+ constructor(opts) {
84
+ if (typeof opts?.dir !== "string" || opts.dir.length === 0)
85
+ throw new Error("localArtifactStub: `dir` is required");
86
+ this.dir = resolve(opts.dir);
87
+ }
88
+ capabilities() {
89
+ return { plane: "local-stub", actions: ["publish"], defaultVisibility: "private", maxFileBytes: LOCAL_STUB_MAX_FILE_BYTES, maxTotalBytes: LOCAL_STUB_MAX_TOTAL_BYTES, multiFile: false, storageDir: this.dir };
90
+ }
91
+ onMount(runId) {
92
+ this.#mint.set(runId, new Set());
93
+ }
94
+ ownMinted(runId) {
95
+ return new Set(this.#mint.get(runId) ?? []);
96
+ }
97
+ publish(req, ctx) {
98
+ const run = this.#chain.then(() => this.#publishLocked(req, ctx));
99
+ this.#chain = run.then(() => undefined, () => undefined);
100
+ return run;
101
+ }
102
+ #ledgerPath() {
103
+ return join(this.dir, LEDGER_FILE);
104
+ }
105
+ #loadLedger() {
106
+ const p = this.#ledgerPath();
107
+ if (!existsSync(p))
108
+ return emptyLedger();
109
+ return parseLedger(readFileSync(p, "utf8"), p);
110
+ }
111
+ #versionDir(slug, version) {
112
+ return join(this.dir, slug, `v${version}`);
113
+ }
114
+ #snapshotOf(row) {
115
+ const base = this.#versionDir(row.slug, row.version);
116
+ const realBase = physicalPathOf(base);
117
+ const files = row.files.map((f) => {
118
+ const target = physicalPathOf(resolve(base, ...f.path.split("/")));
119
+ if (!target.startsWith(realBase + sep))
120
+ throw new Error(`artifact stub ledger is corrupt (a file resolves outside its version directory): ${this.#ledgerPath()}`);
121
+ return { path: f.path, content: new Uint8Array(readFileSync(target)), ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}) };
122
+ });
123
+ return {
124
+ files,
125
+ ...(row.title !== undefined ? { title: row.title } : {}),
126
+ ...(row.description !== undefined ? { description: row.description } : {}),
127
+ ...(row.favicon !== undefined ? { favicon: row.favicon } : {}),
128
+ ...(row.label !== undefined ? { label: row.label } : {}),
129
+ };
130
+ }
131
+ #mintSlug(ledger, requested, firstFile) {
132
+ const fold = requested?.toLowerCase();
133
+ if (requested !== undefined && fold !== undefined && SLUG_RE.test(requested) && !RESERVED_NAMES.has(fold) && !requested.startsWith(".") && !Object.keys(ledger.artifacts).some((s) => s.toLowerCase() === fold))
134
+ return requested;
135
+ const base = slugBase(firstFile);
136
+ const taken = new Set(Object.keys(ledger.artifacts).map((s) => s.toLowerCase()));
137
+ for (;;) {
138
+ const slug = `${base}-${randomBytes(3).toString("hex")}`;
139
+ if (!taken.has(slug))
140
+ return slug;
141
+ }
142
+ }
143
+ #commitLedger(next) {
144
+ const ledgerTmp = join(this.dir, `${LEDGER_FILE}.${randomBytes(4).toString("hex")}.tmp`);
145
+ try {
146
+ const text = JSON.stringify(next, null, 2);
147
+ writeFsync(ledgerTmp, text);
148
+ const verified = parseLedger(readFileSync(ledgerTmp, "utf8"), ledgerTmp);
149
+ if (JSON.stringify(verified) !== JSON.stringify(next))
150
+ throw new Error("artifact stub ledger did not read back as written");
151
+ renameSync(ledgerTmp, this.#ledgerPath());
152
+ }
153
+ catch (err) {
154
+ try {
155
+ rmSync(ledgerTmp, { force: true });
156
+ }
157
+ catch {
158
+ }
159
+ throw err;
160
+ }
161
+ fsyncDirBestEffort(this.dir);
162
+ }
163
+ #withAttempt(ledger, attemptId, attempt) {
164
+ const entries = Object.entries(ledger.attempts).filter(([k]) => k !== attemptId);
165
+ while (entries.length >= ATTEMPT_WINDOW)
166
+ entries.shift();
167
+ entries.push([attemptId, attempt]);
168
+ return Object.fromEntries(entries);
169
+ }
170
+ #replayOf(ledger, attempt) {
171
+ if ("ref" in attempt)
172
+ return { ok: true, ref: attempt.ref };
173
+ if (attempt.code === "conflict") {
174
+ const row = ledger.artifacts[attempt.slug];
175
+ return { ok: false, code: "conflict", currentVersion: row !== undefined ? String(row.version) : attempt.currentVersion, current: row !== undefined ? this.#snapshotOf(row) : "reread" };
176
+ }
177
+ return { ok: false, code: attempt.code, message: attempt.message };
178
+ }
179
+ async #publishLocked(req, ctx) {
180
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
181
+ const lease = acquireStoreDirLock(this.dir, { label: "artifact stub", fileName: LOCK_FILE });
182
+ try {
183
+ const ledger = this.#loadLedger();
184
+ const replay = ledger.attempts[ctx.attemptId];
185
+ if (replay !== undefined)
186
+ return this.#replayOf(ledger, replay);
187
+ const decided = this.#decide(ledger, req, ctx);
188
+ if (!decided.ok) {
189
+ const attempt = decided.outcome.code === "conflict" ? { code: "conflict", slug: decided.slug ?? "", currentVersion: decided.outcome.currentVersion } : { code: decided.outcome.code, message: decided.outcome.message };
190
+ this.#commitLedger({ ...ledger, attempts: this.#withAttempt(ledger, ctx.attemptId, attempt) });
191
+ return decided.outcome;
192
+ }
193
+ const { slug, version, existing, files, mint } = decided;
194
+ const versionDir = this.#versionDir(slug, version);
195
+ const tmpDir = join(this.dir, TMP_DIR, `${slug}-v${version}-${randomBytes(4).toString("hex")}`);
196
+ let staged = false;
197
+ try {
198
+ if (existsSync(versionDir))
199
+ rmSync(versionDir, { recursive: true, force: true });
200
+ mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
201
+ for (const f of files) {
202
+ const target = join(tmpDir, ...f.path.split("/"));
203
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
204
+ writeFsync(target, f.content);
205
+ }
206
+ fsyncTreeBestEffort(tmpDir);
207
+ mkdirSync(dirname(versionDir), { recursive: true, mode: 0o700 });
208
+ renameSync(tmpDir, versionDir);
209
+ staged = true;
210
+ fsyncDirBestEffort(dirname(versionDir));
211
+ const now = new Date().toISOString();
212
+ const url = `${EVAL_STUB_URL_PREFIX}${slug}`;
213
+ const row = {
214
+ slug,
215
+ url,
216
+ version,
217
+ ...(req.title !== undefined ? { title: req.title } : existing?.title !== undefined ? { title: existing.title } : {}),
218
+ ...(req.description !== undefined ? { description: req.description } : existing?.description !== undefined ? { description: existing.description } : {}),
219
+ ...(req.favicon !== undefined ? { favicon: req.favicon } : existing?.favicon !== undefined ? { favicon: existing.favicon } : {}),
220
+ ...(req.label !== undefined ? { label: req.label } : {}),
221
+ files: files.map((f) => ({ path: f.path, ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}), bytes: f.content.byteLength })),
222
+ createdAt: existing?.createdAt ?? now,
223
+ updatedAt: now,
224
+ };
225
+ const ref = { url, slug, version: String(version), ...(row.title !== undefined ? { title: row.title } : {}) };
226
+ this.#commitLedger({ v: 1, artifacts: { ...ledger.artifacts, [slug]: row }, attempts: this.#withAttempt(ledger, ctx.attemptId, { ref }) });
227
+ mint.add(slug);
228
+ return { ok: true, ref };
229
+ }
230
+ catch (err) {
231
+ for (const orphan of [tmpDir, ...(staged ? [versionDir] : [])]) {
232
+ try {
233
+ rmSync(orphan, { recursive: true, force: true });
234
+ }
235
+ catch {
236
+ }
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+ finally {
242
+ lease.release();
243
+ }
244
+ }
245
+ #decide(ledger, req, ctx) {
246
+ const refuse = (code, message, slug) => ({ ok: false, outcome: { ok: false, code, message }, ...(slug !== undefined ? { slug } : {}) });
247
+ if (req.removeFiles !== undefined && req.removeFiles.length > 0)
248
+ return refuse("refused", "the local stub does not apply removeFiles");
249
+ if (req.files.length === 0)
250
+ return refuse("refused", "a publish needs at least one file");
251
+ const seen = new Set();
252
+ const files = [];
253
+ let total = 0;
254
+ for (const f of req.files) {
255
+ const norm = normalizePublishedPath(f.path);
256
+ if (!norm.ok)
257
+ return refuse("refused", `published path ${JSON.stringify(f.path).slice(0, 120)} ${norm.reason}`);
258
+ const fold = norm.path.toLowerCase();
259
+ if (seen.has(fold))
260
+ return refuse("refused", `published path ${JSON.stringify(norm.path).slice(0, 120)} collides with another entry`);
261
+ seen.add(fold);
262
+ total += f.content.byteLength;
263
+ if (f.content.byteLength > LOCAL_STUB_MAX_FILE_BYTES)
264
+ return refuse("payload_too_large", `${norm.path.slice(0, 120)} is ${f.content.byteLength} bytes; the limit is ${LOCAL_STUB_MAX_FILE_BYTES} bytes per file`);
265
+ files.push({ path: norm.path, content: f.content, ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}) });
266
+ }
267
+ if (total > LOCAL_STUB_MAX_TOTAL_BYTES)
268
+ return refuse("payload_too_large", `the files total ${total} bytes; the limit is ${LOCAL_STUB_MAX_TOTAL_BYTES} bytes per version`);
269
+ const mint = this.#mint.get(ctx.runId) ?? new Set();
270
+ this.#mint.set(ctx.runId, mint);
271
+ if (req.url !== undefined) {
272
+ if (!req.url.startsWith(EVAL_STUB_URL_PREFIX))
273
+ return refuse("url_foreign", "not an eval-stub:// link this run published");
274
+ const slug = req.url.slice(EVAL_STUB_URL_PREFIX.length);
275
+ if (!mint.has(slug))
276
+ return refuse("url_foreign", "this run did not publish that artifact", slug);
277
+ const existing = ledger.artifacts[slug];
278
+ if (existing === undefined)
279
+ return refuse("not_found", "the artifact is no longer in the ledger", slug);
280
+ if (req.baseVersion !== undefined && req.baseVersion !== String(existing.version) && req.force !== true) {
281
+ return { ok: false, outcome: { ok: false, code: "conflict", currentVersion: String(existing.version), current: this.#snapshotOf(existing) }, slug };
282
+ }
283
+ return { ok: true, slug, version: existing.version + 1, existing, files, mint };
284
+ }
285
+ return { ok: true, slug: this.#mintSlug(ledger, req.slug, files[0].path), version: 1, existing: undefined, files, mint };
286
+ }
287
+ rows() {
288
+ return Object.values(this.#loadLedger().artifacts).map((r) => ({ url: r.url, slug: r.slug, version: String(r.version), ...(r.title !== undefined ? { title: r.title } : {}) }));
289
+ }
290
+ versionFiles(slug, version) {
291
+ const dir = this.#versionDir(slug, version);
292
+ return existsSync(dir) ? readdirSync(dir, { recursive: true }).map(String).sort() : [];
293
+ }
294
+ }
295
+ export function localArtifactStub(opts) {
296
+ return new LocalArtifactStub(opts);
297
+ }
@@ -209,9 +209,10 @@ export interface BashReadonlyRootBoundary {
209
209
  /** Base for resolving relative operands. Defaults to `roots[0]`. Callers tracking an observable `cd`
210
210
  * should pass the LIVE working directory, or a `..` operand will be judged against the wrong base. */
211
211
  cwd?: string;
212
- /** Expansion for a leading `~`. Without it a `~`-rooted operand is unresolvable and demotes (with no
213
- * out-of-root signal the classifier does not know where it points, which is a different verdict
214
- * from knowing it points outside). */
212
+ /** Expansion for a leading `~` and the value the read faces substitute for the `$HOME` / `${HOME}` word
213
+ * spellings before segmenting ({@link foldHomeVariableSpelling}). Without it a `~`-rooted operand is unresolvable
214
+ * and demotes (with no out-of-root signal — the classifier does not know where it points, which is a
215
+ * different verdict from knowing it points outside). */
215
216
  homeDir?: string;
216
217
  /** The sensitive-path read deny judge over LEXICALLY RESOLVED operands: a hit
217
218
  * demotes the command (ask, never auto-allow), independently of the roots — in-root operands are
@@ -248,11 +249,13 @@ export interface BashReadonlyRootBoundary {
248
249
  export interface CompoundReadonlyVerdict {
249
250
  /** Demotion reason (the same string {@link classifyCompoundReadonly} returns), or undefined. */
250
251
  reason?: string;
251
- /** Set only when the SOLE obstacle to the read-only verdict was the root boundary: every segment is
252
- * an allowlisted reader, no blocking/device/backgrounding arm fired, and the offending arguments are
253
- * statically resolved paths outside the roots. A gate can therefore offer the narrow "allow reading
254
- * from <dir>" approval instead of a generic shell prompt. Absent for every other demotion an
255
- * unresolvable path deliberately does NOT carry it (nothing concrete to grant). */
252
+ /** Set when a listed reader's statically resolved path operand lies outside the roots. With `reason` the
253
+ * containment sentence itself, the roots were the SOLE obstacle (every segment a listed reader, no
254
+ * blocking/device/guarded-form arm fired, every operand resolved) and the narrow "allow reading from <dir>"
255
+ * approval would make the command auto-allowable; with `reason` a SHAPE refusal (follow mode, a device, a
256
+ * blocking stdin read, a guarded verb form, multi-cd) or an UNRESOLVABLE-operand refusal (another operand
257
+ * the walk could not read) the finding rides beside the sentence — the read of that path is the same read,
258
+ * so the boundary's objection is stated either way, while the command stays refused after any grant. */
256
259
  outOfRootRead?: true;
257
260
  /** The resolved out-of-root paths, de-duplicated, in first-seen order. Present iff `outOfRootRead`. */
258
261
  outOfRootPaths?: readonly string[];
@@ -312,6 +315,24 @@ export interface CompoundReadonlyVerdict {
312
315
  * and miss the other.
313
316
  */
314
317
  undecidedPaths?: readonly string[];
318
+ /**
319
+ * Operands (written spellings) the walk could NOT resolve at all — a `~user` form, a `~` under no declared
320
+ * home, an argument the shell rewrites (brace/variable/command expansion), a `cd` target it cannot follow
321
+ * (`cd -`, bare `cd`, a pattern). The STRUCTURAL form of the unresolvable sentence: `reason` carries the
322
+ * first one's sentence, this member every one of them, and both survive whatever other sentence (a shape
323
+ * refusal) takes precedence. CONSUMER CONTRACT: non-empty ⇒ the boundary could not read where the command
324
+ * reads — a fail-closed ask, never a vouch (plain on the classify seat, whose classifier asks beside it; MANDATED on the
325
+ * boundary-only seat, where nothing else would ask).
326
+ */
327
+ unresolvedOperands?: readonly string[];
328
+ /**
329
+ * The grammar gate refused the WHOLE command before the walk ran (an escape, a substitution, a subshell, a
330
+ * redirection, a line break — see {@link rejectedSansRedirection} and the compound gate): no operand was judged,
331
+ * and what the shell would run is not knowable from the text (a reader can be spelled `ca\t`, fed by `<`, or hidden
332
+ * in `$(…)`). CONSUMER CONTRACT: present ⇒ the boundary judged nothing — MANDATED on both probe seats (a name-reading
333
+ * arm or a stored allow rule must not retire a read nobody judged). Structure, never the sentence.
334
+ */
335
+ refusedWhole?: true;
315
336
  /**
316
337
  * Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
317
338
  * {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
@@ -343,6 +364,58 @@ export interface CompoundReadonlyVerdict {
343
364
  * uses the host's native separator.
344
365
  */
345
366
  export declare function formatOutOfRootReadApprovalOption(directory: string): string;
367
+ /** Resolve one operand against the boundary's base, WITHOUT touching the filesystem. Returns undefined
368
+ * when the token cannot be resolved statically — the fail-closed direction, reported as its own
369
+ * demotion rather than silently treated as in-root.
370
+ *
371
+ * The resolution is TEXTUAL, and its limitation runs in BOTH directions (only the first shows up as
372
+ * a refusal, so only that half tends to get reported):
373
+ * · false REFUSAL — a non-canonical spelling of an in-root path (a symlinked `/tmp` → `/private/tmp`
374
+ * prefix) does not textually match the canonical roots and is refused;
375
+ * · false ALLOW — a symlink INSIDE a root that points outside it (`<root>/vendor` → `/etc`) resolves
376
+ * to a textual prefix match, so `cat vendor/passwd` is judged in-root and the real command follows
377
+ * the link. This face is therefore lexical containment, NOT filesystem containment: it is weaker
378
+ * than the structured Read/Grep tools, which canonicalize through `realpath` before comparing
379
+ * (see safety.ts's `canonicalizeAndEnforceRoot`). Closing it means resolving each candidate against
380
+ * the live filesystem, which this function deliberately cannot do (it is synchronous, pure, and
381
+ * shared with the approval-gate face); the seam for it is the ENFORCING caller, which holds the
382
+ * ExecutionEnv.
383
+ *
384
+ * The verdict now carries {@link CompoundReadonlyVerdict.checkedPaths} (the
385
+ * candidates that landed inside), and `createBashReadonlyTool` canonicalizes them on a LOCAL env
386
+ * before running — closing BOTH directions above for that face (an escaping symlink is refused; a
387
+ * non-canonical spelling that resolves back inside is no longer refused). Two residuals stay here by
388
+ * construction, and belong to the caller rather than to this function: the compound/approval-gate face
389
+ * has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
390
+ * `canonicalPath` is an RPC per candidate). */
391
+ export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
392
+ /** Whether a command the grammar gate REFUSED WHOLE carries evidence that it may READ: a listed reader or a shell
393
+ * re-entry program anywhere in it, a substitution where a program name stands (`$C /etc/passwd`), or a stdin
394
+ * redirection from a non-literal target (`cat < $F`). The out-of-root and base-mover readings over its literal path
395
+ * tokens are the containment gate's, asked separately. A refused command with none of these — a write through a
396
+ * redirection (`echo hi > out.txt`), a function definition — names no read for the boundary to mandate: the syntax
397
+ * refusal stays the classify seat's own plain ask. */
398
+ export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>): boolean;
399
+ /**
400
+ * The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
401
+ * command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
402
+ * double-quoted, followed by `/` or by the word's end. Those are exactly the words whose expansion is the home
403
+ * directory and nothing else, so the word is rewritten to what bash hands the program — the value itself, in
404
+ * place, with the word's quote structure untouched (`"$HOME/x"` → `"/home/u/x"`, `$HOME"/x"` → `/home/u"/x"`)
405
+ * — and every arm reads an ordinary absolute path; no tilde is minted, so the tilde-prefix rule of
406
+ * {@link tokenizeSegment} judges only tildes the command actually wrote. Substituted only against a DECLARED
407
+ * home (`homeDir`): with none declared the child's `$HOME` may differ from what `~` falls back to, so the
408
+ * spelling is left as written and meets the substitution hard-reject. The value must survive this file's own
409
+ * reading of the rewritten word: the UNQUOTED spelling only for a value with no `$IFS` whitespace, glob or
410
+ * shell metacharacter (word splitting / pathname expansion / a connector this file would then split on — none
411
+ * of which a real expansion is subject to here), the double-quoted one for any value carrying no `"`, `$`,
412
+ * backtick, backslash or line break (the characters that would change the quote structure or meet the
413
+ * hard-reject). Everything else is left as written: a single-quoted spelling does not expand, and a mid-word
414
+ * or suffixed one (`x$HOME`, `$HOME.bak`, `$HOMES`) still meets the substitution hard-reject. Bails whole on
415
+ * an escape or a backtick in the command (the hard-reject refuses those strings regardless, and the quote
416
+ * walk below is exact only without them) and on unbalanced quoting.
417
+ */
418
+ export declare function foldHomeVariableSpelling(command: string, homeDir: string | undefined): string;
346
419
  /**
347
420
  * {@link classifyCompoundReadonly} with the structured verdict. The name/operator/blocking arms
348
421
  * run FIRST and unchanged, so an existing demotion keeps its exact reason and never acquires the
@@ -568,3 +641,12 @@ export declare const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
568
641
  * ({@link NON_ASCII_WHITESPACE}), so the grammar's tokenization and bash's stay identical.
569
642
  */
570
643
  export declare function classifyBoundedReadonlyPollLoop(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
644
+ /**
645
+ * The poll-loop face's STRUCTURED verdict — the same {@link CompoundReadonlyVerdict} the compound face
646
+ * returns, so a consumer reads the boundary's structural findings (deny row, containment, recursive or
647
+ * undecided operands) off the loop body exactly as off a plain compound, beside whatever sentence the
648
+ * loop grammar or the body's shape produced. {@link classifyBoundedReadonlyPollLoop} is its `reason`
649
+ * projection. A refusal of the LOOP GRAMMAR itself (not a bounded loop, an unlisted body program, an
650
+ * unvetted sleep) carries no structure: no reader segment was walked.
651
+ */
652
+ export declare function classifyBoundedReadonlyPollLoopDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;