litclaude-ai 0.4.3 → 0.4.4

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 (28) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +87 -5
  3. package/README_ko-KR.md +87 -5
  4. package/RELEASE_CHECKLIST.md +7 -7
  5. package/bin/litclaude-ai.js +35 -11
  6. package/bin/litfamily-banner.mjs +32 -0
  7. package/package.json +1 -1
  8. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +14 -14
  11. package/plugins/litclaude/lib/owner-lock.mjs +78 -17
  12. package/plugins/litclaude/lib/rules/engine.mjs +2 -2
  13. package/plugins/litclaude/lib/rules/session-state.mjs +2 -2
  14. package/plugins/litclaude/lib/secure-path-read.mjs +18 -0
  15. package/plugins/litclaude/lib/skill-loop/common.mjs +6 -5
  16. package/plugins/litclaude/lib/skill-loop/curator.mjs +36 -2
  17. package/plugins/litclaude/lib/skill-loop/mutation-transaction.mjs +29 -5
  18. package/plugins/litclaude/lib/skill-loop/proposal-store.mjs +36 -4
  19. package/plugins/litclaude/lib/skill-loop/review.mjs +4 -3
  20. package/plugins/litclaude/lib/skill-observer.mjs +127 -35
  21. package/plugins/litclaude/lib/start-work-lifecycle.mjs +2 -2
  22. package/plugins/litclaude/lib/wikify-knowledge.mjs +44 -16
  23. package/plugins/litclaude/scripts/scaffold-plan.mjs +259 -41
  24. package/plugins/litclaude/skills/browser-drive/scripts/capability-probe.mjs +24 -3
  25. package/plugins/litclaude/skills/teammode/scripts/team.mjs +2 -2
  26. package/scripts/qa-claude-plugin-smoke.sh +36 -8
  27. package/scripts/qa-portable-install.sh +38 -15
  28. package/scripts/qa-real-surface-lib.mjs +4 -1
@@ -14,6 +14,13 @@ import {
14
14
  } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
 
17
+ import {
18
+ directoryIdentity,
19
+ pathIdentity,
20
+ sameDirectoryIdentity,
21
+ samePathIdentity,
22
+ } from "./secure-path-read.mjs";
23
+
17
24
  const MAX_OWNER_BYTES = 4096;
18
25
  const sleepSync = (milliseconds) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
19
26
  const monotonicNowMs = () => Number(process.hrtime.bigint()) / 1_000_000;
@@ -28,7 +35,7 @@ export class OwnerLockError extends Error {
28
35
 
29
36
  const ownerPathFor = (lockDir) => join(lockDir, "owner.json");
30
37
  const safeReceiptToken = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/u.test(value);
31
- const sameDirectory = (left, right) => left?.dev === right?.dev && left?.ino === right?.ino;
38
+ const sameDirectory = sameDirectoryIdentity;
32
39
 
33
40
  const assertLockDirectory = (lockDir, expected) => {
34
41
  const details = lstatSync(lockDir, { throwIfNoEntry: false });
@@ -36,13 +43,52 @@ const assertLockDirectory = (lockDir, expected) => {
36
43
  if (details.isSymbolicLink() || !details.isDirectory()) {
37
44
  throw new OwnerLockError("owner lock path must be a regular directory", 65);
38
45
  }
39
- const identity = { dev: details.dev, ino: details.ino };
46
+ const identity = directoryIdentity(details);
40
47
  if (expected && !sameDirectory(identity, expected)) {
41
48
  throw new OwnerLockError("owner lock directory changed", 65);
42
49
  }
43
50
  return identity;
44
51
  };
45
52
 
53
+ // Destructive cleanup is commit-by-rename: once the checked path is moved to a private
54
+ // quarantine, a pathname replacement cannot make us remove the replacement. Re-checking the
55
+ // moved entry catches Linux inode reuse (ctime/birthtime differ even when dev+ino are forged).
56
+ const removeVerifiedPath = (path, expectedIdentity, expectedDirectory, quarantinePath, verify) => {
57
+ if (expectedDirectory) assertLockDirectory(dirname(path), expectedDirectory);
58
+ const current = lstatSync(path, { throwIfNoEntry: false });
59
+ const matches = (stat) => {
60
+ if (!stat) return false;
61
+ if (expectedIdentity?.ctimeMs === undefined) {
62
+ return stat.isDirectory() && !stat.isSymbolicLink()
63
+ && sameDirectoryIdentity(directoryIdentity(stat), expectedIdentity);
64
+ }
65
+ return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1
66
+ && samePathIdentity(pathIdentity(stat), expectedIdentity);
67
+ };
68
+ if (!current || !matches(current)) return false;
69
+ const quarantine = quarantinePath ?? `${path}.remove.${randomUUID()}`;
70
+ try {
71
+ renameSync(path, quarantine);
72
+ const moved = lstatSync(quarantine, { throwIfNoEntry: false });
73
+ const movedMatches = expectedIdentity?.ctimeMs === undefined
74
+ ? matches(moved)
75
+ : sameDirectoryIdentity(directoryIdentity(moved), {
76
+ dev: expectedIdentity.dev,
77
+ ino: expectedIdentity.ino,
78
+ birthtimeMs: expectedIdentity.birthtimeMs,
79
+ });
80
+ if (!moved || !movedMatches) throw new Error("identity changed");
81
+ if (verify && !verify(quarantine)) throw new Error("entry content changed");
82
+ rmSync(quarantine, { recursive: true, force: true });
83
+ return true;
84
+ } catch {
85
+ try {
86
+ if (!lstatSync(path, { throwIfNoEntry: false })) renameSync(quarantine, path);
87
+ } catch { /* Leave uncertain quarantine state for the caller to inspect. */ }
88
+ return false;
89
+ }
90
+ };
91
+
46
92
  const processIsAlive = (pid) => {
47
93
  if (!Number.isSafeInteger(pid) || pid <= 0) return false;
48
94
  try {
@@ -64,8 +110,7 @@ export const readOwnerLock = (lockDir) => {
64
110
  const openedStats = fstatSync(fd);
65
111
  if (
66
112
  !openedStats.isFile()
67
- || openedStats.dev !== pathStats.dev
68
- || openedStats.ino !== pathStats.ino
113
+ || !samePathIdentity(pathIdentity(openedStats), pathIdentity(pathStats))
69
114
  || openedStats.size !== pathStats.size
70
115
  ) return null;
71
116
  const owner = JSON.parse(readFileSync(fd, "utf8"));
@@ -96,8 +141,7 @@ const readTakeoverClaim = (lockDir, name, ownerNonce) => {
96
141
  const openedStats = fstatSync(fd);
97
142
  if (
98
143
  !openedStats.isFile()
99
- || openedStats.dev !== pathStats.dev
100
- || openedStats.ino !== pathStats.ino
144
+ || !samePathIdentity(pathIdentity(openedStats), pathIdentity(pathStats))
101
145
  || openedStats.size !== pathStats.size
102
146
  ) return null;
103
147
  const text = readFileSync(fd, "utf8");
@@ -114,7 +158,7 @@ const readTakeoverClaim = (lockDir, name, ownerNonce) => {
114
158
  && claim.pid > 0
115
159
  && Number.isFinite(claim.acquired_at_ms)
116
160
  && claim.acquired_at_ms >= 0
117
- ) return claim;
161
+ ) return { claim, identity: pathIdentity(pathStats), text };
118
162
 
119
163
  const legacyPidText = text.trim();
120
164
  const legacyPid = Number(legacyPidText);
@@ -127,10 +171,14 @@ const readTakeoverClaim = (lockDir, name, ownerNonce) => {
127
171
  || pathStats.mtimeMs < 0
128
172
  ) return null;
129
173
  return {
130
- owner_nonce: ownerNonce,
131
- claimant_nonce: `legacy-${legacyPid}`,
132
- pid: legacyPid,
133
- acquired_at_ms: pathStats.mtimeMs,
174
+ claim: {
175
+ owner_nonce: ownerNonce,
176
+ claimant_nonce: `legacy-${legacyPid}`,
177
+ pid: legacyPid,
178
+ acquired_at_ms: pathStats.mtimeMs,
179
+ },
180
+ identity: pathIdentity(pathStats),
181
+ text,
134
182
  };
135
183
  } catch {
136
184
  return null;
@@ -155,14 +203,22 @@ const takeoverWinner = (lockDir, incumbent, ownClaim, staleAfter, expectedLockDi
155
203
  const active = [];
156
204
  for (const name of names) {
157
205
  assertLockDirectory(lockDir, expectedLockDirectory);
158
- const claim = readTakeoverClaim(lockDir, name, incumbent.nonce);
159
- if (claim === null) return null;
206
+ const claimRecord = readTakeoverClaim(lockDir, name, incumbent.nonce);
207
+ if (claimRecord === null) return null;
208
+ const { claim, identity, text } = claimRecord;
160
209
  const alive = processIsAlive(claim.pid);
161
210
  const oldEnough = Date.now() - claim.acquired_at_ms >= staleAfter;
162
211
  if (!alive && oldEnough) {
163
212
  try {
164
213
  assertLockDirectory(lockDir, expectedLockDirectory);
165
- rmSync(join(lockDir, name));
214
+ const claimPath = join(lockDir, name);
215
+ if (!removeVerifiedPath(
216
+ claimPath,
217
+ identity,
218
+ expectedLockDirectory,
219
+ undefined,
220
+ (quarantine) => readFileSync(quarantine, "utf8") === text,
221
+ )) return null;
166
222
  } catch (error) {
167
223
  if (error?.code !== "ENOENT") return null;
168
224
  }
@@ -199,7 +255,7 @@ export const acquireOwnerLock = (lockDir, { timeoutMs = 500, staleMs = 30_000 }
199
255
  } catch {
200
256
  try {
201
257
  assertLockDirectory(lockDir, lockDirectory);
202
- rmSync(lockDir, { recursive: true, force: true });
258
+ if (!removeVerifiedPath(lockDir, lockDirectory)) throw new Error("lock directory changed");
203
259
  } catch { /* Leave uncertain state fail-closed. */ }
204
260
  throw new OwnerLockError("lock ownership receipt failed", 65);
205
261
  }
@@ -237,8 +293,13 @@ export const acquireOwnerLock = (lockDir, { timeoutMs = 500, staleMs = 30_000 }
237
293
  const current = readOwnerLock(lockDir);
238
294
  if (current?.nonce === incumbent.nonce && current.pid === incumbent.pid) {
239
295
  assertLockDirectory(lockDir, lockDirectory);
240
- renameSync(lockDir, displaced);
241
- rmSync(displaced, { recursive: true, force: true });
296
+ if (!removeVerifiedPath(lockDir, lockDirectory, undefined, displaced,
297
+ (quarantine) => {
298
+ const replacementOwner = readOwnerLock(quarantine);
299
+ return replacementOwner?.nonce === incumbent.nonce && replacementOwner.pid === incumbent.pid;
300
+ })) {
301
+ throw new Error("stale lock directory changed");
302
+ }
242
303
  continue;
243
304
  }
244
305
  }
@@ -27,6 +27,7 @@ import { parseRuleFile } from "./frontmatter.mjs";
27
27
  import { isStaticRule, matchRule } from "./matcher.mjs";
28
28
  import { sortCandidates } from "./ordering.mjs";
29
29
  import { claimInjectedKeys, ruleKey } from "./session-state.mjs";
30
+ import { pathIdentity, samePathIdentity } from "../secure-path-read.mjs";
30
31
 
31
32
  const MAX_RULE_FILE_BYTES = 256 * 1024;
32
33
 
@@ -49,8 +50,7 @@ const readCandidateBounded = (candidate) => {
49
50
  const openedStats = fstatSync(fd);
50
51
  if (
51
52
  !openedStats.isFile()
52
- || openedStats.dev !== pathStats.dev
53
- || openedStats.ino !== pathStats.ino
53
+ || !samePathIdentity(pathIdentity(openedStats), pathIdentity(pathStats))
54
54
  || openedStats.size !== pathStats.size
55
55
  ) return null;
56
56
 
@@ -22,6 +22,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
22
22
 
23
23
  import { acquireOwnerLock, OwnerLockError, releaseOwnerLock } from "../owner-lock.mjs";
24
24
  import { POST_COMPACT_REINJECTION_BUDGET } from "./constants.mjs";
25
+ import { pathIdentity, samePathIdentity } from "../secure-path-read.mjs";
25
26
 
26
27
  const MAX_TRACKED_KEYS = 500;
27
28
  const MAX_STATE_BYTES = 256 * 1024;
@@ -83,8 +84,7 @@ const readStateResult = (projectRoot, id) => {
83
84
  const openedStats = fstatSync(fd);
84
85
  if (
85
86
  !openedStats.isFile()
86
- || openedStats.dev !== pathStats.dev
87
- || openedStats.ino !== pathStats.ino
87
+ || !samePathIdentity(pathIdentity(openedStats), pathIdentity(pathStats))
88
88
  || openedStats.size !== pathStats.size
89
89
  ) return { status: "uncertain" };
90
90
  const parsed = JSON.parse(readFileSync(fd, "utf8"));
@@ -14,6 +14,24 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  export const MAX_BOUNDED_FILE_BYTES = 8 * 1024 * 1024;
15
15
  export const MAX_BOUNDED_AGGREGATE_BYTES = 32 * 1024 * 1024;
16
16
 
17
+ export const pathIdentity = (stat) => ({
18
+ dev: stat?.dev,
19
+ ino: stat?.ino,
20
+ ctimeMs: stat?.ctimeMs,
21
+ birthtimeMs: stat?.birthtimeMs,
22
+ });
23
+ export const samePathIdentity = (left, right) => Boolean(left && right)
24
+ && ["dev", "ino", "ctimeMs", "birthtimeMs"].every((field) => Number.isFinite(left[field])
25
+ && Number.isFinite(right[field]) && left[field] === right[field]);
26
+ export const directoryIdentity = (stat) => ({
27
+ dev: stat?.dev,
28
+ ino: stat?.ino,
29
+ birthtimeMs: stat?.birthtimeMs,
30
+ });
31
+ export const sameDirectoryIdentity = (left, right) => Boolean(left && right)
32
+ && ["dev", "ino", "birthtimeMs"].every((field) => Number.isFinite(left[field])
33
+ && Number.isFinite(right[field]) && left[field] === right[field]);
34
+
17
35
  const identity = (stat) => ({
18
36
  dev: stat.dev,
19
37
  ino: stat.ino,
@@ -18,6 +18,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
18
18
  import { tmpdir } from "node:os";
19
19
 
20
20
  import { acquireOwnerLock, releaseOwnerLock } from "../owner-lock.mjs";
21
+ import { directoryIdentity, pathIdentity, sameDirectoryIdentity, samePathIdentity } from "../secure-path-read.mjs";
21
22
  import { containsSecret } from "../secret-shapes.mjs";
22
23
  import { decodeStrictUtf8, parseStrictJson } from "../strict-json.mjs";
23
24
 
@@ -147,14 +148,14 @@ export function captureDirectoryIdentity(path, ErrorType, code, { allowMissing =
147
148
  if (!details.isDirectory() || details.isSymbolicLink()) {
148
149
  throw new ErrorType(code, "expected path is not a real directory");
149
150
  }
150
- return { dev: details.dev, ino: details.ino };
151
+ return directoryIdentity(details);
151
152
  }
152
153
 
153
154
  export function directoryIdentityMatches(path, expected) {
154
155
  const details = lstatSync(path, { throwIfNoEntry: false });
155
156
  if (expected === null) return !details;
156
157
  return Boolean(details?.isDirectory() && !details.isSymbolicLink()
157
- && details.dev === expected.dev && details.ino === expected.ino);
158
+ && sameDirectoryIdentity(directoryIdentity(details), expected));
158
159
  }
159
160
 
160
161
  export function assertRegularSingleLink(path, ErrorType, code, { maxBytes } = {}) {
@@ -167,14 +168,14 @@ export function assertRegularSingleLink(path, ErrorType, code, { maxBytes } = {}
167
168
  try {
168
169
  descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
169
170
  const opened = fstatSync(descriptor);
170
- if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino
171
+ if (!opened.isFile() || opened.nlink !== 1 || !samePathIdentity(pathIdentity(opened), pathIdentity(named))
171
172
  || opened.size !== named.size || opened.mtimeMs !== named.mtimeMs) {
172
173
  throw new ErrorType(code, "record identity changed while opening");
173
174
  }
174
175
  const bytes = readFileSync(descriptor);
175
176
  const after = fstatSync(descriptor);
176
- if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
177
- || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) {
177
+ if (!samePathIdentity(pathIdentity(after), pathIdentity(opened)) || after.size !== opened.size
178
+ || after.mtimeMs !== opened.mtimeMs) {
178
179
  throw new ErrorType(code, "record identity changed while reading");
179
180
  }
180
181
  return bytes;
@@ -203,7 +203,24 @@ function removeEmptyOwnedDirectory(path, identity) {
203
203
  if (!directoryIdentityMatches(path, identity)) {
204
204
  throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
205
205
  }
206
- rmdirSync(path);
206
+ const quarantine = `${path}.remove.${randomUUID()}`;
207
+ try {
208
+ renameSync(path, quarantine);
209
+ if (!directoryIdentityMatches(quarantine, identity)) throw new Error("directory identity changed");
210
+ let quarantined;
211
+ try {
212
+ quarantined = opendirSync(quarantine);
213
+ if (quarantined.readSync() !== null) throw new Error("directory gained an entry");
214
+ } finally {
215
+ quarantined?.closeSync();
216
+ }
217
+ rmdirSync(quarantine);
218
+ } catch {
219
+ try {
220
+ if (!existsSync(path) && existsSync(quarantine)) renameSync(quarantine, path);
221
+ } catch { /* Leave uncertain cleanup as a visible residue. */ }
222
+ throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
223
+ }
207
224
  }
208
225
 
209
226
  export function runCurator(root, {
@@ -462,7 +479,24 @@ export function runCurator(root, {
462
479
  if (!directoryIdentityMatches(record.path, record.identity)) {
463
480
  throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
464
481
  }
465
- rmSync(record.path, { recursive: true });
482
+ const quarantine = join(skills, `.${skill}.rollback-${randomUUID()}`);
483
+ try {
484
+ renameSync(record.path, quarantine);
485
+ if (!directoryIdentityMatches(quarantine, record.identity)) throw new Error("backup identity changed");
486
+ const quarantinedSnapshot = captureStagedSnapshot(root, quarantine, limits === null ? {} : {
487
+ workLimits: limits,
488
+ work: createSnapshotWorkBudget(),
489
+ });
490
+ if (!isDeepStrictEqual(quarantinedSnapshot, record.snapshot)) {
491
+ throw new Error("backup contents changed");
492
+ }
493
+ rmSync(quarantine, { recursive: true, force: true });
494
+ } catch {
495
+ try {
496
+ if (!existsSync(record.path) && existsSync(quarantine)) renameSync(quarantine, record.path);
497
+ } catch { /* Leave uncertain cleanup as a visible residue. */ }
498
+ throw new SkillLoopCuratorError("CURATOR_CLEANUP_UNSAFE");
499
+ }
466
500
  backupRecords.delete(skill);
467
501
  }
468
502
  removeEmptyOwnedDirectory(backupOwnership.stamp?.path, backupOwnership.stamp?.identity ?? null);
@@ -1,5 +1,6 @@
1
1
  import { lstatSync, renameSync, rmSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
3
4
 
4
5
  import { validateProposal } from "./proposal-record.mjs";
5
6
  import { readProposalRecord, transitionProposalRecordUnlocked } from "./proposal-store.mjs";
@@ -110,9 +111,10 @@ function journalPath(root, create) {
110
111
  }
111
112
 
112
113
  function validIdentity(identity) {
113
- return identity === null || (isPlainObject(identity) && requireExactKeys(identity, ["dev", "ino"])
114
+ return identity === null || (isPlainObject(identity) && requireExactKeys(identity, ["dev", "ino", "birthtimeMs"])
114
115
  && Number.isSafeInteger(identity.dev) && identity.dev >= 0
115
- && Number.isSafeInteger(identity.ino) && identity.ino > 0);
116
+ && Number.isSafeInteger(identity.ino) && identity.ino > 0
117
+ && Number.isFinite(identity.birthtimeMs) && identity.birthtimeMs >= 0);
116
118
  }
117
119
 
118
120
  function validTemporaryName(name, skill, kind, suffix) {
@@ -304,7 +306,28 @@ function assertExactDirectory(root, path, identity, snapshot, targetRoot, skill,
304
306
 
305
307
  function removeExactDirectory(root, path, identity, snapshot, targetRoot, skill, snapshotOptions) {
306
308
  assertExactDirectory(root, path, identity, snapshot, targetRoot, skill, snapshotOptions);
307
- rmSync(path, { recursive: true });
309
+ const skills = ensureSafeDirectoryChain(root, [".claude", "skills"], {
310
+ create: false,
311
+ ErrorType: SkillLoopMutationTransactionError,
312
+ code: "MUTATION_TRANSACTION_UNSAFE",
313
+ });
314
+ if (skills === null || dirname(path) !== skills) fail("MUTATION_TRANSACTION_AMBIGUOUS");
315
+ const quarantine = join(skills, `.${skill}.rollback-${randomUUID()}`);
316
+ try {
317
+ renameSync(path, quarantine);
318
+ if (!directoryIdentityMatches(quarantine, identity)) throw new Error("directory identity changed");
319
+ const quarantinedSnapshot = captureStagedSnapshot(root, quarantine, snapshotOptions);
320
+ if (!snapshotsMatch(quarantinedSnapshot, snapshot)) throw new Error("directory contents changed");
321
+ rmSync(quarantine, { recursive: true, force: true });
322
+ } catch (error) {
323
+ try {
324
+ if (!lstatSync(path, { throwIfNoEntry: false }) && lstatSync(quarantine, { throwIfNoEntry: false })) {
325
+ renameSync(quarantine, path);
326
+ }
327
+ } catch { /* Leave uncertain cleanup as a visible residue. */ }
328
+ if (error instanceof SkillLoopMutationTransactionError) throw error;
329
+ fail("MUTATION_TRANSACTION_AMBIGUOUS");
330
+ }
308
331
  }
309
332
 
310
333
  function ensureLedgerCommitted(root, journal) {
@@ -497,7 +520,8 @@ export function recoverSkillMutationTransactionUnlocked(root, {
497
520
  if (stageDetails && stage !== null) removeExactDirectory(root, stage, journal.stageIdentity, journal.after,
498
521
  journal.targetRoot, journal.targetSkill, snapshotOptions);
499
522
  renameSync(displaced, target);
500
- if (!directoryIdentityMatches(target, journal.beforeIdentity)) fail("MUTATION_TRANSACTION_AMBIGUOUS");
523
+ assertExactDirectory(root, target, journal.beforeIdentity, journal.before,
524
+ journal.targetRoot, journal.targetSkill, snapshotOptions);
501
525
  clearJournalUnlocked(root);
502
526
  return { status: "rolled-back", kind: journal.kind, requestId: journal.requestId };
503
527
  }
@@ -1,4 +1,5 @@
1
- import { lstatSync, opendirSync, unlinkSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstatSync, opendirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
 
4
5
  import {
@@ -7,10 +8,12 @@ import {
7
8
  ensureSafeDirectoryChain,
8
9
  PROPOSAL_MAX_BYTES,
9
10
  readStrictJsonFile,
11
+ prettyJsonBytes,
10
12
  RECORD_ID,
11
13
  skillRootsEquivalent,
12
14
  withSkillLoopLock,
13
15
  } from "./common.mjs";
16
+ import { directoryIdentity, pathIdentity, sameDirectoryIdentity, samePathIdentity } from "../secure-path-read.mjs";
14
17
  import { SkillLoopProposalError, validateProposal } from "./proposal-record.mjs";
15
18
  import { recoverReviewTransactionUnlocked } from "./review-transaction.mjs";
16
19
  import { recoverSkillMutationTransactionUnlocked } from "./mutation-transaction.mjs";
@@ -96,7 +99,13 @@ export function queueProposalRecordUnlocked(root, proposal) {
96
99
  if (!written?.isFile() || written.isSymbolicLink() || written.nlink !== 1) {
97
100
  throw new SkillLoopProposalError("PROPOSAL_STORE_UNSAFE");
98
101
  }
99
- return { proposal: persistedProposal, path, identity: { dev: written.dev, ino: written.ino } };
102
+ const bytes = prettyJsonBytes(persistedProposal);
103
+ return {
104
+ proposal: persistedProposal,
105
+ path,
106
+ identity: pathIdentity(written),
107
+ sha256: createHash("sha256").update(bytes).digest("hex"),
108
+ };
100
109
  }
101
110
 
102
111
  export function normalizeProposalTargetRoot(root, proposal) {
@@ -110,10 +119,33 @@ export function normalizeProposalTargetRoot(root, proposal) {
110
119
  export function removeQueuedProposalRecordUnlocked(record) {
111
120
  const current = lstatSync(record.path, { throwIfNoEntry: false });
112
121
  if (!current?.isFile() || current.isSymbolicLink() || current.nlink !== 1
113
- || current.dev !== record.identity.dev || current.ino !== record.identity.ino) {
122
+ || !samePathIdentity(pathIdentity(current), record.identity)) {
123
+ throw new SkillLoopProposalError("PROPOSAL_ROLLBACK_UNSAFE");
124
+ }
125
+ const quarantine = `${record.path}.remove.${randomUUID()}`;
126
+ try {
127
+ renameSync(record.path, quarantine);
128
+ const moved = lstatSync(quarantine, { throwIfNoEntry: false });
129
+ if (!moved?.isFile() || moved.isSymbolicLink() || moved.nlink !== 1
130
+ || !sameDirectoryIdentity(directoryIdentity(moved), {
131
+ dev: record.identity.dev,
132
+ ino: record.identity.ino,
133
+ birthtimeMs: record.identity.birthtimeMs,
134
+ })) {
135
+ throw new Error("proposal identity changed");
136
+ }
137
+ if (record.sha256
138
+ && createHash("sha256").update(readFileSync(quarantine)).digest("hex") !== record.sha256) {
139
+ throw new Error("proposal contents changed");
140
+ }
141
+ rmSync(quarantine, { force: true });
142
+ } catch {
143
+ try {
144
+ if (!lstatSync(record.path, { throwIfNoEntry: false })
145
+ && lstatSync(quarantine, { throwIfNoEntry: false })) renameSync(quarantine, record.path);
146
+ } catch { /* Leave uncertain cleanup as a visible residue. */ }
114
147
  throw new SkillLoopProposalError("PROPOSAL_ROLLBACK_UNSAFE");
115
148
  }
116
- unlinkSync(record.path);
117
149
  }
118
150
 
119
151
  function proposalEntriesBounded(root) {
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { canonicalSkillIds } from "../canonical-skill-catalog.mjs";
6
6
  import { containsSecret } from "../secret-shapes.mjs";
7
+ import { pathIdentity, samePathIdentity } from "../secure-path-read.mjs";
7
8
  import { decodeStrictUtf8, parseStrictJson } from "../strict-json.mjs";
8
9
  import { resolveAgentTarget } from "./authority.mjs";
9
10
  import { skillRootFor } from "./common.mjs";
@@ -70,14 +71,14 @@ function readStableTranscript(path) {
70
71
  try {
71
72
  descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
72
73
  const opened = fstatSync(descriptor);
73
- if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino
74
+ if (!opened.isFile() || opened.nlink !== 1 || !samePathIdentity(pathIdentity(opened), pathIdentity(named))
74
75
  || opened.size !== named.size || opened.mtimeMs !== named.mtimeMs) {
75
76
  throw new SkillLoopReviewError("TRANSCRIPT_UNSAFE");
76
77
  }
77
78
  const bytes = readFileSync(descriptor);
78
79
  const after = fstatSync(descriptor);
79
- if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
80
- || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) {
80
+ if (!samePathIdentity(pathIdentity(after), pathIdentity(opened)) || after.size !== opened.size
81
+ || after.mtimeMs !== opened.mtimeMs) {
81
82
  throw new SkillLoopReviewError("TRANSCRIPT_UNSAFE");
82
83
  }
83
84
  return bytes;