javi-forge 1.28.0 → 1.29.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/assets/claude-hooks/javi-forge-skillguard-pre-tool-use.mjs +828 -0
- package/assets/claude-hooks/manifest.json +1 -0
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +3 -0
- package/dist/commands/plugin.d.ts +3 -1
- package/dist/commands/plugin.js +16 -2
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.d.ts +112 -0
- package/dist/lib/__fixtures__/claude-hook-ownership.js +96 -0
- package/dist/lib/agent-skills.d.ts +8 -0
- package/dist/lib/agent-skills.js +28 -61
- package/dist/lib/claude-hook-manager.d.ts +88 -0
- package/dist/lib/claude-hook-manager.js +276 -0
- package/dist/lib/claude-hook-settings.d.ts +116 -0
- package/dist/lib/claude-hook-settings.js +283 -0
- package/dist/lib/plugin.d.ts +8 -0
- package/dist/lib/plugin.js +13 -40
- package/dist/lib/skill-install-gate.d.ts +44 -8
- package/dist/lib/skill-install-gate.js +77 -8
- package/dist/lib/skill-scanner.d.ts +37 -0
- package/dist/lib/skill-scanner.js +59 -9
- package/dist/ui/AutoSkills.js +10 -0
- package/dist/ui/Plugin.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Claude PreToolUse ownership manager (Slice 2). Its only filesystem
|
|
3
|
+
* surface is `safeReadFile` plus one isolated no-follow `lstat` helper — it never
|
|
4
|
+
* writes, creates directories, or makes backups. It owns asset byte
|
|
5
|
+
* classification (always recompute the full-file SHA), the settings read+parse
|
|
6
|
+
* wrapper (identity delegated to `claude-hook-settings`), the Node `>=22` check,
|
|
7
|
+
* and the component-level doctor. Install/repair are declared but unimplemented
|
|
8
|
+
* Slice-3 seams — Slice 3 GROWS this file, it does not relocate this code.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { lstat } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { CLAUDE_HOOK_ASSETS_DIR } from "../constants.js";
|
|
14
|
+
import { ASSET_MANAGED_MARKER, ASSET_NAME, } from "./__fixtures__/claude-hook-ownership.js";
|
|
15
|
+
import { classifySettingsEntry, isPlainObject, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, } from "./claude-hook-settings.js";
|
|
16
|
+
import { safeReadFile } from "./safe-read.js";
|
|
17
|
+
/** 1 MiB read budget, shared with the runtime's stdin envelope. */
|
|
18
|
+
const ASSET_MAX_BYTES = 1024 * 1024;
|
|
19
|
+
const NODE_MINIMUM_MAJOR = 22;
|
|
20
|
+
const READ_OPTS = {
|
|
21
|
+
maxBytes: ASSET_MAX_BYTES,
|
|
22
|
+
hardRejectOverBytes: ASSET_MAX_BYTES,
|
|
23
|
+
maxLineLength: Number.POSITIVE_INFINITY,
|
|
24
|
+
};
|
|
25
|
+
const COVERAGE = ["Bash", "PowerShell", "Read", "Write", "Edit"];
|
|
26
|
+
const HOST_RESIDUAL = "spawn/start/timeout failures continue through Claude permission flow";
|
|
27
|
+
const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
|
|
28
|
+
async function lstatNoFollow(target) {
|
|
29
|
+
try {
|
|
30
|
+
const stats = await lstat(target);
|
|
31
|
+
if (stats.isSymbolicLink())
|
|
32
|
+
return { kind: "symlink" };
|
|
33
|
+
if (!stats.isFile())
|
|
34
|
+
return { kind: "non-regular" };
|
|
35
|
+
return { kind: "file" };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const code = error.code;
|
|
39
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
40
|
+
return { kind: "enoent" };
|
|
41
|
+
return { kind: "error", detail: code ?? String(error) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Hash the observed bytes as UTF-8, matching the manifest's raw-file hash for the managed asset. */
|
|
45
|
+
function sha256Of(content) {
|
|
46
|
+
return createHash("sha256")
|
|
47
|
+
.update(Buffer.from(content, "utf8"))
|
|
48
|
+
.digest("hex");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Classify the asset into one of nine states from observed bytes only. Never
|
|
52
|
+
* trusts a claimed hash: the full-file SHA is always recomputed and compared to
|
|
53
|
+
* the manifest.
|
|
54
|
+
*/
|
|
55
|
+
export async function classifyAssetState(assetPath, manifest) {
|
|
56
|
+
const stat = await lstatNoFollow(assetPath);
|
|
57
|
+
if (stat.kind === "enoent")
|
|
58
|
+
return { state: "absent" };
|
|
59
|
+
if (stat.kind === "symlink")
|
|
60
|
+
return { state: "symlink" };
|
|
61
|
+
if (stat.kind === "non-regular")
|
|
62
|
+
return { state: "non-regular" };
|
|
63
|
+
if (stat.kind === "error")
|
|
64
|
+
return { state: "non-regular", detail: stat.detail };
|
|
65
|
+
const read = await safeReadFile(assetPath, READ_OPTS);
|
|
66
|
+
if (!read.ok) {
|
|
67
|
+
if (read.reason === "not-found")
|
|
68
|
+
return { state: "absent" };
|
|
69
|
+
if (read.reason === "binary")
|
|
70
|
+
return { state: "foreign", detail: "binary" };
|
|
71
|
+
if (read.reason === "too-large") {
|
|
72
|
+
return { state: "foreign", detail: "exceeds asset budget" };
|
|
73
|
+
}
|
|
74
|
+
return { state: "non-regular", detail: read.reason };
|
|
75
|
+
}
|
|
76
|
+
if (!read.content.startsWith(`${ASSET_MANAGED_MARKER}\n`)) {
|
|
77
|
+
return { state: "foreign" };
|
|
78
|
+
}
|
|
79
|
+
const sha256 = sha256Of(read.content);
|
|
80
|
+
if (sha256 === manifest.asset.sha256) {
|
|
81
|
+
return {
|
|
82
|
+
state: "managed-current",
|
|
83
|
+
version: manifest.asset.version,
|
|
84
|
+
sha256,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (manifest.asset.historical.includes(sha256)) {
|
|
88
|
+
return {
|
|
89
|
+
state: "released-outdated",
|
|
90
|
+
version: manifest.asset.version,
|
|
91
|
+
sha256,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { state: "edited-managed", sha256 };
|
|
95
|
+
}
|
|
96
|
+
/** Read `.claude/settings.json` and classify it (lstat → bounded read → legacy SHA → pure classifier). */
|
|
97
|
+
export async function classifySettingsFile(settingsPath, currentAssetSha, identities) {
|
|
98
|
+
const parsed = await readSettings(settingsPath);
|
|
99
|
+
if ("state" in parsed)
|
|
100
|
+
return parsed.classification;
|
|
101
|
+
return classifySettingsEntry(parsed.value, currentAssetSha, identities);
|
|
102
|
+
}
|
|
103
|
+
const done = (classification) => ({
|
|
104
|
+
state: true,
|
|
105
|
+
classification,
|
|
106
|
+
});
|
|
107
|
+
async function readSettings(settingsPath) {
|
|
108
|
+
const stat = await lstatNoFollow(settingsPath);
|
|
109
|
+
if (stat.kind === "enoent")
|
|
110
|
+
return done({ state: "absent" });
|
|
111
|
+
if (stat.kind === "symlink")
|
|
112
|
+
return done({ state: "symlink" });
|
|
113
|
+
if (stat.kind === "non-regular")
|
|
114
|
+
return done({ state: "non-regular" });
|
|
115
|
+
if (stat.kind === "error") {
|
|
116
|
+
return done({ state: "non-regular", detail: stat.detail });
|
|
117
|
+
}
|
|
118
|
+
const read = await safeReadFile(settingsPath, READ_OPTS);
|
|
119
|
+
if (!read.ok) {
|
|
120
|
+
if (read.reason === "not-found")
|
|
121
|
+
return done({ state: "absent" });
|
|
122
|
+
if (read.reason === "binary" || read.reason === "too-large") {
|
|
123
|
+
return done({ state: "malformed", detail: read.reason });
|
|
124
|
+
}
|
|
125
|
+
return done({ state: "non-regular", detail: read.reason });
|
|
126
|
+
}
|
|
127
|
+
if (sha256Of(read.content) === LEGACY_FILE_SHA256) {
|
|
128
|
+
return done({ state: "exact-legacy", detail: "whole-file" });
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return { value: JSON.parse(read.content) };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return done({ state: "malformed", detail: "invalid-json" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Node availability + `>=22` check from a version string (no spawn). */
|
|
138
|
+
export function detectNode(nodeVersion) {
|
|
139
|
+
if (!nodeVersion)
|
|
140
|
+
return { available: false, satisfiesMinimum: false };
|
|
141
|
+
const major = Number.parseInt(nodeVersion.split(".")[0] ?? "", 10);
|
|
142
|
+
return {
|
|
143
|
+
available: true,
|
|
144
|
+
version: nodeVersion,
|
|
145
|
+
satisfiesMinimum: Number.isFinite(major) && major >= NODE_MINIMUM_MAJOR,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const NO_SIGNALS = {
|
|
149
|
+
matcherExact: false,
|
|
150
|
+
commandShapeExact: false,
|
|
151
|
+
assetSettingsConsistent: false,
|
|
152
|
+
};
|
|
153
|
+
/** Derive matcher/command/consistency signals from the marked handler, if any. */
|
|
154
|
+
function settingsSignals(value, classification, currentAssetSha) {
|
|
155
|
+
if (classification.groupIndex === undefined || !isPlainObject(value)) {
|
|
156
|
+
return NO_SIGNALS;
|
|
157
|
+
}
|
|
158
|
+
const hooks = value.hooks;
|
|
159
|
+
const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
|
|
160
|
+
? hooks.PreToolUse
|
|
161
|
+
: [];
|
|
162
|
+
const group = groups[classification.groupIndex];
|
|
163
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
164
|
+
return NO_SIGNALS;
|
|
165
|
+
const handler = group.hooks[classification.handlerIndex ?? -1];
|
|
166
|
+
if (!isPlainObject(handler))
|
|
167
|
+
return NO_SIGNALS;
|
|
168
|
+
const args = handler.args;
|
|
169
|
+
const commandShapeExact = handler.type === "command" &&
|
|
170
|
+
handler.command === "node" &&
|
|
171
|
+
Array.isArray(args) &&
|
|
172
|
+
args.length === 1 &&
|
|
173
|
+
args[0] === MANAGED_ASSET_ARG &&
|
|
174
|
+
handler.timeout === 30;
|
|
175
|
+
let assetSettingsConsistent = false;
|
|
176
|
+
if (typeof handler.statusMessage === "string" &&
|
|
177
|
+
handler.statusMessage.startsWith(MANAGED_STATUS_PREFIX)) {
|
|
178
|
+
const token = handler.statusMessage.slice(MANAGED_STATUS_PREFIX.length);
|
|
179
|
+
assetSettingsConsistent =
|
|
180
|
+
ASSET_SHA_TOKEN.test(token) && token === currentAssetSha;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
matcherExact: group.matcher === MANAGED_MATCHER,
|
|
184
|
+
commandShapeExact,
|
|
185
|
+
assetSettingsConsistent,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const REMEDIATION = {
|
|
189
|
+
absent: "install the managed $ (Slice 3)",
|
|
190
|
+
"released-outdated": "upgrade the managed $ (Slice 3)",
|
|
191
|
+
"exact-legacy": "migrate the legacy $ (Slice 3)",
|
|
192
|
+
"edited-managed": "repair the managed $ with --force (Slice 3)",
|
|
193
|
+
foreign: "manually review the $",
|
|
194
|
+
symlink: "manually review the $",
|
|
195
|
+
"non-regular": "manually review the $",
|
|
196
|
+
malformed: "manually review the $",
|
|
197
|
+
};
|
|
198
|
+
function remediationFor(state, component) {
|
|
199
|
+
return REMEDIATION[state]?.replace("$", component);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Assemble the read-only component-level doctor report (no writes). `healthy` is
|
|
203
|
+
* exactly: both components `managed-current`, matcher and command shape exact,
|
|
204
|
+
* Node `>=22`. `assetSettingsConsistent` is a reported advisory, NOT part of it.
|
|
205
|
+
*/
|
|
206
|
+
export async function doctorClaudePreToolUse(projectDir, options) {
|
|
207
|
+
const manifest = options?.manifest ?? (await readManifest());
|
|
208
|
+
const currentAssetSha = manifest.asset.sha256;
|
|
209
|
+
const asset = await classifyAssetState(path.join(projectDir, ".claude", "hooks", ASSET_NAME), manifest);
|
|
210
|
+
const settingsRead = await readSettings(path.join(projectDir, ".claude", "settings.json"));
|
|
211
|
+
const settings = "state" in settingsRead
|
|
212
|
+
? settingsRead.classification
|
|
213
|
+
: classifySettingsEntry(settingsRead.value, currentAssetSha, manifest.settingsEntries);
|
|
214
|
+
const signals = "value" in settingsRead
|
|
215
|
+
? settingsSignals(settingsRead.value, settings, currentAssetSha)
|
|
216
|
+
: NO_SIGNALS;
|
|
217
|
+
const node = detectNode(options?.nodeVersion ?? process.versions.node);
|
|
218
|
+
const remediation = new Set();
|
|
219
|
+
if (asset.state !== "managed-current") {
|
|
220
|
+
const line = remediationFor(asset.state, "asset");
|
|
221
|
+
if (line)
|
|
222
|
+
remediation.add(line);
|
|
223
|
+
}
|
|
224
|
+
if (settings.state !== "managed-current") {
|
|
225
|
+
const line = remediationFor(settings.state, "settings");
|
|
226
|
+
if (line)
|
|
227
|
+
remediation.add(line);
|
|
228
|
+
}
|
|
229
|
+
if (!node.satisfiesMinimum)
|
|
230
|
+
remediation.add("install Node 22 or newer");
|
|
231
|
+
if (!signals.matcherExact)
|
|
232
|
+
remediation.add("restore the exact managed matcher");
|
|
233
|
+
if (!signals.commandShapeExact) {
|
|
234
|
+
remediation.add("restore the exact managed command shape");
|
|
235
|
+
}
|
|
236
|
+
const healthy = settings.state === "managed-current" &&
|
|
237
|
+
asset.state === "managed-current" &&
|
|
238
|
+
signals.matcherExact &&
|
|
239
|
+
signals.commandShapeExact &&
|
|
240
|
+
node.satisfiesMinimum;
|
|
241
|
+
return {
|
|
242
|
+
healthy,
|
|
243
|
+
settings: {
|
|
244
|
+
state: settings.state,
|
|
245
|
+
version: settings.version,
|
|
246
|
+
canonicalSha256: settings.canonicalSha256,
|
|
247
|
+
detail: settings.detail ?? settings.state,
|
|
248
|
+
},
|
|
249
|
+
asset: {
|
|
250
|
+
state: asset.state,
|
|
251
|
+
version: asset.version,
|
|
252
|
+
sha256: asset.sha256,
|
|
253
|
+
detail: asset.detail ?? asset.state,
|
|
254
|
+
},
|
|
255
|
+
node,
|
|
256
|
+
matcherExact: signals.matcherExact,
|
|
257
|
+
commandShapeExact: signals.commandShapeExact,
|
|
258
|
+
assetSettingsConsistent: signals.assetSettingsConsistent,
|
|
259
|
+
coverage: COVERAGE,
|
|
260
|
+
hostResidual: HOST_RESIDUAL,
|
|
261
|
+
remediation: [...remediation].sort(),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function readManifest() {
|
|
265
|
+
const read = await safeReadFile(path.join(CLAUDE_HOOK_ASSETS_DIR, "manifest.json"), READ_OPTS);
|
|
266
|
+
if (!read.ok)
|
|
267
|
+
throw new Error(`unreadable claude-hooks manifest: ${read.reason}`);
|
|
268
|
+
return JSON.parse(read.content);
|
|
269
|
+
}
|
|
270
|
+
export function installClaudePreToolUse(_projectDir) {
|
|
271
|
+
throw new Error("unimplemented: Slice 3 transaction");
|
|
272
|
+
}
|
|
273
|
+
export function repairClaudePreToolUse(_projectDir, _options) {
|
|
274
|
+
throw new Error("unimplemented: Slice 3 transaction");
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=claude-hook-manager.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure settings-entry ownership recognition for the SkillGuard Claude
|
|
3
|
+
* PreToolUse guard (Slice 2). Every function takes already-parsed JS values and
|
|
4
|
+
* returns values — ZERO filesystem access (all bytes-on-disk access lives in
|
|
5
|
+
* `claude-hook-manager.ts`). It covers protocol-shape validation, deterministic
|
|
6
|
+
* canonical identity (Decision ②: the live asset SHA token is normalized out
|
|
7
|
+
* before hashing), exact v0 legacy recognition by deep structural equality, and
|
|
8
|
+
* removal/merge PLANNING only. Identity is always recomputed from observed
|
|
9
|
+
* structure; a marker only claims ownership, it never proves it.
|
|
10
|
+
*/
|
|
11
|
+
import { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX } from "./__fixtures__/claude-hook-ownership.js";
|
|
12
|
+
export { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, };
|
|
13
|
+
/** The nine independent component states (identical for asset and settings). */
|
|
14
|
+
export type ClaudeHookComponentState = "absent" | "managed-current" | "released-outdated" | "exact-legacy" | "edited-managed" | "foreign" | "symlink" | "non-regular" | "malformed";
|
|
15
|
+
/** A released settings-entry identity: version plus placeholder-normalized hash. */
|
|
16
|
+
export interface CanonicalSettingsEntry {
|
|
17
|
+
version: number;
|
|
18
|
+
canonicalSha256: string;
|
|
19
|
+
}
|
|
20
|
+
/** The manifest's settings-entry identity binding. */
|
|
21
|
+
export interface SettingsIdentityManifest {
|
|
22
|
+
current: CanonicalSettingsEntry | null;
|
|
23
|
+
historical: CanonicalSettingsEntry[];
|
|
24
|
+
}
|
|
25
|
+
/** Result of classifying a settings container against the manifest identity. */
|
|
26
|
+
export interface SettingsClassification {
|
|
27
|
+
state: ClaudeHookComponentState;
|
|
28
|
+
version?: number;
|
|
29
|
+
canonicalSha256?: string;
|
|
30
|
+
detail?: string;
|
|
31
|
+
/** Index of the marked group inside `hooks.PreToolUse`. */
|
|
32
|
+
groupIndex?: number;
|
|
33
|
+
/** Index of the marked handler inside the group's `hooks`. */
|
|
34
|
+
handlerIndex?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* A valid settings container is an object; when `hooks` is present it MUST be
|
|
39
|
+
* an object; when `hooks.PreToolUse` is present it MUST be an array. Anything
|
|
40
|
+
* else is malformed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function validateSettingsShape(parsed: unknown): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
|
|
45
|
+
* the fixed placeholder so settings identity is invariant under asset rotation.
|
|
46
|
+
* A non-managed or malformed token is returned unchanged so it hashes to a
|
|
47
|
+
* distinct (non-current) value.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeStatusMessage(statusMessage: unknown): string;
|
|
50
|
+
/** Parse the marker version (`…:v1:…` → 1) for diagnostics only. */
|
|
51
|
+
export declare function parseVersionFromStatus(statusMessage: unknown): number | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Canonicalize the marker-proven handler group into deterministic bytes and its
|
|
54
|
+
* SHA-256. Fixed key order (`type,command,args,timeout,statusMessage` inside a
|
|
55
|
+
* `matcher,hooks` group), the asset-SHA token normalized out, two-space indent
|
|
56
|
+
* plus a trailing newline. Only the single handler participates.
|
|
57
|
+
*/
|
|
58
|
+
export declare function canonicalizeSettingsEntry(group: unknown, handler: unknown): {
|
|
59
|
+
serialization: string;
|
|
60
|
+
canonicalSha256: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Order-sensitive for arrays, key-set-exact (order-insensitive) for objects,
|
|
64
|
+
* strict for scalars. No substring, normalization, or tolerance.
|
|
65
|
+
*/
|
|
66
|
+
export declare function deepStructuralEqual(a: unknown, b: unknown): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* In-object legacy recognition: exactly one deep-equal match for each of the
|
|
69
|
+
* four cohort objects (L1–L3 Pre, L4 Post) is `exact-legacy`; a partial,
|
|
70
|
+
* duplicate, or edited cohort is `foreign` (partial-legacy); unrecognized
|
|
71
|
+
* PreToolUse handler content is `foreign` per spec R2; only a container with no
|
|
72
|
+
* PreToolUse handler content is `absent` (installable). Whole-file SHA legacy is
|
|
73
|
+
* decided by the manager, which holds the raw bytes. (Spec R2 overrides
|
|
74
|
+
* design.md Algorithm C, which returns `absent` for the no-cohort fallthrough
|
|
75
|
+
* and so cannot flag a resembling unmarked handler.)
|
|
76
|
+
*/
|
|
77
|
+
export declare function classifyLegacy(parsed: unknown): SettingsClassification;
|
|
78
|
+
/**
|
|
79
|
+
* Reduce a parsed settings container to one component state, from parsed
|
|
80
|
+
* structure only. Shape validation first, then marker-driven identity (always
|
|
81
|
+
* recomputed), then the legacy fallback when no marker is present.
|
|
82
|
+
*/
|
|
83
|
+
export declare function classifySettingsEntry(parsed: unknown, _currentAssetSha: string, identities: SettingsIdentityManifest): SettingsClassification;
|
|
84
|
+
export interface ManagedRemovalPlan {
|
|
85
|
+
refused: boolean;
|
|
86
|
+
reason?: string;
|
|
87
|
+
state: ClaudeHookComponentState;
|
|
88
|
+
groupIndex?: number;
|
|
89
|
+
handlerIndex?: number;
|
|
90
|
+
/** True when removing the managed handler empties the group. */
|
|
91
|
+
removeGroup?: boolean;
|
|
92
|
+
/** Sibling handlers in the group that removal must preserve. */
|
|
93
|
+
preservedSiblings?: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Plan removal of the marker-proven managed handler. Only a recognized managed
|
|
97
|
+
* identity (`managed-current` or `released-outdated`) is eligible; foreign,
|
|
98
|
+
* partial-legacy, edited-managed, and every non-regular state refuse.
|
|
99
|
+
*/
|
|
100
|
+
export declare function planManagedClaudeHookRemoval(parsed: unknown, currentAssetSha: string, identities: SettingsIdentityManifest): ManagedRemovalPlan;
|
|
101
|
+
export interface ManagedMergePlan {
|
|
102
|
+
refused: boolean;
|
|
103
|
+
reason?: string;
|
|
104
|
+
state: ClaudeHookComponentState;
|
|
105
|
+
action: "install" | "replace" | "noop" | "refuse";
|
|
106
|
+
groupIndex?: number;
|
|
107
|
+
handlerIndex?: number;
|
|
108
|
+
preservedSiblings?: number;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Plan the managed merge. `absent` installs a new group, `managed-current` is a
|
|
112
|
+
* no-op, `released-outdated` replaces the marked handler in place (siblings
|
|
113
|
+
* preserved), and every other state refuses (edited/force is a Slice-3 concern).
|
|
114
|
+
*/
|
|
115
|
+
export declare function planManagedClaudeHookMerge(parsed: unknown, currentAssetSha: string, identities: SettingsIdentityManifest): ManagedMergePlan;
|
|
116
|
+
//# sourceMappingURL=claude-hook-settings.d.ts.map
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure settings-entry ownership recognition for the SkillGuard Claude
|
|
3
|
+
* PreToolUse guard (Slice 2). Every function takes already-parsed JS values and
|
|
4
|
+
* returns values — ZERO filesystem access (all bytes-on-disk access lives in
|
|
5
|
+
* `claude-hook-manager.ts`). It covers protocol-shape validation, deterministic
|
|
6
|
+
* canonical identity (Decision ②: the live asset SHA token is normalized out
|
|
7
|
+
* before hashing), exact v0 legacy recognition by deep structural equality, and
|
|
8
|
+
* removal/merge PLANNING only. Identity is always recomputed from observed
|
|
9
|
+
* structure; a marker only claims ownership, it never proves it.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { ASSET_SHA_PLACEHOLDER, LEGACY_COHORT, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, } from "./__fixtures__/claude-hook-ownership.js";
|
|
13
|
+
export { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, };
|
|
14
|
+
// Shape helpers
|
|
15
|
+
export function isPlainObject(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A valid settings container is an object; when `hooks` is present it MUST be
|
|
20
|
+
* an object; when `hooks.PreToolUse` is present it MUST be an array. Anything
|
|
21
|
+
* else is malformed.
|
|
22
|
+
*/
|
|
23
|
+
export function validateSettingsShape(parsed) {
|
|
24
|
+
if (!isPlainObject(parsed))
|
|
25
|
+
return false;
|
|
26
|
+
const { hooks } = parsed;
|
|
27
|
+
if (hooks !== undefined && !isPlainObject(hooks))
|
|
28
|
+
return false;
|
|
29
|
+
const pre = isPlainObject(hooks) ? hooks.PreToolUse : undefined;
|
|
30
|
+
if (pre !== undefined && !Array.isArray(pre))
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// Canonical identity (Decision ②)
|
|
35
|
+
const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
|
|
36
|
+
const VERSION_PATTERN = /^javi-forge-global-pretooluse:v(\d+):sha256:/;
|
|
37
|
+
/**
|
|
38
|
+
* Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
|
|
39
|
+
* the fixed placeholder so settings identity is invariant under asset rotation.
|
|
40
|
+
* A non-managed or malformed token is returned unchanged so it hashes to a
|
|
41
|
+
* distinct (non-current) value.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeStatusMessage(statusMessage) {
|
|
44
|
+
if (typeof statusMessage !== "string")
|
|
45
|
+
return "";
|
|
46
|
+
if (!statusMessage.startsWith(MANAGED_STATUS_PREFIX))
|
|
47
|
+
return statusMessage;
|
|
48
|
+
const token = statusMessage.slice(MANAGED_STATUS_PREFIX.length);
|
|
49
|
+
return ASSET_SHA_TOKEN.test(token)
|
|
50
|
+
? `${MANAGED_STATUS_PREFIX}${ASSET_SHA_PLACEHOLDER}`
|
|
51
|
+
: statusMessage;
|
|
52
|
+
}
|
|
53
|
+
/** Parse the marker version (`…:v1:…` → 1) for diagnostics only. */
|
|
54
|
+
export function parseVersionFromStatus(statusMessage) {
|
|
55
|
+
if (typeof statusMessage !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
const match = VERSION_PATTERN.exec(statusMessage);
|
|
58
|
+
return match ? Number(match[1]) : undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Canonicalize the marker-proven handler group into deterministic bytes and its
|
|
62
|
+
* SHA-256. Fixed key order (`type,command,args,timeout,statusMessage` inside a
|
|
63
|
+
* `matcher,hooks` group), the asset-SHA token normalized out, two-space indent
|
|
64
|
+
* plus a trailing newline. Only the single handler participates.
|
|
65
|
+
*/
|
|
66
|
+
export function canonicalizeSettingsEntry(group, handler) {
|
|
67
|
+
const h = isPlainObject(handler) ? handler : {};
|
|
68
|
+
const g = isPlainObject(group) ? group : {};
|
|
69
|
+
const canonicalHandler = {
|
|
70
|
+
type: h.type,
|
|
71
|
+
command: h.command,
|
|
72
|
+
args: h.args,
|
|
73
|
+
timeout: h.timeout,
|
|
74
|
+
statusMessage: normalizeStatusMessage(h.statusMessage),
|
|
75
|
+
};
|
|
76
|
+
const canonicalGroup = { matcher: g.matcher, hooks: [canonicalHandler] };
|
|
77
|
+
const serialization = `${JSON.stringify(canonicalGroup, null, 2)}\n`;
|
|
78
|
+
const canonicalSha256 = createHash("sha256")
|
|
79
|
+
.update(serialization, "utf8")
|
|
80
|
+
.digest("hex");
|
|
81
|
+
return { serialization, canonicalSha256 };
|
|
82
|
+
}
|
|
83
|
+
// Legacy recognition (deep structural equality only)
|
|
84
|
+
/**
|
|
85
|
+
* Order-sensitive for arrays, key-set-exact (order-insensitive) for objects,
|
|
86
|
+
* strict for scalars. No substring, normalization, or tolerance.
|
|
87
|
+
*/
|
|
88
|
+
export function deepStructuralEqual(a, b) {
|
|
89
|
+
if (a === b)
|
|
90
|
+
return true;
|
|
91
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
92
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return a.every((item, index) => deepStructuralEqual(item, b[index]));
|
|
96
|
+
}
|
|
97
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
98
|
+
const keysA = Object.keys(a);
|
|
99
|
+
const keysB = Object.keys(b);
|
|
100
|
+
if (keysA.length !== keysB.length)
|
|
101
|
+
return false;
|
|
102
|
+
return keysA.every((key) => Object.hasOwn(b, key) && deepStructuralEqual(a[key], b[key]));
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
function countDeepMatches(target, list) {
|
|
107
|
+
let count = 0;
|
|
108
|
+
for (const item of list)
|
|
109
|
+
if (deepStructuralEqual(target, item))
|
|
110
|
+
count++;
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* In-object legacy recognition: exactly one deep-equal match for each of the
|
|
115
|
+
* four cohort objects (L1–L3 Pre, L4 Post) is `exact-legacy`; a partial,
|
|
116
|
+
* duplicate, or edited cohort is `foreign` (partial-legacy); unrecognized
|
|
117
|
+
* PreToolUse handler content is `foreign` per spec R2; only a container with no
|
|
118
|
+
* PreToolUse handler content is `absent` (installable). Whole-file SHA legacy is
|
|
119
|
+
* decided by the manager, which holds the raw bytes. (Spec R2 overrides
|
|
120
|
+
* design.md Algorithm C, which returns `absent` for the no-cohort fallthrough
|
|
121
|
+
* and so cannot flag a resembling unmarked handler.)
|
|
122
|
+
*/
|
|
123
|
+
export function classifyLegacy(parsed) {
|
|
124
|
+
const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
|
|
125
|
+
const pre = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
|
|
126
|
+
? hooks.PreToolUse
|
|
127
|
+
: [];
|
|
128
|
+
const post = isPlainObject(hooks) && Array.isArray(hooks.PostToolUse)
|
|
129
|
+
? hooks.PostToolUse
|
|
130
|
+
: [];
|
|
131
|
+
const counts = [
|
|
132
|
+
countDeepMatches(LEGACY_COHORT.L1, pre),
|
|
133
|
+
countDeepMatches(LEGACY_COHORT.L2, pre),
|
|
134
|
+
countDeepMatches(LEGACY_COHORT.L3, pre),
|
|
135
|
+
countDeepMatches(LEGACY_COHORT.L4, post),
|
|
136
|
+
];
|
|
137
|
+
if (counts.every((count) => count === 1)) {
|
|
138
|
+
return { state: "exact-legacy", detail: "cohort" };
|
|
139
|
+
}
|
|
140
|
+
if (counts.some((count) => count >= 1)) {
|
|
141
|
+
return { state: "foreign", detail: "partial-legacy" };
|
|
142
|
+
}
|
|
143
|
+
const preHandlers = pre.reduce((total, group) => total +
|
|
144
|
+
(isPlainObject(group) && Array.isArray(group.hooks)
|
|
145
|
+
? group.hooks.length
|
|
146
|
+
: 0), 0);
|
|
147
|
+
if (preHandlers > 0)
|
|
148
|
+
return { state: "foreign", detail: "no-marker" };
|
|
149
|
+
return { state: "absent" };
|
|
150
|
+
}
|
|
151
|
+
function findManagedMarkers(groups) {
|
|
152
|
+
const markers = [];
|
|
153
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
154
|
+
const group = groups[groupIndex];
|
|
155
|
+
const handlers = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
156
|
+
for (let handlerIndex = 0; handlerIndex < handlers.length; handlerIndex++) {
|
|
157
|
+
const handler = handlers[handlerIndex];
|
|
158
|
+
if (isPlainObject(handler) &&
|
|
159
|
+
typeof handler.statusMessage === "string" &&
|
|
160
|
+
handler.statusMessage.startsWith(MANAGED_STATUS_PREFIX)) {
|
|
161
|
+
markers.push({ groupIndex, handlerIndex, group, handler });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return markers;
|
|
166
|
+
}
|
|
167
|
+
function isValidMatcherGroup(group) {
|
|
168
|
+
return (isPlainObject(group) &&
|
|
169
|
+
typeof group.matcher === "string" &&
|
|
170
|
+
Array.isArray(group.hooks));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Reduce a parsed settings container to one component state, from parsed
|
|
174
|
+
* structure only. Shape validation first, then marker-driven identity (always
|
|
175
|
+
* recomputed), then the legacy fallback when no marker is present.
|
|
176
|
+
*/
|
|
177
|
+
export function classifySettingsEntry(parsed, _currentAssetSha, identities) {
|
|
178
|
+
if (!validateSettingsShape(parsed))
|
|
179
|
+
return { state: "malformed" };
|
|
180
|
+
const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
|
|
181
|
+
const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
|
|
182
|
+
? hooks.PreToolUse
|
|
183
|
+
: [];
|
|
184
|
+
const markers = findManagedMarkers(groups);
|
|
185
|
+
if (markers.length > 1) {
|
|
186
|
+
return { state: "edited-managed", detail: "multiple markers" };
|
|
187
|
+
}
|
|
188
|
+
if (markers.length === 0)
|
|
189
|
+
return classifyLegacy(parsed);
|
|
190
|
+
const marker = markers[0];
|
|
191
|
+
if (!isValidMatcherGroup(marker.group)) {
|
|
192
|
+
return { state: "edited-managed", detail: "marker in invalid container" };
|
|
193
|
+
}
|
|
194
|
+
const { canonicalSha256 } = canonicalizeSettingsEntry(marker.group, marker.handler);
|
|
195
|
+
const version = parseVersionFromStatus(marker.handler.statusMessage);
|
|
196
|
+
const base = {
|
|
197
|
+
version,
|
|
198
|
+
canonicalSha256,
|
|
199
|
+
groupIndex: marker.groupIndex,
|
|
200
|
+
handlerIndex: marker.handlerIndex,
|
|
201
|
+
};
|
|
202
|
+
if (identities.current &&
|
|
203
|
+
canonicalSha256 === identities.current.canonicalSha256) {
|
|
204
|
+
return { state: "managed-current", ...base };
|
|
205
|
+
}
|
|
206
|
+
if (identities.historical.some((entry) => entry.canonicalSha256 === canonicalSha256)) {
|
|
207
|
+
return { state: "released-outdated", ...base };
|
|
208
|
+
}
|
|
209
|
+
return { state: "edited-managed", ...base };
|
|
210
|
+
}
|
|
211
|
+
function groupHandlerCount(parsed, groupIndex) {
|
|
212
|
+
const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
|
|
213
|
+
const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
|
|
214
|
+
? hooks.PreToolUse
|
|
215
|
+
: [];
|
|
216
|
+
const group = groups[groupIndex];
|
|
217
|
+
return isPlainObject(group) && Array.isArray(group.hooks)
|
|
218
|
+
? group.hooks.length
|
|
219
|
+
: 0;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Plan removal of the marker-proven managed handler. Only a recognized managed
|
|
223
|
+
* identity (`managed-current` or `released-outdated`) is eligible; foreign,
|
|
224
|
+
* partial-legacy, edited-managed, and every non-regular state refuse.
|
|
225
|
+
*/
|
|
226
|
+
export function planManagedClaudeHookRemoval(parsed, currentAssetSha, identities) {
|
|
227
|
+
const cls = classifySettingsEntry(parsed, currentAssetSha, identities);
|
|
228
|
+
if (cls.state === "managed-current" || cls.state === "released-outdated") {
|
|
229
|
+
const handlerCount = groupHandlerCount(parsed, cls.groupIndex ?? -1);
|
|
230
|
+
const preservedSiblings = Math.max(handlerCount - 1, 0);
|
|
231
|
+
return {
|
|
232
|
+
refused: false,
|
|
233
|
+
state: cls.state,
|
|
234
|
+
groupIndex: cls.groupIndex,
|
|
235
|
+
handlerIndex: cls.handlerIndex,
|
|
236
|
+
removeGroup: preservedSiblings === 0,
|
|
237
|
+
preservedSiblings,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
refused: true,
|
|
242
|
+
reason: `refuse removal for state ${cls.state}`,
|
|
243
|
+
state: cls.state,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Plan the managed merge. `absent` installs a new group, `managed-current` is a
|
|
248
|
+
* no-op, `released-outdated` replaces the marked handler in place (siblings
|
|
249
|
+
* preserved), and every other state refuses (edited/force is a Slice-3 concern).
|
|
250
|
+
*/
|
|
251
|
+
export function planManagedClaudeHookMerge(parsed, currentAssetSha, identities) {
|
|
252
|
+
const cls = classifySettingsEntry(parsed, currentAssetSha, identities);
|
|
253
|
+
if (cls.state === "absent") {
|
|
254
|
+
return { refused: false, state: cls.state, action: "install" };
|
|
255
|
+
}
|
|
256
|
+
if (cls.state === "managed-current") {
|
|
257
|
+
return {
|
|
258
|
+
refused: false,
|
|
259
|
+
state: cls.state,
|
|
260
|
+
action: "noop",
|
|
261
|
+
groupIndex: cls.groupIndex,
|
|
262
|
+
handlerIndex: cls.handlerIndex,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (cls.state === "released-outdated") {
|
|
266
|
+
const preservedSiblings = Math.max(groupHandlerCount(parsed, cls.groupIndex ?? -1) - 1, 0);
|
|
267
|
+
return {
|
|
268
|
+
refused: false,
|
|
269
|
+
state: cls.state,
|
|
270
|
+
action: "replace",
|
|
271
|
+
groupIndex: cls.groupIndex,
|
|
272
|
+
handlerIndex: cls.handlerIndex,
|
|
273
|
+
preservedSiblings,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
refused: true,
|
|
278
|
+
reason: `refuse merge for state ${cls.state}`,
|
|
279
|
+
state: cls.state,
|
|
280
|
+
action: "refuse",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=claude-hook-settings.js.map
|
package/dist/lib/plugin.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export declare function installPlugin(source: string, options?: {
|
|
|
14
14
|
success: boolean;
|
|
15
15
|
name?: string;
|
|
16
16
|
error?: string;
|
|
17
|
+
/**
|
|
18
|
+
* FU-1 (R4-002): true when the failure is a skillguard gate refusal
|
|
19
|
+
* (manifest-integrity or verdict refusal, incl. a scanner-error deny).
|
|
20
|
+
* The CLI layer turns this into a non-zero exit code so scripted
|
|
21
|
+
* consumers can tell a refusal apart from success. Plain usage errors
|
|
22
|
+
* (invalid source, validation failed) leave it unset.
|
|
23
|
+
*/
|
|
24
|
+
refused?: boolean;
|
|
17
25
|
}>;
|
|
18
26
|
/**
|
|
19
27
|
* Remove an installed plugin by name.
|