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.
- package/CHANGELOG.md +19 -0
- package/README.md +87 -5
- package/README_ko-KR.md +87 -5
- package/RELEASE_CHECKLIST.md +7 -7
- package/bin/litclaude-ai.js +35 -11
- package/bin/litfamily-banner.mjs +32 -0
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +14 -14
- package/plugins/litclaude/lib/owner-lock.mjs +78 -17
- package/plugins/litclaude/lib/rules/engine.mjs +2 -2
- package/plugins/litclaude/lib/rules/session-state.mjs +2 -2
- package/plugins/litclaude/lib/secure-path-read.mjs +18 -0
- package/plugins/litclaude/lib/skill-loop/common.mjs +6 -5
- package/plugins/litclaude/lib/skill-loop/curator.mjs +36 -2
- package/plugins/litclaude/lib/skill-loop/mutation-transaction.mjs +29 -5
- package/plugins/litclaude/lib/skill-loop/proposal-store.mjs +36 -4
- package/plugins/litclaude/lib/skill-loop/review.mjs +4 -3
- package/plugins/litclaude/lib/skill-observer.mjs +127 -35
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +2 -2
- package/plugins/litclaude/lib/wikify-knowledge.mjs +44 -16
- package/plugins/litclaude/scripts/scaffold-plan.mjs +259 -41
- package/plugins/litclaude/skills/browser-drive/scripts/capability-probe.mjs +24 -3
- package/plugins/litclaude/skills/teammode/scripts/team.mjs +2 -2
- package/scripts/qa-claude-plugin-smoke.sh +36 -8
- package/scripts/qa-portable-install.sh +38 -15
- 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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
159
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
177
|
-
|| after.mtimeMs !== opened.mtimeMs
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
80
|
-
|| after.mtimeMs !== opened.mtimeMs
|
|
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;
|