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
package/dist/lib/plugin.js
CHANGED
|
@@ -4,8 +4,8 @@ import { PLUGIN_ASSET_DIRS, PLUGIN_MANIFEST_FILE, PLUGIN_REGISTRY_URL, PLUGINS_D
|
|
|
4
4
|
import { generateAgentSkillsManifest } from "./agent-skills.js";
|
|
5
5
|
import { autoWirePlugins } from "./auto-wire.js";
|
|
6
6
|
import { execFileAsync } from "./exec.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.js";
|
|
8
|
+
import { scanSkillsWithCoverage } from "./skill-scanner.js";
|
|
9
9
|
const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
10
10
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
11
11
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
@@ -158,54 +158,27 @@ export async function installPlugin(source, options = {}) {
|
|
|
158
158
|
// leaves staging intact (removed by `finally`) and never destroys a
|
|
159
159
|
// prior install. dryRun skips the gate entirely (no staged clone).
|
|
160
160
|
// Scanner/eval errors deny unconditionally (D7 — a throw is not a
|
|
161
|
-
// verdict, so no force branch consults it).
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
// D6) while the lead line still names the rejected count (JD-014).
|
|
166
|
-
let declaredResults = [];
|
|
161
|
+
// verdict, so no force branch consults it). The refusal policy +
|
|
162
|
+
// message-building is shared with plugin import via
|
|
163
|
+
// evaluateCoverageGate (R2-001).
|
|
164
|
+
let coverage;
|
|
167
165
|
try {
|
|
168
166
|
const declaredPaths = (validation.manifest.skills ?? []).map((skill) => path.join("skills", skill));
|
|
169
|
-
|
|
170
|
-
// Manifest-integrity refusals — block-level, force NEVER lifts
|
|
171
|
-
// (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl.
|
|
172
|
-
// node_modules/.git). A walk with I/O errors cannot certify the
|
|
173
|
-
// installed footprint — refuse first, before symlink/undeclared
|
|
174
|
-
// checks, because the broken subtree may hide either (JD-013).
|
|
175
|
-
if (coverage.errors.length > 0) {
|
|
176
|
-
return {
|
|
177
|
-
success: false,
|
|
178
|
-
error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
if (coverage.symlinks.length > 0) {
|
|
182
|
-
return {
|
|
183
|
-
success: false,
|
|
184
|
-
error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (coverage.undeclared.length > 0) {
|
|
188
|
-
return {
|
|
189
|
-
success: false,
|
|
190
|
-
error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
declaredResults = coverage.declared;
|
|
194
|
-
gate = evaluateInstallGate(coverage.declared, { force });
|
|
167
|
+
coverage = await scanSkillsWithCoverage(tmpDir, declaredPaths);
|
|
195
168
|
}
|
|
196
169
|
catch (scanError) {
|
|
197
|
-
const msg = scanError instanceof Error ? scanError.message : String(scanError);
|
|
198
170
|
return {
|
|
199
171
|
success: false,
|
|
200
|
-
|
|
172
|
+
refused: true,
|
|
173
|
+
error: scanFailureMessage(scanError),
|
|
201
174
|
};
|
|
202
175
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
|
|
176
|
+
const decision = evaluateCoverageGate(coverage, { force });
|
|
177
|
+
if (decision.refusalError) {
|
|
206
178
|
return {
|
|
207
179
|
success: false,
|
|
208
|
-
|
|
180
|
+
refused: true,
|
|
181
|
+
error: decision.refusalError,
|
|
209
182
|
};
|
|
210
183
|
}
|
|
211
184
|
// Remove existing version if present
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
3
|
*
|
|
4
|
-
* Pure
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Pure helpers: verdict evaluation ({@link evaluateInstallGate}) and the
|
|
5
|
+
* coverage refusal policy + message-building ({@link evaluateCoverageGate},
|
|
6
|
+
* {@link scanFailureMessage}) live in exactly one place so all three
|
|
7
|
+
* entrypoints (plugin add, plugin import, skills auto-install) share
|
|
8
|
+
* identical force semantics and byte-identical refusal messages (R2-001).
|
|
9
|
+
* Scanning + try/catch stay at the call sites (a pure helper cannot own the
|
|
10
|
+
* I/O); this module imports no fs.
|
|
8
11
|
*
|
|
9
12
|
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
10
13
|
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
11
|
-
* any symlink, empty/missing `skills` on import,
|
|
12
|
-
* source dir) are NOT verdicts —
|
|
13
|
-
*
|
|
14
|
+
* any symlink, case-colliding declared dirs, empty/missing `skills` on import,
|
|
15
|
+
* declared paths escaping the source dir) are NOT verdicts —
|
|
16
|
+
* `evaluateCoverageGate` enforces the walk-derived ones BEFORE the verdict
|
|
17
|
+
* gate runs, so `force` can never lift them either.
|
|
14
18
|
*/
|
|
15
|
-
import type { SkillScanResult } from "./skill-scanner.js";
|
|
19
|
+
import type { SkillCoverageScan, SkillScanResult } from "./skill-scanner.js";
|
|
16
20
|
export interface InstallGateDecision {
|
|
17
21
|
allowed: boolean;
|
|
18
22
|
/** Rejected results when `!allowed`, else `[]`. */
|
|
@@ -28,4 +32,36 @@ export interface InstallGateDecision {
|
|
|
28
32
|
export declare function evaluateInstallGate(results: SkillScanResult[], options?: {
|
|
29
33
|
force?: boolean;
|
|
30
34
|
}): InstallGateDecision;
|
|
35
|
+
export interface CoverageGateDecision {
|
|
36
|
+
/** Verdict-gate evaluation of the declared results. */
|
|
37
|
+
gate: InstallGateDecision;
|
|
38
|
+
/**
|
|
39
|
+
* Refusal message when the install must be refused, else `null`
|
|
40
|
+
* (⇔ the install proceeds). Manifest-integrity refusals come first
|
|
41
|
+
* (errors → symlinks → undeclared — block-level, force never lifts),
|
|
42
|
+
* then the verdict refusal (`!gate.allowed`). Messages are the
|
|
43
|
+
* byte-identical UX contract both package entrypoints share (spec:
|
|
44
|
+
* "refusal output reuses scanner reports"; R2-001: the refusal policy +
|
|
45
|
+
* message-building lives in exactly one place).
|
|
46
|
+
*/
|
|
47
|
+
refusalError: string | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Shared refusal policy for the coverage walk + verdict gate (R2-001) — the
|
|
51
|
+
* chain both `plugin add` and `plugin import` run between
|
|
52
|
+
* `scanSkillsWithCoverage` and placement. A `null` refusalError means the
|
|
53
|
+
* install may proceed; any non-null message is a block-level refusal the
|
|
54
|
+
* caller returns verbatim (`force` never lifts integrity refusals; the
|
|
55
|
+
* verdict branch already encodes the force rule via {@link evaluateInstallGate}).
|
|
56
|
+
*/
|
|
57
|
+
export declare function evaluateCoverageGate(coverage: SkillCoverageScan, options?: {
|
|
58
|
+
force?: boolean;
|
|
59
|
+
}): CoverageGateDecision;
|
|
60
|
+
/**
|
|
61
|
+
* Byte-identical message for the scanner-error deny (D7): a throw from the
|
|
62
|
+
* coverage walk or verdict evaluation is not a verdict, so no force branch
|
|
63
|
+
* consults it — the install is denied unconditionally (R2-001: shared by
|
|
64
|
+
* both package entrypoints).
|
|
65
|
+
*/
|
|
66
|
+
export declare function scanFailureMessage(error: unknown): string;
|
|
31
67
|
//# sourceMappingURL=skill-install-gate.d.ts.map
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
3
|
*
|
|
4
|
-
* Pure
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Pure helpers: verdict evaluation ({@link evaluateInstallGate}) and the
|
|
5
|
+
* coverage refusal policy + message-building ({@link evaluateCoverageGate},
|
|
6
|
+
* {@link scanFailureMessage}) live in exactly one place so all three
|
|
7
|
+
* entrypoints (plugin add, plugin import, skills auto-install) share
|
|
8
|
+
* identical force semantics and byte-identical refusal messages (R2-001).
|
|
9
|
+
* Scanning + try/catch stay at the call sites (a pure helper cannot own the
|
|
10
|
+
* I/O); this module imports no fs.
|
|
8
11
|
*
|
|
9
12
|
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
10
13
|
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
11
|
-
* any symlink, empty/missing `skills` on import,
|
|
12
|
-
* source dir) are NOT verdicts —
|
|
13
|
-
*
|
|
14
|
+
* any symlink, case-colliding declared dirs, empty/missing `skills` on import,
|
|
15
|
+
* declared paths escaping the source dir) are NOT verdicts —
|
|
16
|
+
* `evaluateCoverageGate` enforces the walk-derived ones BEFORE the verdict
|
|
17
|
+
* gate runs, so `force` can never lift them either.
|
|
14
18
|
*/
|
|
15
|
-
import { isRejectedVerdict } from "./skill-scanner.js";
|
|
19
|
+
import { formatBatchReport, isRejectedVerdict } from "./skill-scanner.js";
|
|
16
20
|
/**
|
|
17
21
|
* Evaluate a set of declared-skill scan results against the install gate.
|
|
18
22
|
*
|
|
@@ -27,4 +31,69 @@ export function evaluateInstallGate(results, options) {
|
|
|
27
31
|
const allowed = !hasBlock && (rejected.length === 0 || force);
|
|
28
32
|
return { allowed, rejected: allowed ? [] : rejected };
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Shared refusal policy for the coverage walk + verdict gate (R2-001) — the
|
|
36
|
+
* chain both `plugin add` and `plugin import` run between
|
|
37
|
+
* `scanSkillsWithCoverage` and placement. A `null` refusalError means the
|
|
38
|
+
* install may proceed; any non-null message is a block-level refusal the
|
|
39
|
+
* caller returns verbatim (`force` never lifts integrity refusals; the
|
|
40
|
+
* verdict branch already encodes the force rule via {@link evaluateInstallGate}).
|
|
41
|
+
*/
|
|
42
|
+
export function evaluateCoverageGate(coverage, options) {
|
|
43
|
+
// Manifest-integrity refusals — block-level, force NEVER lifts
|
|
44
|
+
// (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl.
|
|
45
|
+
// node_modules/.git). A walk with I/O errors cannot certify the
|
|
46
|
+
// installed footprint — refuse first, before symlink/undeclared
|
|
47
|
+
// checks, because the broken subtree may hide either (JD-013).
|
|
48
|
+
if (coverage.errors.length > 0) {
|
|
49
|
+
return {
|
|
50
|
+
gate: { allowed: false, rejected: [] },
|
|
51
|
+
refusalError: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// FU-5 (F3 residual): a declared dir with case-colliding on-disk twins
|
|
55
|
+
// is ambiguous — the declared scan read one twin while both install.
|
|
56
|
+
const ambiguous = coverage.ambiguousDeclaredDirs ?? [];
|
|
57
|
+
if (ambiguous.length > 0) {
|
|
58
|
+
return {
|
|
59
|
+
gate: { allowed: false, rejected: [] },
|
|
60
|
+
refusalError: `skillguard: install refused — ambiguous declared skill dir(s) (case-colliding on-disk dirs; manifest-integrity, force never lifts):\n${ambiguous.map((p) => ` ${p}`).join("\n")}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (coverage.symlinks.length > 0) {
|
|
64
|
+
return {
|
|
65
|
+
gate: { allowed: false, rejected: [] },
|
|
66
|
+
refusalError: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (coverage.undeclared.length > 0) {
|
|
70
|
+
return {
|
|
71
|
+
gate: { allowed: false, rejected: [] },
|
|
72
|
+
refusalError: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const gate = evaluateInstallGate(coverage.declared, options);
|
|
76
|
+
if (!gate.allowed) {
|
|
77
|
+
const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
|
|
78
|
+
const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
|
|
79
|
+
return {
|
|
80
|
+
gate,
|
|
81
|
+
// Lead line names the rejected count; the batch report renders
|
|
82
|
+
// the FULL declared set so the header/rows reflect every scanned
|
|
83
|
+
// skill (D6, JD-014).
|
|
84
|
+
refusalError: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(coverage.declared)}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { gate, refusalError: null };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Byte-identical message for the scanner-error deny (D7): a throw from the
|
|
91
|
+
* coverage walk or verdict evaluation is not a verdict, so no force branch
|
|
92
|
+
* consults it — the install is denied unconditionally (R2-001: shared by
|
|
93
|
+
* both package entrypoints).
|
|
94
|
+
*/
|
|
95
|
+
export function scanFailureMessage(error) {
|
|
96
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
97
|
+
return `skillguard scan failed — ${msg}`;
|
|
98
|
+
}
|
|
30
99
|
//# sourceMappingURL=skill-install-gate.js.map
|
|
@@ -107,6 +107,20 @@ export interface SkillCoverageScan {
|
|
|
107
107
|
* proceed un-scanned.
|
|
108
108
|
*/
|
|
109
109
|
errors: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Distinct real on-disk dirs that case-collide with a DECLARED dir (FU-5,
|
|
112
|
+
* F3 residual): two or more on-disk dirs whose lowercased paths are equal
|
|
113
|
+
* AND match a declared dir — e.g. declared `skills/alpha` with on-disk
|
|
114
|
+
* siblings `skills/alpha` + `skills/Alpha` on a case-sensitive FS.
|
|
115
|
+
* Lowercased declared-dir membership (R1-F2-N1) cannot tell the twins
|
|
116
|
+
* apart: both pass membership (neither SKILL.md lands in `undeclared`)
|
|
117
|
+
* while the declared scan reads only one of them — the other installs
|
|
118
|
+
* un-scanned. The caller refuses on this (manifest-integrity, block-
|
|
119
|
+
* level, force never lifts). Absent or empty when no declared dir
|
|
120
|
+
* case-collides; a single case-folded on-disk dir (no twin) is NOT
|
|
121
|
+
* ambiguous — it resolves via the normal case-fold lookup.
|
|
122
|
+
*/
|
|
123
|
+
ambiguousDeclaredDirs?: string[];
|
|
110
124
|
}
|
|
111
125
|
/**
|
|
112
126
|
* SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
|
|
@@ -121,6 +135,29 @@ export interface SkillCoverageScan {
|
|
|
121
135
|
* {@link scanSkillFile} afterwards (JD-005).
|
|
122
136
|
*/
|
|
123
137
|
export declare function scanSkillsWithCoverage(dir: string, declaredPaths: string[]): Promise<SkillCoverageScan>;
|
|
138
|
+
export interface PathContainmentCheck {
|
|
139
|
+
/** True when the entry stays inside the root lexically AND by realpath. */
|
|
140
|
+
ok: boolean;
|
|
141
|
+
/** Absolute lexical resolution of `entry` under `rootAbs`. */
|
|
142
|
+
entryAbs: string;
|
|
143
|
+
/** Which containment check failed when `!ok`. */
|
|
144
|
+
violation?: "lexical" | "realpath";
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Containment core (R2-002) — the ONE implementation of the lexical +
|
|
148
|
+
* realpath containment policy (JD-003): an entry must stay inside the root
|
|
149
|
+
* both lexically (`../../x`, absolute paths) and by realpath, so an in-tree
|
|
150
|
+
* symlink cannot redirect a read outside the staged clone. Realpath
|
|
151
|
+
* resolution is best-effort: a missing declared dir has no realpath yet, in
|
|
152
|
+
* which case lexical containment is the whole guard (the coverage walk later
|
|
153
|
+
* reports it as a missing/unscannable declared skill).
|
|
154
|
+
*
|
|
155
|
+
* Two surfaces wrap this core with their own error shapes (both bound by
|
|
156
|
+
* tests — keep the messages distinct): {@link resolveContained} (throwing;
|
|
157
|
+
* used by the coverage walk) and `skillPathContained` in agent-skills.ts
|
|
158
|
+
* (non-throwing `{ ok, reason }`; the import gate refuses gracefully).
|
|
159
|
+
*/
|
|
160
|
+
export declare function checkPathContained(rootAbs: string, rootReal: string, entry: string): Promise<PathContainmentCheck>;
|
|
124
161
|
export declare function formatScanReport(result: SkillScanResult): string;
|
|
125
162
|
export declare function formatBatchReport(results: SkillScanResult[]): string;
|
|
126
163
|
export {};
|
|
@@ -528,6 +528,34 @@ export async function scanSkillsWithCoverage(dir, declaredPaths) {
|
|
|
528
528
|
// Starting from `rootAbs` also keeps undeclared/symlinks/errors absolute
|
|
529
529
|
// and consistent with the gate's realpath expectations.
|
|
530
530
|
await walk(rootAbs);
|
|
531
|
+
// FU-5 (F3 residual): a declared dir whose lowercased path maps to MORE
|
|
532
|
+
// THAN ONE distinct real on-disk dir is ambiguous. Declared-dir
|
|
533
|
+
// membership is lowercased on both sides (R1-F2-N1), so a case-colliding
|
|
534
|
+
// TWIN (declared `skills/alpha` + on-disk siblings `skills/alpha` and
|
|
535
|
+
// `skills/Alpha` on a case-sensitive FS) passes membership for BOTH
|
|
536
|
+
// dirs — neither SKILL.md lands in `undeclared` — while the declared
|
|
537
|
+
// scan below reads only one of them; the other would install un-scanned.
|
|
538
|
+
// Surface the colliding dirs as a refusal-class condition (the gate
|
|
539
|
+
// refuses, manifest-integrity, force never lifts). A single case-folded
|
|
540
|
+
// dir (no twin) is unaffected — the two-tier lookup resolves it.
|
|
541
|
+
const dirsByLower = new Map();
|
|
542
|
+
for (const d of walkDirs) {
|
|
543
|
+
const key = d.toLowerCase();
|
|
544
|
+
const group = dirsByLower.get(key);
|
|
545
|
+
if (group) {
|
|
546
|
+
group.push(d);
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
dirsByLower.set(key, [d]);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const ambiguousDeclaredDirs = [];
|
|
553
|
+
for (const [key, group] of dirsByLower) {
|
|
554
|
+
if (group.length > 1 && declaredDirAbsLower.has(key)) {
|
|
555
|
+
// Sorted for deterministic output (readdir order is OS-dependent).
|
|
556
|
+
ambiguousDeclaredDirs.push(...[...group].sort());
|
|
557
|
+
}
|
|
558
|
+
}
|
|
531
559
|
// Content-scanned results for declared entries only (JD-005), in declared
|
|
532
560
|
// order so reports are deterministic. A declared file that is a symlink is
|
|
533
561
|
// already in `symlinks` — reading through it would escape the tree (JD-003),
|
|
@@ -561,7 +589,7 @@ export async function scanSkillsWithCoverage(dir, declaredPaths) {
|
|
|
561
589
|
continue;
|
|
562
590
|
declared.push(await scanSkillFile(file));
|
|
563
591
|
}
|
|
564
|
-
return { declared, undeclared, symlinks, errors };
|
|
592
|
+
return { declared, undeclared, symlinks, errors, ambiguousDeclaredDirs };
|
|
565
593
|
}
|
|
566
594
|
/**
|
|
567
595
|
* Resolve the on-disk skill file for a declared skill directory. The coverage
|
|
@@ -597,17 +625,25 @@ async function declaredSkillFileOnDisk(absDir) {
|
|
|
597
625
|
return canonical;
|
|
598
626
|
}
|
|
599
627
|
/**
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
* realpath, so an in-tree
|
|
603
|
-
*
|
|
628
|
+
* Containment core (R2-002) — the ONE implementation of the lexical +
|
|
629
|
+
* realpath containment policy (JD-003): an entry must stay inside the root
|
|
630
|
+
* both lexically (`../../x`, absolute paths) and by realpath, so an in-tree
|
|
631
|
+
* symlink cannot redirect a read outside the staged clone. Realpath
|
|
632
|
+
* resolution is best-effort: a missing declared dir has no realpath yet, in
|
|
633
|
+
* which case lexical containment is the whole guard (the coverage walk later
|
|
634
|
+
* reports it as a missing/unscannable declared skill).
|
|
635
|
+
*
|
|
636
|
+
* Two surfaces wrap this core with their own error shapes (both bound by
|
|
637
|
+
* tests — keep the messages distinct): {@link resolveContained} (throwing;
|
|
638
|
+
* used by the coverage walk) and `skillPathContained` in agent-skills.ts
|
|
639
|
+
* (non-throwing `{ ok, reason }`; the import gate refuses gracefully).
|
|
604
640
|
*/
|
|
605
|
-
async function
|
|
641
|
+
export async function checkPathContained(rootAbs, rootReal, entry) {
|
|
606
642
|
const entryAbs = path.resolve(rootAbs, entry);
|
|
607
643
|
// Lexical containment — catches `../outside` and absolute entries.
|
|
608
644
|
const rel = path.relative(rootAbs, entryAbs);
|
|
609
645
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
610
|
-
|
|
646
|
+
return { ok: false, entryAbs, violation: "lexical" };
|
|
611
647
|
}
|
|
612
648
|
// Realpath containment — catches a directory inside the tree whose real
|
|
613
649
|
// location is outside it. A missing declared dir (later `unscannable`) has
|
|
@@ -617,13 +653,27 @@ async function resolveContained(rootAbs, rootReal, entry) {
|
|
|
617
653
|
real = await fs.realpath(entryAbs);
|
|
618
654
|
}
|
|
619
655
|
catch {
|
|
620
|
-
return entryAbs;
|
|
656
|
+
return { ok: true, entryAbs };
|
|
621
657
|
}
|
|
622
658
|
const relReal = path.relative(rootReal, real);
|
|
623
659
|
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
660
|
+
return { ok: false, entryAbs, violation: "realpath" };
|
|
661
|
+
}
|
|
662
|
+
return { ok: true, entryAbs };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Resolve a declared skill entry to an absolute directory and verify it stays
|
|
666
|
+
* inside the scan root — both lexically (`../../x`, absolute paths) and by
|
|
667
|
+
* realpath, so an in-tree symlink cannot redirect the declared read outside the
|
|
668
|
+
* staged clone (JD-003). Throws when the entry escapes; the caller denies.
|
|
669
|
+
* Thin throwing surface over the shared containment core (R2-002).
|
|
670
|
+
*/
|
|
671
|
+
async function resolveContained(rootAbs, rootReal, entry) {
|
|
672
|
+
const check = await checkPathContained(rootAbs, rootReal, entry);
|
|
673
|
+
if (!check.ok) {
|
|
624
674
|
throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
|
|
625
675
|
}
|
|
626
|
-
return entryAbs;
|
|
676
|
+
return check.entryAbs;
|
|
627
677
|
}
|
|
628
678
|
// =============================================================================
|
|
629
679
|
// Report formatting
|
package/dist/ui/AutoSkills.js
CHANGED
|
@@ -24,10 +24,20 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, force = fals
|
|
|
24
24
|
force,
|
|
25
25
|
})
|
|
26
26
|
.then((r) => {
|
|
27
|
+
// FU-1 (R4-002): a refused batch (gate-blocked skills) must exit
|
|
28
|
+
// non-zero so scripted consumers can tell a refusal from success;
|
|
29
|
+
// `process.exitCode` (not `process.exit`) keeps Ink rendering.
|
|
30
|
+
// A clean run resets to 0 so an interactive re-scan (r) after a
|
|
31
|
+
// refusal can still exit 0. Force-lifted unscannable installs are
|
|
32
|
+
// NOT blocked — they keep exit 0.
|
|
33
|
+
process.exitCode = r.blocked.length > 0 ? 1 : 0;
|
|
27
34
|
setResult(r);
|
|
28
35
|
setLoading(false);
|
|
29
36
|
})
|
|
30
37
|
.catch((e) => {
|
|
38
|
+
// A scan throw is a gate deny (D7) — nothing was copied; exit
|
|
39
|
+
// non-zero like any other refusal.
|
|
40
|
+
process.exitCode = 1;
|
|
31
41
|
setError(String(e));
|
|
32
42
|
setLoading(false);
|
|
33
43
|
});
|
package/dist/ui/Plugin.js
CHANGED
|
@@ -106,7 +106,7 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
|
|
|
106
106
|
});
|
|
107
107
|
break;
|
|
108
108
|
}
|
|
109
|
-
await runPluginImport(target, dryRun, onStep, force);
|
|
109
|
+
await runPluginImport(target, dryRun, onStep, { force });
|
|
110
110
|
break;
|
|
111
111
|
case "export-skills":
|
|
112
112
|
if (target === "global") {
|