javi-forge 1.27.0 → 1.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/dispatch/simple-renderers.js +1 -1
- package/dist/cli/dispatch/skills-cmd.js +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +15 -0
- package/dist/commands/plugin.d.ts +6 -2
- package/dist/commands/plugin.js +18 -4
- package/dist/lib/agent-skills.d.ts +9 -0
- package/dist/lib/agent-skills.js +122 -1
- package/dist/lib/auto-skill-install.d.ts +5 -0
- package/dist/lib/auto-skill-install.js +40 -2
- package/dist/lib/plugin.d.ts +9 -0
- package/dist/lib/plugin.js +31 -1
- package/dist/lib/skill-install-gate.d.ts +67 -0
- package/dist/lib/skill-install-gate.js +99 -0
- package/dist/lib/skill-scanner.d.ts +80 -0
- package/dist/lib/skill-scanner.js +281 -0
- package/dist/ui/AutoSkills.d.ts +3 -1
- package/dist/ui/AutoSkills.js +27 -2
- package/dist/ui/Plugin.d.ts +3 -1
- package/dist/ui/Plugin.js +4 -4
- package/package.json +9 -5
|
@@ -46,7 +46,7 @@ export function handlePlugin(cli, ctx) {
|
|
|
46
46
|
: "list";
|
|
47
47
|
const target = cli.input[2];
|
|
48
48
|
render(React.createElement(CIContextProvider, { isCI: ctx.isCI },
|
|
49
|
-
React.createElement(Plugin, { action: action, target: target, dryRun: cli.flags.dryRun, codex: cli.flags.codex })), { stdin: ctx.inkStdin });
|
|
49
|
+
React.createElement(Plugin, { action: action, target: target, dryRun: cli.flags.dryRun, codex: cli.flags.codex, force: cli.flags.force })), { stdin: ctx.inkStdin });
|
|
50
50
|
}
|
|
51
51
|
export function handleInitDefault(cli, ctx) {
|
|
52
52
|
const presetStack = VALID_STACKS.includes(cli.flags.stack)
|
|
@@ -37,7 +37,7 @@ export async function handleSkillsCmd(cli, ctx) {
|
|
|
37
37
|
// Auto / auto-install: render interactive Ink UI
|
|
38
38
|
if (skillsAction === "auto" || skillsAction === "auto-install") {
|
|
39
39
|
render(React.createElement(CIContextProvider, { isCI: ctx.isCI },
|
|
40
|
-
React.createElement(AutoSkills, { projectDir: process.cwd(), skillsDir: cli.flags.skillsDir || undefined, dryRun: cli.flags.dryRun })), { stdin: ctx.inkStdin });
|
|
40
|
+
React.createElement(AutoSkills, { projectDir: process.cwd(), skillsDir: cli.flags.skillsDir || undefined, dryRun: cli.flags.dryRun, force: cli.flags.force })), { stdin: ctx.inkStdin });
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
// Score and benchmark are non-interactive CLI commands
|
package/dist/cli/help.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Help banner shown by meow when `--help` is passed or invalid args are supplied.
|
|
9
9
|
* Multi-line template literal — preserve exact formatting (whitespace is significant).
|
|
10
10
|
*/
|
|
11
|
-
export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
|
|
11
|
+
export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n SkillGuard install gate (plugin add / plugin import / skills auto)\n Every install is scanned before anything is written. Refusals are\n fail-closed and name the offending files:\n - SKILL.md files that block (critical threats) are refused \u2014 always.\n - Unscannable files (binary, oversized, unreadable) are refused unless\n --force is given.\n - Symlinks anywhere in the tree and SKILL.md files outside the declared\n set are manifest-integrity refusals \u2014 they are refused even with --force.\n - Empty or missing skills.json `skills` array on import is refused.\n A refused install/auto-install exits non-zero (exit 1) so scripts and CI\n can tell a refusal apart from success; clean installs \u2014 including\n --force-lifted unscannable ones \u2014 exit 0.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge plugin add org/repo\n $ javi-forge plugin add org/repo --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
|
|
12
12
|
/**
|
|
13
13
|
* Per-command help for `ci`, shown by `javi-forge ci --help` (or when `ci` is
|
|
14
14
|
* given an unknown subcommand). Kept consistent with the global HELP_TEXT
|
package/dist/cli/help.js
CHANGED
|
@@ -89,6 +89,19 @@ export const HELP_TEXT = `
|
|
|
89
89
|
cannot be written, the hook is left untouched. Symlinked
|
|
90
90
|
hook paths are refused even with --force.
|
|
91
91
|
|
|
92
|
+
SkillGuard install gate (plugin add / plugin import / skills auto)
|
|
93
|
+
Every install is scanned before anything is written. Refusals are
|
|
94
|
+
fail-closed and name the offending files:
|
|
95
|
+
- SKILL.md files that block (critical threats) are refused — always.
|
|
96
|
+
- Unscannable files (binary, oversized, unreadable) are refused unless
|
|
97
|
+
--force is given.
|
|
98
|
+
- Symlinks anywhere in the tree and SKILL.md files outside the declared
|
|
99
|
+
set are manifest-integrity refusals — they are refused even with --force.
|
|
100
|
+
- Empty or missing skills.json \`skills\` array on import is refused.
|
|
101
|
+
A refused install/auto-install exits non-zero (exit 1) so scripts and CI
|
|
102
|
+
can tell a refusal apart from success; clean installs — including
|
|
103
|
+
--force-lifted unscannable ones — exit 0.
|
|
104
|
+
|
|
92
105
|
Examples
|
|
93
106
|
$ javi-forge
|
|
94
107
|
$ javi-forge init --dry-run
|
|
@@ -96,6 +109,8 @@ export const HELP_TEXT = `
|
|
|
96
109
|
$ javi-forge ci
|
|
97
110
|
$ javi-forge ci init
|
|
98
111
|
$ javi-forge ci init --force
|
|
112
|
+
$ javi-forge plugin add org/repo
|
|
113
|
+
$ javi-forge plugin add org/repo --force
|
|
99
114
|
$ javi-forge tdd init
|
|
100
115
|
$ javi-forge ci --quick
|
|
101
116
|
$ javi-forge ci --no-ci-ghagga --no-security
|
|
@@ -3,7 +3,9 @@ type StepCallback = (step: InitStep) => void;
|
|
|
3
3
|
/**
|
|
4
4
|
* Add (install) a plugin from a GitHub source.
|
|
5
5
|
*/
|
|
6
|
-
export declare function runPluginAdd(source: string, dryRun: boolean, onStep: StepCallback
|
|
6
|
+
export declare function runPluginAdd(source: string, dryRun: boolean, onStep: StepCallback, options?: {
|
|
7
|
+
force?: boolean;
|
|
8
|
+
}): Promise<void>;
|
|
7
9
|
/**
|
|
8
10
|
* Remove an installed plugin by name.
|
|
9
11
|
*/
|
|
@@ -35,7 +37,9 @@ export declare function runPluginExportCodex(name: string, onStep: StepCallback)
|
|
|
35
37
|
/**
|
|
36
38
|
* Import an Agent Skills spec package and convert to javi-forge plugin format.
|
|
37
39
|
*/
|
|
38
|
-
export declare function runPluginImport(sourceDir: string, dryRun: boolean, onStep: StepCallback
|
|
40
|
+
export declare function runPluginImport(sourceDir: string, dryRun: boolean, onStep: StepCallback, options?: {
|
|
41
|
+
force?: boolean;
|
|
42
|
+
}): Promise<void>;
|
|
39
43
|
/**
|
|
40
44
|
* Generate a project-level skills.json from all installed plugins.
|
|
41
45
|
* Makes the project discoverable by `npx skills add` and 40+ AI agents.
|
package/dist/commands/plugin.js
CHANGED
|
@@ -7,10 +7,10 @@ function report(onStep, id, label, status, detail) {
|
|
|
7
7
|
/**
|
|
8
8
|
* Add (install) a plugin from a GitHub source.
|
|
9
9
|
*/
|
|
10
|
-
export async function runPluginAdd(source, dryRun, onStep) {
|
|
10
|
+
export async function runPluginAdd(source, dryRun, onStep, options = {}) {
|
|
11
11
|
const stepId = "plugin-add";
|
|
12
12
|
report(onStep, stepId, `Install plugin: ${source}`, "running");
|
|
13
|
-
const result = await installPlugin(source, { dryRun });
|
|
13
|
+
const result = await installPlugin(source, { dryRun, force: options.force });
|
|
14
14
|
if (result.success) {
|
|
15
15
|
report(onStep, stepId, `Install plugin: ${source}`, "done", dryRun
|
|
16
16
|
? `dry-run: would install ${result.name}`
|
|
@@ -18,6 +18,12 @@ export async function runPluginAdd(source, dryRun, onStep) {
|
|
|
18
18
|
}
|
|
19
19
|
else {
|
|
20
20
|
report(onStep, stepId, `Install plugin: ${source}`, "error", result.error);
|
|
21
|
+
// FU-1 (R4-002): a skillguard refusal must be distinguishable from
|
|
22
|
+
// success by scripted consumers — exit non-zero. `process.exitCode`
|
|
23
|
+
// (not `process.exit`) so the Ink tree keeps rendering/unmounting
|
|
24
|
+
// normally. Non-gate failures (validation, clone errors) keep exit 0.
|
|
25
|
+
if (result.refused)
|
|
26
|
+
process.exitCode = 1;
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
@@ -145,10 +151,13 @@ export async function runPluginExportCodex(name, onStep) {
|
|
|
145
151
|
/**
|
|
146
152
|
* Import an Agent Skills spec package and convert to javi-forge plugin format.
|
|
147
153
|
*/
|
|
148
|
-
export async function runPluginImport(sourceDir, dryRun, onStep) {
|
|
154
|
+
export async function runPluginImport(sourceDir, dryRun, onStep, options = {}) {
|
|
149
155
|
const stepId = "plugin-import";
|
|
150
156
|
report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "running");
|
|
151
|
-
const result = await importAgentSkillsPackage(sourceDir, {
|
|
157
|
+
const result = await importAgentSkillsPackage(sourceDir, {
|
|
158
|
+
dryRun,
|
|
159
|
+
force: options.force,
|
|
160
|
+
});
|
|
152
161
|
if (result.success) {
|
|
153
162
|
report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "done", dryRun
|
|
154
163
|
? `dry-run: would import ${result.name}`
|
|
@@ -156,6 +165,11 @@ export async function runPluginImport(sourceDir, dryRun, onStep) {
|
|
|
156
165
|
}
|
|
157
166
|
else {
|
|
158
167
|
report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "error", result.error);
|
|
168
|
+
// FU-1 (R4-002): same exit-code contract as runPluginAdd — a skillguard
|
|
169
|
+
// refusal (manifest-integrity or verdict) exits non-zero; plain input
|
|
170
|
+
// errors (skills.json missing/invalid) keep exit 0.
|
|
171
|
+
if (result.refused)
|
|
172
|
+
process.exitCode = 1;
|
|
159
173
|
}
|
|
160
174
|
}
|
|
161
175
|
/**
|
|
@@ -30,10 +30,19 @@ export declare function exportPluginAsAgentSkills(name: string): Promise<{
|
|
|
30
30
|
*/
|
|
31
31
|
export declare function importAgentSkillsPackage(sourceDir: string, options?: {
|
|
32
32
|
dryRun?: boolean;
|
|
33
|
+
force?: boolean;
|
|
33
34
|
}): Promise<{
|
|
34
35
|
success: boolean;
|
|
35
36
|
name?: string;
|
|
36
37
|
error?: string;
|
|
38
|
+
/**
|
|
39
|
+
* FU-1 (R4-002): true when the failure is a skillguard gate refusal
|
|
40
|
+
* (manifest-integrity — invalid name, empty/missing skills, containment
|
|
41
|
+
* escape, walk errors/symlinks/undeclared — or verdict refusal, incl. a
|
|
42
|
+
* scanner-error deny). The CLI layer turns this into a non-zero exit
|
|
43
|
+
* code. Plain input errors (skills.json missing/invalid) leave it unset.
|
|
44
|
+
*/
|
|
45
|
+
refused?: boolean;
|
|
37
46
|
}>;
|
|
38
47
|
/**
|
|
39
48
|
* Aggregate multiple installed plugins into a single Agent Skills spec manifest.
|
package/dist/lib/agent-skills.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { AGENT_SKILLS_MANIFEST_FILE, PLUGIN_MANIFEST_FILE, PLUGINS_DIR, } from "../constants.js";
|
|
4
|
+
import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.js";
|
|
5
|
+
import { checkPathContained, scanSkillsWithCoverage } from "./skill-scanner.js";
|
|
4
6
|
// ── Conversion ─────────────────────────────────────────────────────────────
|
|
5
7
|
/**
|
|
6
8
|
* Convert a javi-forge PluginManifest to an Agent Skills spec manifest.
|
|
@@ -68,7 +70,7 @@ export async function exportPluginAsAgentSkills(name) {
|
|
|
68
70
|
* Reads skills.json, converts to plugin.json, copies to plugins dir.
|
|
69
71
|
*/
|
|
70
72
|
export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
71
|
-
const { dryRun = false } = options;
|
|
73
|
+
const { dryRun = false, force = false } = options;
|
|
72
74
|
const skillsPath = path.join(sourceDir, AGENT_SKILLS_MANIFEST_FILE);
|
|
73
75
|
if (!(await fs.pathExists(skillsPath))) {
|
|
74
76
|
return { success: false, error: "skills.json not found" };
|
|
@@ -88,11 +90,112 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
|
88
90
|
error: "skills.json missing required fields (name, version, description)",
|
|
89
91
|
};
|
|
90
92
|
}
|
|
93
|
+
// R1-002: `name` becomes the import destination (`destDir = path.join(
|
|
94
|
+
// PLUGINS_DIR, name)`) and is handed to `fs.remove` + `fs.copy` below with
|
|
95
|
+
// NO path validation — a hostile skills.json name (e.g. `"../../.bashrc"`,
|
|
96
|
+
// an absolute path, or a separator-bearing name) would delete/copy
|
|
97
|
+
// ARBITRARY paths outside PLUGINS_DIR. The gate validates declared
|
|
98
|
+
// `skills[].path` escapes but never the name that determines the
|
|
99
|
+
// write/delete destination. Same-trust note: `name` is attacker-influenced
|
|
100
|
+
// when installing from a registry package, not a typed-in label — refuse
|
|
101
|
+
// BEFORE any `fs.remove`/`fs.copy`/`destDir` use, so an existing install
|
|
102
|
+
// is preserved (style-consistent with the gate's manifest-integrity
|
|
103
|
+
// refusals, block-level, force never lifts).
|
|
91
104
|
const pluginName = agentManifest.name;
|
|
105
|
+
// R1-F2-N2: `name` is attacker-influenced JSON — it need not be a string at
|
|
106
|
+
// all (`{"name": 123}` is truthy, so it sails past the required-fields
|
|
107
|
+
// check above). Guard the type BEFORE the `.trim()` check: a non-string
|
|
108
|
+
// name refuses cleanly with the same manifest-integrity message instead of
|
|
109
|
+
// throwing a TypeError out of `.trim()` (which propagated to the UI as a
|
|
110
|
+
// "Fatal error").
|
|
111
|
+
if (typeof pluginName !== "string") {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
refused: true,
|
|
115
|
+
error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (pluginName.trim() === "" ||
|
|
119
|
+
pluginName === "." ||
|
|
120
|
+
pluginName.includes("..") ||
|
|
121
|
+
pluginName.includes("/") ||
|
|
122
|
+
pluginName.includes("\\") ||
|
|
123
|
+
path.isAbsolute(pluginName)) {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
refused: true,
|
|
127
|
+
error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// JD-006: import requires a non-empty, well-formed skills array. Each entry
|
|
131
|
+
// must carry a `name` and a `path` that resolves INSIDE sourceDir (normalized
|
|
132
|
+
// + realpath containment — "../../x" or an absolute path refuses; the gate
|
|
133
|
+
// never reads outside the staged clone, JD-003).
|
|
134
|
+
if (!Array.isArray(agentManifest.skills) ||
|
|
135
|
+
agentManifest.skills.length === 0) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
refused: true,
|
|
139
|
+
error: "skills.json must declare a non-empty skills array (every skill-shaped file must be declared)",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const sourceRootAbs = path.resolve(sourceDir);
|
|
143
|
+
const sourceRootReal = await fs.realpath(sourceRootAbs);
|
|
144
|
+
const declaredPaths = [];
|
|
145
|
+
for (const entry of agentManifest.skills) {
|
|
146
|
+
if (!entry || typeof entry.name !== "string" || !entry.name) {
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
refused: true,
|
|
150
|
+
error: "skills.json skills entry missing name",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (typeof entry.path !== "string" || !entry.path) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
refused: true,
|
|
157
|
+
error: `skills.json skills entry "${entry.name}" missing path`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const contained = await skillPathContained(sourceRootAbs, sourceRootReal, entry.path);
|
|
161
|
+
if (!contained.ok) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
refused: true,
|
|
165
|
+
error: `skills.json skills entry "${entry.name}" path escapes package root (${contained.reason}) — refusing`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
declaredPaths.push(entry.path);
|
|
169
|
+
}
|
|
92
170
|
if (dryRun) {
|
|
93
171
|
return { success: true, name: pluginName };
|
|
94
172
|
}
|
|
95
173
|
const destDir = path.join(PLUGINS_DIR, pluginName);
|
|
174
|
+
// ── SkillGuard runtime gate (D8, JD-001/JD-003/JD-006/JD-007) ──────────
|
|
175
|
+
// Runs BEFORE the existing-install remove and fs.copy: a refusal preserves
|
|
176
|
+
// an existing install and installs nothing. dryRun early-returns above, so
|
|
177
|
+
// no scan happens on dry-run. Scanner/eval errors deny unconditionally (D7).
|
|
178
|
+
// The refusal policy + message-building is shared with plugin add via
|
|
179
|
+
// evaluateCoverageGate (R2-001).
|
|
180
|
+
let coverage;
|
|
181
|
+
try {
|
|
182
|
+
coverage = await scanSkillsWithCoverage(sourceDir, declaredPaths);
|
|
183
|
+
}
|
|
184
|
+
catch (scanError) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
refused: true,
|
|
188
|
+
error: scanFailureMessage(scanError),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const decision = evaluateCoverageGate(coverage, { force });
|
|
192
|
+
if (decision.refusalError) {
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
refused: true,
|
|
196
|
+
error: decision.refusalError,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
96
199
|
// Remove existing version if present
|
|
97
200
|
if (await fs.pathExists(destDir)) {
|
|
98
201
|
await fs.remove(destDir);
|
|
@@ -117,6 +220,24 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
|
|
|
117
220
|
});
|
|
118
221
|
return { success: true, name: pluginName };
|
|
119
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Verify a declared skill entry path stays inside the package root — both
|
|
225
|
+
* lexically (`../../x`, absolute paths) and by realpath, so an in-tree symlink
|
|
226
|
+
* cannot redirect the import read outside the staged clone (JD-003/JD-006).
|
|
227
|
+
* Non-throwing `{ ok, reason }` surface over the shared containment core
|
|
228
|
+
* (`checkPathContained`, R2-002) — the import gate refuses gracefully instead
|
|
229
|
+
* of throwing.
|
|
230
|
+
*/
|
|
231
|
+
async function skillPathContained(rootAbs, rootReal, entryPath) {
|
|
232
|
+
const check = await checkPathContained(rootAbs, rootReal, entryPath);
|
|
233
|
+
if (!check.ok) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
reason: `path "${entryPath}" resolves outside the package root${check.violation === "realpath" ? " (realpath)" : ""}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { ok: true };
|
|
240
|
+
}
|
|
120
241
|
// ── Aggregation ──────────────────────────────────────────────────────────
|
|
121
242
|
/**
|
|
122
243
|
* Aggregate multiple installed plugins into a single Agent Skills spec manifest.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SkillScanResult } from "./skill-scanner.js";
|
|
1
2
|
import type { StackDetectionResult } from "./stack-detector.js";
|
|
2
3
|
export interface SkillInstallResult {
|
|
3
4
|
/** Skills that were successfully installed (copied) */
|
|
@@ -6,6 +7,8 @@ export interface SkillInstallResult {
|
|
|
6
7
|
skipped: string[];
|
|
7
8
|
/** Skills that were recommended but not found in the source */
|
|
8
9
|
notFound: string[];
|
|
10
|
+
/** Skills refused by the skillguard gate (block / unscannable w/o force) */
|
|
11
|
+
blocked: SkillScanResult[];
|
|
9
12
|
/** The full detection result for reporting */
|
|
10
13
|
detection: StackDetectionResult;
|
|
11
14
|
}
|
|
@@ -18,6 +21,8 @@ export interface AutoInstallOptions {
|
|
|
18
21
|
skillsTargetDir?: string;
|
|
19
22
|
/** If true, skip actually copying files */
|
|
20
23
|
dryRun?: boolean;
|
|
24
|
+
/** Bypass the gate for unscannable sources ONLY — block always refuses (D2) */
|
|
25
|
+
force?: boolean;
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
23
28
|
* Scan a project, detect its stack, and install matching AI skills.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { DEFAULT_SKILLS_DIR } from "../commands/skills/constants.js";
|
|
4
|
+
import { evaluateInstallGate } from "./skill-install-gate.js";
|
|
5
|
+
import { scanSkillFile } from "./skill-scanner.js";
|
|
4
6
|
import { detectProjectStack } from "./stack-detector.js";
|
|
5
7
|
// ── Core ───────────────────────────────────────────────────────────────────
|
|
6
8
|
/**
|
|
@@ -14,7 +16,7 @@ import { detectProjectStack } from "./stack-detector.js";
|
|
|
14
16
|
* source to target (useful for project-local skill installation).
|
|
15
17
|
*/
|
|
16
18
|
export async function autoInstallSkills(options) {
|
|
17
|
-
const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, } = options;
|
|
19
|
+
const { projectDir, skillsSourceDir = DEFAULT_SKILLS_DIR, skillsTargetDir = DEFAULT_SKILLS_DIR, dryRun = false, force = false, } = options;
|
|
18
20
|
// 1. Detect project stack
|
|
19
21
|
const detection = await detectProjectStack(projectDir);
|
|
20
22
|
if (detection.recommendedSkills.length === 0) {
|
|
@@ -22,6 +24,7 @@ export async function autoInstallSkills(options) {
|
|
|
22
24
|
installed: [],
|
|
23
25
|
skipped: [],
|
|
24
26
|
notFound: [],
|
|
27
|
+
blocked: [],
|
|
25
28
|
detection,
|
|
26
29
|
};
|
|
27
30
|
}
|
|
@@ -29,6 +32,10 @@ export async function autoInstallSkills(options) {
|
|
|
29
32
|
const skipped = [];
|
|
30
33
|
const notFound = [];
|
|
31
34
|
const sameDir = path.resolve(skillsSourceDir) === path.resolve(skillsTargetDir);
|
|
35
|
+
// 2. Classify: notFound / sameDir-skipped / target-skipped / copyable.
|
|
36
|
+
// Predicates unchanged from pre-gate behavior; sameDir short-circuits
|
|
37
|
+
// BEFORE any scanning (D4).
|
|
38
|
+
const copyable = [];
|
|
32
39
|
for (const skillName of detection.recommendedSkills) {
|
|
33
40
|
const sourcePath = path.join(skillsSourceDir, skillName);
|
|
34
41
|
const targetPath = path.join(skillsTargetDir, skillName);
|
|
@@ -49,6 +56,32 @@ export async function autoInstallSkills(options) {
|
|
|
49
56
|
skipped.push(skillName);
|
|
50
57
|
continue;
|
|
51
58
|
}
|
|
59
|
+
copyable.push(skillName);
|
|
60
|
+
}
|
|
61
|
+
// 3. SkillGuard gate (D4, JD-009): scan-gate every copyable source via its
|
|
62
|
+
// folder-root SKILL.md. dryRun still scans (read-only) but copies nothing.
|
|
63
|
+
// A scan THROW rejects the whole function — nothing copied, matching the
|
|
64
|
+
// UI error path (AutoSkills.tsx:41-44).
|
|
65
|
+
const scans = [];
|
|
66
|
+
for (const skillName of copyable) {
|
|
67
|
+
const sourceSkillMd = path.join(skillsSourceDir, skillName, "SKILL.md");
|
|
68
|
+
scans.push(await scanSkillFile(sourceSkillMd));
|
|
69
|
+
}
|
|
70
|
+
const gate = evaluateInstallGate(scans, { force });
|
|
71
|
+
if (!gate.allowed) {
|
|
72
|
+
// Any block (or unscannable without force) ⇒ copy NOTHING.
|
|
73
|
+
return {
|
|
74
|
+
installed: [],
|
|
75
|
+
skipped,
|
|
76
|
+
notFound,
|
|
77
|
+
blocked: gate.rejected,
|
|
78
|
+
detection,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// 4. Copy every copyable skill — nothing was refused.
|
|
82
|
+
for (const skillName of copyable) {
|
|
83
|
+
const sourcePath = path.join(skillsSourceDir, skillName);
|
|
84
|
+
const targetPath = path.join(skillsTargetDir, skillName);
|
|
52
85
|
// Copy skill to target
|
|
53
86
|
if (!dryRun) {
|
|
54
87
|
await fs.ensureDir(targetPath);
|
|
@@ -59,7 +92,7 @@ export async function autoInstallSkills(options) {
|
|
|
59
92
|
}
|
|
60
93
|
installed.push(skillName);
|
|
61
94
|
}
|
|
62
|
-
return { installed, skipped, notFound, detection };
|
|
95
|
+
return { installed, skipped, notFound, blocked: [], detection };
|
|
63
96
|
}
|
|
64
97
|
/**
|
|
65
98
|
* Get a human-readable summary of auto-install results.
|
|
@@ -86,6 +119,11 @@ export function formatAutoInstallSummary(result) {
|
|
|
86
119
|
if (result.notFound.length > 0) {
|
|
87
120
|
lines.push(` Not found: ${result.notFound.join(", ")}`);
|
|
88
121
|
}
|
|
122
|
+
if (result.blocked.length > 0) {
|
|
123
|
+
lines.push(` Blocked: ${result.blocked
|
|
124
|
+
.map((b) => `${b.skillName} [${b.verdict.toUpperCase()}]`)
|
|
125
|
+
.join(", ")}`);
|
|
126
|
+
}
|
|
89
127
|
return lines.join("\n");
|
|
90
128
|
}
|
|
91
129
|
//# sourceMappingURL=auto-skill-install.js.map
|
package/dist/lib/plugin.d.ts
CHANGED
|
@@ -9,10 +9,19 @@ export declare function validatePlugin(pluginDir: string): Promise<PluginValidat
|
|
|
9
9
|
*/
|
|
10
10
|
export declare function installPlugin(source: string, options?: {
|
|
11
11
|
dryRun?: boolean;
|
|
12
|
+
force?: boolean;
|
|
12
13
|
}): Promise<{
|
|
13
14
|
success: boolean;
|
|
14
15
|
name?: string;
|
|
15
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;
|
|
16
25
|
}>;
|
|
17
26
|
/**
|
|
18
27
|
* Remove an installed plugin by name.
|
package/dist/lib/plugin.js
CHANGED
|
@@ -4,6 +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 { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.js";
|
|
8
|
+
import { scanSkillsWithCoverage } from "./skill-scanner.js";
|
|
7
9
|
const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
8
10
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
9
11
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
@@ -112,7 +114,7 @@ export async function validatePlugin(pluginDir) {
|
|
|
112
114
|
* Clones the repo to a temp dir, validates, then copies to plugins dir.
|
|
113
115
|
*/
|
|
114
116
|
export async function installPlugin(source, options = {}) {
|
|
115
|
-
const { dryRun = false } = options;
|
|
117
|
+
const { dryRun = false, force = false } = options;
|
|
116
118
|
// Normalize source to a git URL
|
|
117
119
|
const gitUrl = normalizeGitUrl(source);
|
|
118
120
|
if (!gitUrl) {
|
|
@@ -151,6 +153,34 @@ export async function installPlugin(source, options = {}) {
|
|
|
151
153
|
const pluginName = validation.manifest.name;
|
|
152
154
|
const destDir = path.join(PLUGINS_DIR, pluginName);
|
|
153
155
|
if (!dryRun) {
|
|
156
|
+
// ── SkillGuard runtime gate (D1/D3, JD-006/JD-007) ────────────
|
|
157
|
+
// Runs BEFORE the existing-install remove and fs.move: a refusal
|
|
158
|
+
// leaves staging intact (removed by `finally`) and never destroys a
|
|
159
|
+
// prior install. dryRun skips the gate entirely (no staged clone).
|
|
160
|
+
// Scanner/eval errors deny unconditionally (D7 — a throw is not a
|
|
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;
|
|
165
|
+
try {
|
|
166
|
+
const declaredPaths = (validation.manifest.skills ?? []).map((skill) => path.join("skills", skill));
|
|
167
|
+
coverage = await scanSkillsWithCoverage(tmpDir, declaredPaths);
|
|
168
|
+
}
|
|
169
|
+
catch (scanError) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
refused: true,
|
|
173
|
+
error: scanFailureMessage(scanError),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const decision = evaluateCoverageGate(coverage, { force });
|
|
177
|
+
if (decision.refusalError) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
refused: true,
|
|
181
|
+
error: decision.refusalError,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
154
184
|
// Remove existing version if present
|
|
155
185
|
if (await fs.pathExists(destDir)) {
|
|
156
186
|
await fs.remove(destDir);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
13
|
+
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
import type { SkillCoverageScan, SkillScanResult } from "./skill-scanner.js";
|
|
20
|
+
export interface InstallGateDecision {
|
|
21
|
+
allowed: boolean;
|
|
22
|
+
/** Rejected results when `!allowed`, else `[]`. */
|
|
23
|
+
rejected: SkillScanResult[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Evaluate a set of declared-skill scan results against the install gate.
|
|
27
|
+
*
|
|
28
|
+
* `allowed = !hasBlock && (rejected.length === 0 || force)` — a `block` verdict
|
|
29
|
+
* refuses unconditionally; `force` bypasses ONLY `unscannable`. Empty,
|
|
30
|
+
* pass, and warn results are allowed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function evaluateInstallGate(results: SkillScanResult[], options?: {
|
|
33
|
+
force?: boolean;
|
|
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;
|
|
67
|
+
//# sourceMappingURL=skill-install-gate.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
13
|
+
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
import { formatBatchReport, isRejectedVerdict } from "./skill-scanner.js";
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate a set of declared-skill scan results against the install gate.
|
|
22
|
+
*
|
|
23
|
+
* `allowed = !hasBlock && (rejected.length === 0 || force)` — a `block` verdict
|
|
24
|
+
* refuses unconditionally; `force` bypasses ONLY `unscannable`. Empty,
|
|
25
|
+
* pass, and warn results are allowed.
|
|
26
|
+
*/
|
|
27
|
+
export function evaluateInstallGate(results, options) {
|
|
28
|
+
const rejected = results.filter((r) => isRejectedVerdict(r.verdict));
|
|
29
|
+
const hasBlock = results.some((r) => r.verdict === "block");
|
|
30
|
+
const force = options?.force ?? false;
|
|
31
|
+
const allowed = !hasBlock && (rejected.length === 0 || force);
|
|
32
|
+
return { allowed, rejected: allowed ? [] : rejected };
|
|
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
|
+
}
|
|
99
|
+
//# sourceMappingURL=skill-install-gate.js.map
|
|
@@ -78,6 +78,86 @@ export declare function scanSkillFile(filePath: string): Promise<SkillScanResult
|
|
|
78
78
|
* Useful for scanning a plugin's skills directory before installation.
|
|
79
79
|
*/
|
|
80
80
|
export declare function scanSkillsDirectory(dir: string): Promise<SkillScanResult[]>;
|
|
81
|
+
export interface SkillCoverageScan {
|
|
82
|
+
/**
|
|
83
|
+
* Scan results for the declared-entry SKILL.md files — the ONLY files the
|
|
84
|
+
* walk content-scans (JD-005). A declared file that is a symlink is never
|
|
85
|
+
* read through (it is already in {@link symlinks}); a missing declared
|
|
86
|
+
* SKILL.md fails closed as `unscannable`.
|
|
87
|
+
*/
|
|
88
|
+
declared: SkillScanResult[];
|
|
89
|
+
/**
|
|
90
|
+
* Skill-shaped files (basename `SKILL.md`/`skill.md`) found in the tree
|
|
91
|
+
* OUTSIDE the declared set — including under `node_modules`/`.git` (JD-007).
|
|
92
|
+
* Paths only; content is never read during the walk (JD-005).
|
|
93
|
+
*/
|
|
94
|
+
undeclared: string[];
|
|
95
|
+
/**
|
|
96
|
+
* ANY symlink (file or dir) found in the tree. The caller refuses on this
|
|
97
|
+
* (manifest-integrity, block-level, force never lifts — JD-007); the walk
|
|
98
|
+
* never dereferences them (JD-003).
|
|
99
|
+
*/
|
|
100
|
+
symlinks: string[];
|
|
101
|
+
/**
|
|
102
|
+
* Paths the walk could not enumerate or stat (realpath/readdir/lstat I/O
|
|
103
|
+
* failure — e.g. an unreadable subtree). An incomplete walk cannot certify
|
|
104
|
+
* the installed footprint, so the caller refuses on this (manifest-
|
|
105
|
+
* integrity, block-level, force never lifts — JD-013); a silent `return`/
|
|
106
|
+
* `continue` would treat the broken subtree as empty and let the install
|
|
107
|
+
* proceed un-scanned.
|
|
108
|
+
*/
|
|
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[];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
|
|
127
|
+
*
|
|
128
|
+
* Visits the ENTIRE tree with NO `node_modules`/`.git` exemption, so the visit
|
|
129
|
+
* set is exactly the footprint `fs.move`/`fs.copy` will place (JD-007). Collects
|
|
130
|
+
* only basename `SKILL.md`/`skill.md` files — never `PLUGIN.md`/README content
|
|
131
|
+
* (JD-002) — and flags ANY symlink (file or dir) without dereferencing it
|
|
132
|
+
* (JD-007/JD-003). A realpath visited-set terminates cycles defensively even if
|
|
133
|
+
* a future caller ever recurses through a link (JD-003). The walk itself does NO
|
|
134
|
+
* content reads: only the declared-entry files are handed to
|
|
135
|
+
* {@link scanSkillFile} afterwards (JD-005).
|
|
136
|
+
*/
|
|
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>;
|
|
81
161
|
export declare function formatScanReport(result: SkillScanResult): string;
|
|
82
162
|
export declare function formatBatchReport(results: SkillScanResult[]): string;
|
|
83
163
|
export {};
|
|
@@ -394,6 +394,287 @@ export async function scanSkillsDirectory(dir) {
|
|
|
394
394
|
await walk(dir);
|
|
395
395
|
return results;
|
|
396
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
|
|
399
|
+
*
|
|
400
|
+
* Visits the ENTIRE tree with NO `node_modules`/`.git` exemption, so the visit
|
|
401
|
+
* set is exactly the footprint `fs.move`/`fs.copy` will place (JD-007). Collects
|
|
402
|
+
* only basename `SKILL.md`/`skill.md` files — never `PLUGIN.md`/README content
|
|
403
|
+
* (JD-002) — and flags ANY symlink (file or dir) without dereferencing it
|
|
404
|
+
* (JD-007/JD-003). A realpath visited-set terminates cycles defensively even if
|
|
405
|
+
* a future caller ever recurses through a link (JD-003). The walk itself does NO
|
|
406
|
+
* content reads: only the declared-entry files are handed to
|
|
407
|
+
* {@link scanSkillFile} afterwards (JD-005).
|
|
408
|
+
*/
|
|
409
|
+
export async function scanSkillsWithCoverage(dir, declaredPaths) {
|
|
410
|
+
// Resolve the scan root once. Declared entries must stay inside it — a
|
|
411
|
+
// hostile manifest can never make the gate read outside the staged clone
|
|
412
|
+
// (JD-003: "no read outside the staged clone"; the interface contract names
|
|
413
|
+
// declared paths realpath-contained).
|
|
414
|
+
const rootAbs = path.resolve(dir);
|
|
415
|
+
const rootReal = await fs.realpath(rootAbs);
|
|
416
|
+
// Resolve each declared entry once (containment-verified) and reuse it for
|
|
417
|
+
// both the coverage set and the content scan — never twice.
|
|
418
|
+
const declaredDirs = new Map();
|
|
419
|
+
for (const entry of declaredPaths) {
|
|
420
|
+
declaredDirs.set(entry, await resolveContained(rootAbs, rootReal, entry));
|
|
421
|
+
}
|
|
422
|
+
// Declared skill DIRECTORIES — membership is case-insensitive by declared
|
|
423
|
+
// directory, never by file-basename spelling (R1-001/R3-001/R4-001). The
|
|
424
|
+
// walk collects ANY entry whose lowercased basename is `skill.md`, because
|
|
425
|
+
// the installed footprint is the on-disk tree whether the author wrote
|
|
426
|
+
// `SKILL.md`, `Skill.md`, `SKILL.MD` or any other fold — a declared skill's
|
|
427
|
+
// file must be recognized as declared no matter its case. Seeding exact-case
|
|
428
|
+
// basenames (F1/JD-011 seeded `SKILL.md` + `skill.md`) still missed every
|
|
429
|
+
// other fold: the file was collected, failed membership, and landed in
|
|
430
|
+
// `undeclared` — a block-level refusal `--force` never lifts — while the
|
|
431
|
+
// declared scan reported `unscannable` (permanent lockout for a legit
|
|
432
|
+
// declared skill). Membership by declared DIRECTORY keeps the smuggling
|
|
433
|
+
// refusal intact: a skill-shaped file whose parent dir is NOT a declared
|
|
434
|
+
// dir still misses the set and refuses as undeclared (CASE3).
|
|
435
|
+
const declaredDirAbs = new Set(declaredDirs.values());
|
|
436
|
+
// R1-F2-N1: declared-dir membership must ALSO fold case on the DIRECTORY
|
|
437
|
+
// name. `declaredDirAbs` retains MANIFEST case (`resolveContained` returns
|
|
438
|
+
// the manifest-spelled `entryAbs`); a package authored on a case-
|
|
439
|
+
// insensitive FS can declare `skills/Alpha` while the disk tree carries
|
|
440
|
+
// `skills/alpha` — the on-disk footprint the walk actually sees. Exact-
|
|
441
|
+
// case membership left the walk's dirname check missing the set → the file
|
|
442
|
+
// landed `undeclared` (block-level, force never lifts) while the declared
|
|
443
|
+
// scan reported the manifest-case path `unscannable` — the third instance
|
|
444
|
+
// of the case-lockout class (JD-011 file, R1-001 file-fold, dir-name
|
|
445
|
+
// fold). Compare lowercased on BOTH sides; the real on-disk path is still
|
|
446
|
+
// the one scanned below (never invent casing for file access).
|
|
447
|
+
const declaredDirAbsLower = new Set([...declaredDirAbs].map((d) => d.toLowerCase()));
|
|
448
|
+
const undeclared = [];
|
|
449
|
+
const symlinks = [];
|
|
450
|
+
const errors = [];
|
|
451
|
+
// Real on-disk dirs the walk actually visited (readdir-spelled casing) —
|
|
452
|
+
// used AFTER the walk to resolve declared dirs whose manifest spelling
|
|
453
|
+
// differs in case from the disk (R1-F2-N1); see the declared scan below.
|
|
454
|
+
const walkDirs = [];
|
|
455
|
+
const visited = new Set();
|
|
456
|
+
async function walk(currentDir) {
|
|
457
|
+
// realpath visited-set: a defensive cycle invariant (JD-003). Symlinks are
|
|
458
|
+
// never recursed into, so no cycle can form through the walk itself; the
|
|
459
|
+
// set guarantees termination even if that ever changes.
|
|
460
|
+
let real;
|
|
461
|
+
try {
|
|
462
|
+
real = await fs.realpath(currentDir);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Fail-closed (JD-013): an unlistable subtree must surface as an
|
|
466
|
+
// error the caller refuses on, not silently read as empty.
|
|
467
|
+
errors.push(currentDir);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (visited.has(real))
|
|
471
|
+
return;
|
|
472
|
+
visited.add(real);
|
|
473
|
+
walkDirs.push(currentDir);
|
|
474
|
+
let entries;
|
|
475
|
+
try {
|
|
476
|
+
entries = await fs.readdir(currentDir);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Fail-closed (JD-013): same as realpath above — record, do not
|
|
480
|
+
// swallow, so the caller can refuse an incomplete walk.
|
|
481
|
+
errors.push(currentDir);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
for (const entry of entries) {
|
|
485
|
+
const fullPath = path.join(currentDir, entry);
|
|
486
|
+
let lst;
|
|
487
|
+
try {
|
|
488
|
+
lst = await fs.lstat(fullPath);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Fail-closed (JD-013): a path we cannot stat (race, I/O, or
|
|
492
|
+
// permission) must not silently vanish from the footprint
|
|
493
|
+
// inventory — record it and keep walking the rest.
|
|
494
|
+
errors.push(fullPath);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// Symlinks are never dereferenced: flagged for the caller's
|
|
498
|
+
// manifest-integrity refusal, never recursed into, never scanned
|
|
499
|
+
// (JD-007/JD-003).
|
|
500
|
+
if (lst.isSymbolicLink()) {
|
|
501
|
+
symlinks.push(fullPath);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (lst.isDirectory()) {
|
|
505
|
+
await walk(fullPath);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
// SKILL.md-only collection: basename SKILL.md/skill.md (any case
|
|
509
|
+
// fold), never PLUGIN.md or README (JD-002). Declared-ness is
|
|
510
|
+
// decided by the parent DIRECTORY being declared (R1-001): any case
|
|
511
|
+
// fold of the file inside a declared dir is declared, so the file
|
|
512
|
+
// is scanned — never flagged undeclared (block-level, force never
|
|
513
|
+
// lifts) for the exact-case spelling it happens to carry on disk.
|
|
514
|
+
if (entry.toLowerCase() === "skill.md") {
|
|
515
|
+
const resolved = path.resolve(fullPath);
|
|
516
|
+
if (!declaredDirAbsLower.has(path.dirname(resolved).toLowerCase())) {
|
|
517
|
+
undeclared.push(fullPath);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// R3-F3-N1: walk the ABSOLUTE root, never the caller's raw `dir`. With a
|
|
523
|
+
// relative invocation (`javi-forge plugin import <relative-dir>`), the
|
|
524
|
+
// walkDirs two-tier lookup below compares walk-visited paths against
|
|
525
|
+
// `declaredAbs` (always absolute) — a relative walk made every declared dir
|
|
526
|
+
// fall back to the manifest path, reporting an existing declared file as
|
|
527
|
+
// `unscannable` (force would lift it and install un-scanned content).
|
|
528
|
+
// Starting from `rootAbs` also keeps undeclared/symlinks/errors absolute
|
|
529
|
+
// and consistent with the gate's realpath expectations.
|
|
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
|
+
}
|
|
559
|
+
// Content-scanned results for declared entries only (JD-005), in declared
|
|
560
|
+
// order so reports are deterministic. A declared file that is a symlink is
|
|
561
|
+
// already in `symlinks` — reading through it would escape the tree (JD-003),
|
|
562
|
+
// so it is skipped here (the caller refuses on `symlinks` first anyway).
|
|
563
|
+
const symlinkSet = new Set(symlinks.map((p) => path.resolve(p)));
|
|
564
|
+
const declared = [];
|
|
565
|
+
// Iterating the map keeps declared order (insertion order == declaredPaths)
|
|
566
|
+
// and guarantees an entry cannot be absent once resolved.
|
|
567
|
+
for (const declaredAbs of declaredDirs.values()) {
|
|
568
|
+
// R1-F2-N1: the declared scan reads the REAL on-disk dir the walk saw —
|
|
569
|
+
// a manifest spelling `skills/Alpha` against a disk tree `skills/alpha`
|
|
570
|
+
// must scan the lowercase dir that exists. The two-tier lookup prefers
|
|
571
|
+
// the exact-case real dir when present, then a case-fold match; a truly
|
|
572
|
+
// missing declared dir (no real dir matches) falls back to the manifest
|
|
573
|
+
// path so it still fails closed as `unscannable` (unchanged). Only
|
|
574
|
+
// the real on-disk path is ever handed to
|
|
575
|
+
// `declaredSkillFileOnDisk`/`scanSkillFile` — no casing is invented for
|
|
576
|
+
// file access.
|
|
577
|
+
const realDir = walkDirs.find((d) => d === declaredAbs) ??
|
|
578
|
+
walkDirs.find((d) => d.toLowerCase() === declaredAbs.toLowerCase()) ??
|
|
579
|
+
declaredAbs;
|
|
580
|
+
// Case-tolerant resolution (JD-011/R1-001): a declared skill whose
|
|
581
|
+
// on-disk file is lowercase `skill.md` — or any other case fold
|
|
582
|
+
// (`Skill.md`, `SKILL.MD`, …) — is the same declared entry; scan the
|
|
583
|
+
// file that actually exists instead of reporting the exact-case path
|
|
584
|
+
// as a missing/unscannable file.
|
|
585
|
+
const file = await declaredSkillFileOnDisk(realDir);
|
|
586
|
+
// Whether the canonical or the lowercase variant, a symlinked declared
|
|
587
|
+
// file is already in `symlinks` — never read through it (JD-003/JD-007).
|
|
588
|
+
if (symlinkSet.has(path.resolve(file)))
|
|
589
|
+
continue;
|
|
590
|
+
declared.push(await scanSkillFile(file));
|
|
591
|
+
}
|
|
592
|
+
return { declared, undeclared, symlinks, errors, ambiguousDeclaredDirs };
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Resolve the on-disk skill file for a declared skill directory. The coverage
|
|
596
|
+
* walk recognizes ANY case fold of the `skill.md`/`SKILL.md` basename in a
|
|
597
|
+
* declared dir as the declared file (R1-001), so a declared entry may
|
|
598
|
+
* legitimately carry any fold on disk; favor the conventional exact-case name,
|
|
599
|
+
* fall back to the lowercase variant (JD-011), then to any other case fold via
|
|
600
|
+
* a case-insensitive readdir. When no skill-shaped file exists at all, return
|
|
601
|
+
* the canonical path so `scanSkillFile` reports the declared skill as
|
|
602
|
+
* `unscannable` (fail-closed, unchanged behavior).
|
|
603
|
+
*/
|
|
604
|
+
async function declaredSkillFileOnDisk(absDir) {
|
|
605
|
+
const canonical = path.join(absDir, "SKILL.md");
|
|
606
|
+
if (await fs.pathExists(canonical))
|
|
607
|
+
return canonical;
|
|
608
|
+
const lower = path.join(absDir, "skill.md");
|
|
609
|
+
if (await fs.pathExists(lower))
|
|
610
|
+
return lower;
|
|
611
|
+
// Any other case fold (`Skill.md`, `SKILL.MD`, `skill.MD`, …) is the same
|
|
612
|
+
// declared file (R1-001): the declared scan must read what actually exists
|
|
613
|
+
// or the declared entry reports `unscannable` while the walk collects it as
|
|
614
|
+
// declared. Prefer the canonical name when both exist (JD-F1-N1 residual).
|
|
615
|
+
try {
|
|
616
|
+
const entries = await fs.readdir(absDir);
|
|
617
|
+
const fold = entries.find((e) => e.toLowerCase() === "skill.md");
|
|
618
|
+
if (fold)
|
|
619
|
+
return path.join(absDir, fold);
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// Unreadable declared dir → return the canonical path so scanSkillFile
|
|
623
|
+
// fails closed as `unscannable` (no new behavior).
|
|
624
|
+
}
|
|
625
|
+
return canonical;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
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).
|
|
640
|
+
*/
|
|
641
|
+
export async function checkPathContained(rootAbs, rootReal, entry) {
|
|
642
|
+
const entryAbs = path.resolve(rootAbs, entry);
|
|
643
|
+
// Lexical containment — catches `../outside` and absolute entries.
|
|
644
|
+
const rel = path.relative(rootAbs, entryAbs);
|
|
645
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
646
|
+
return { ok: false, entryAbs, violation: "lexical" };
|
|
647
|
+
}
|
|
648
|
+
// Realpath containment — catches a directory inside the tree whose real
|
|
649
|
+
// location is outside it. A missing declared dir (later `unscannable`) has
|
|
650
|
+
// no realpath yet; its lexical containment above is then the whole guard.
|
|
651
|
+
let real;
|
|
652
|
+
try {
|
|
653
|
+
real = await fs.realpath(entryAbs);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return { ok: true, entryAbs };
|
|
657
|
+
}
|
|
658
|
+
const relReal = path.relative(rootReal, real);
|
|
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) {
|
|
674
|
+
throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
|
|
675
|
+
}
|
|
676
|
+
return check.entryAbs;
|
|
677
|
+
}
|
|
397
678
|
// =============================================================================
|
|
398
679
|
// Report formatting
|
|
399
680
|
// =============================================================================
|
package/dist/ui/AutoSkills.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ interface AutoSkillsProps {
|
|
|
3
3
|
projectDir: string;
|
|
4
4
|
skillsDir?: string;
|
|
5
5
|
dryRun?: boolean;
|
|
6
|
+
/** Bypass the skillguard gate for unscannable sources ONLY — block always refuses (D5) */
|
|
7
|
+
force?: boolean;
|
|
6
8
|
}
|
|
7
|
-
export default function AutoSkills({ projectDir, skillsDir, dryRun, }: AutoSkillsProps): React.JSX.Element;
|
|
9
|
+
export default function AutoSkills({ projectDir, skillsDir, dryRun, force, }: AutoSkillsProps): React.JSX.Element;
|
|
8
10
|
export {};
|
|
9
11
|
//# sourceMappingURL=AutoSkills.d.ts.map
|
package/dist/ui/AutoSkills.js
CHANGED
|
@@ -2,10 +2,11 @@ import { Box, Text, useApp, useInput } from "ink";
|
|
|
2
2
|
import Spinner from "ink-spinner";
|
|
3
3
|
import React, { useCallback, useEffect, useState } from "react";
|
|
4
4
|
import { autoInstallSkills } from "../lib/auto-skill-install.js";
|
|
5
|
+
import { formatScanReport } from "../lib/skill-scanner.js";
|
|
5
6
|
import { useCIMode } from "./CIContext.js";
|
|
6
7
|
import Header from "./Header.js";
|
|
7
8
|
import { theme } from "./theme.js";
|
|
8
|
-
export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
9
|
+
export default function AutoSkills({ projectDir, skillsDir, dryRun, force = false, }) {
|
|
9
10
|
const { exit } = useApp();
|
|
10
11
|
const isCI = useCIMode();
|
|
11
12
|
const [result, setResult] = useState(null);
|
|
@@ -20,16 +21,27 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
|
20
21
|
skillsSourceDir: skillsDir,
|
|
21
22
|
skillsTargetDir: skillsDir,
|
|
22
23
|
dryRun: dryRun ?? false,
|
|
24
|
+
force,
|
|
23
25
|
})
|
|
24
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;
|
|
25
34
|
setResult(r);
|
|
26
35
|
setLoading(false);
|
|
27
36
|
})
|
|
28
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;
|
|
29
41
|
setError(String(e));
|
|
30
42
|
setLoading(false);
|
|
31
43
|
});
|
|
32
|
-
}, [projectDir, skillsDir, dryRun]);
|
|
44
|
+
}, [projectDir, skillsDir, dryRun, force]);
|
|
33
45
|
useEffect(() => {
|
|
34
46
|
runDetection();
|
|
35
47
|
}, [runDetection]);
|
|
@@ -116,6 +128,19 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
|
116
128
|
React.createElement(Text, { dimColor: true, color: theme.warning },
|
|
117
129
|
" ",
|
|
118
130
|
"not found in source")))))),
|
|
131
|
+
result.blocked.length > 0 && (React.createElement(Box, { flexDirection: "column" }, result.blocked.map((scan) => (React.createElement(Box, { key: `blocked-${scan.skillName}`, flexDirection: "column" },
|
|
132
|
+
React.createElement(Box, { marginLeft: 4 },
|
|
133
|
+
React.createElement(Text, { color: theme.error },
|
|
134
|
+
"\u2717",
|
|
135
|
+
" ",
|
|
136
|
+
scan.skillName,
|
|
137
|
+
" \u2014 refused")),
|
|
138
|
+
React.createElement(Box, { marginLeft: 6 },
|
|
139
|
+
React.createElement(Text, { color: theme.muted, dimColor: true }, formatScanReport(scan)
|
|
140
|
+
.split("\n")
|
|
141
|
+
.map((l) => l.trim())
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join(" | ")))))))),
|
|
119
144
|
totalSkills === 0 && (React.createElement(Box, { marginLeft: 4 },
|
|
120
145
|
React.createElement(Text, { color: theme.muted }, "No skills recommended for this stack")))))),
|
|
121
146
|
!loading && (React.createElement(Box, { marginTop: 1 },
|
package/dist/ui/Plugin.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ interface PluginProps {
|
|
|
4
4
|
target?: string;
|
|
5
5
|
dryRun: boolean;
|
|
6
6
|
codex?: boolean;
|
|
7
|
+
/** Bypass the skillguard gate for unscannable sources ONLY — block always refuses (D5) */
|
|
8
|
+
force?: boolean;
|
|
7
9
|
}
|
|
8
|
-
export default function Plugin({ action, target, dryRun, codex, }: PluginProps): React.JSX.Element;
|
|
10
|
+
export default function Plugin({ action, target, dryRun, codex, force, }: PluginProps): React.JSX.Element;
|
|
9
11
|
export {};
|
|
10
12
|
//# sourceMappingURL=Plugin.d.ts.map
|
package/dist/ui/Plugin.js
CHANGED
|
@@ -16,7 +16,7 @@ const STATUS_COLOR = {
|
|
|
16
16
|
error: theme.error,
|
|
17
17
|
skipped: theme.muted,
|
|
18
18
|
};
|
|
19
|
-
export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
19
|
+
export default function Plugin({ action, target, dryRun, codex = false, force = false, }) {
|
|
20
20
|
const [steps, setSteps] = useState([]);
|
|
21
21
|
const [done, setDone] = useState(false);
|
|
22
22
|
const onStep = (step) => {
|
|
@@ -44,7 +44,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
44
44
|
});
|
|
45
45
|
break;
|
|
46
46
|
}
|
|
47
|
-
await runPluginAdd(target, dryRun, onStep);
|
|
47
|
+
await runPluginAdd(target, dryRun, onStep, { force });
|
|
48
48
|
break;
|
|
49
49
|
case "remove":
|
|
50
50
|
if (!target) {
|
|
@@ -106,7 +106,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
106
106
|
});
|
|
107
107
|
break;
|
|
108
108
|
}
|
|
109
|
-
await runPluginImport(target, dryRun, onStep);
|
|
109
|
+
await runPluginImport(target, dryRun, onStep, { force });
|
|
110
110
|
break;
|
|
111
111
|
case "export-skills":
|
|
112
112
|
if (target === "global") {
|
|
@@ -129,7 +129,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
129
129
|
setDone(true);
|
|
130
130
|
};
|
|
131
131
|
run();
|
|
132
|
-
}, [action, target, dryRun]);
|
|
132
|
+
}, [action, target, dryRun, force]);
|
|
133
133
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
134
134
|
React.createElement(Box, { marginBottom: 1 },
|
|
135
135
|
React.createElement(Text, { bold: true, color: theme.primary }, "javi-forge"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javi-forge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.1",
|
|
4
4
|
"description": "Project scaffolding and AI-ready CI bootstrap",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -85,12 +85,16 @@
|
|
|
85
85
|
"overrides": {
|
|
86
86
|
"handlebars": "^4.7.9",
|
|
87
87
|
"picomatch": "^4.0.4",
|
|
88
|
-
"brace-expansion": "^5.0.
|
|
88
|
+
"brace-expansion": "^5.0.9",
|
|
89
89
|
"lodash": "^4.18.0",
|
|
90
90
|
"lodash-es": "^4.18.0",
|
|
91
|
-
"fast-uri": "^3.1.
|
|
92
|
-
"postcss": "^8.5.
|
|
93
|
-
"vite": "^8.0.
|
|
91
|
+
"fast-uri": "^3.1.5",
|
|
92
|
+
"postcss": "^8.5.18",
|
|
93
|
+
"vite": "^8.0.16",
|
|
94
|
+
"js-yaml": "^4.3.1",
|
|
95
|
+
"nanoid": "^3.3.17",
|
|
96
|
+
"undici": "^7.29.0",
|
|
97
|
+
"ws": "^8.21.0"
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
}
|