diff --git a/CHANGELOG.md b/CHANGELOG.md index c8185fb..5bc8d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## Unreleased +## 0.14.0 - 2026-10-09 + +### Added +- `unchanged` gate: files matching a glob that existed at the base ref must stay + byte-identical. Protects snapshots, golden outputs, applied migrations and CI + workflows from being edited to make a check pass. Line-ending normalization does + not count as a change. Diff-aware and fails closed without a base. +- `deps-declared` gate: every package a JavaScript/TypeScript file imports must be + declared in the nearest `package.json`. Catches imports that resolve only through + hoisting or a global install. Builtins, relative paths, subpath imports and + self-references are skipped; `allow` covers path aliases. +- `instruction-refs` gate: every `@import`, relative Markdown link and + directory-qualified code-span path in the agent instruction files must exist. + Failures point at the instruction file's line. +- `instruction-sync` gains `require`: listed tools (`claude-code`, `gemini-cli`, ...) + must have their own instruction file in sync with or linked to the canonical one. + The reason names a `CLAUDE.local.md` that turns off the AGENTS.md fallback. + `skillgate sync --create ` writes the missing `@AGENTS.md` pointers. +- `signed-commits` gate: every commit between the base ref and HEAD must be signed + (`trust: signed`, the default) or carry a good, checkable signature (`trust: verified`). +- Command gates receive `SKILLGATE_CHANGED_FILES` (a file listing the changed, + existing files, filtered by `when.changed`) and `SKILLGATE_CHANGED_COUNT`, so a + linter can run on the diff. Both are unset when no base resolves. +- Decision log: `--log ` or `SKILLGATE_LOG` appends every `gate` and `check` + verdict as a JSON line; `skillgate log` summarizes blocks per gate and blocked + commands. Logs inside the worktree (outside `.git/`) are refused, and logging + never changes a verdict. +- `skillgate explain --commands ` replays a command list or a decision log + against the current `finishLine` and names the gates each command would run. + `explain --command` also lists those gates. + +### Changed +- A passing `trivy` gate reports the vulnerabilities below the blocking severities + (`not blocking: 12 HIGH`). One extra JSON scan runs after a passing vulnerability + scan; it is informational and `summary: false` turns it off. +- `--cache` also reuses passing `unchanged` results. + ## 0.13.1 - 2026-10-03 ### Fixed diff --git a/README.md b/README.md index 6fe4916..508da9d 100644 --- a/README.md +++ b/README.md @@ -285,11 +285,15 @@ A `file-contains` gate (e.g. require a touched changelog) and the other types ar | `trivy` | Trivy finds no leaked secrets, no blocking CVEs, and can generate a CycloneDX SBOM | | `evidence` | a named `file` exists and is non-empty | | `not-empty` | a directory at `path` contains at least `min` entries (default 1) | -| `instruction-sync` | every AI agent instruction file (CLAUDE.md, AGENTS.md, Cursor, Copilot…) still agrees with the canonical one (optional `threshold`, default 0.95) | +| `instruction-sync` | every AI agent instruction file (CLAUDE.md, AGENTS.md, Cursor, Copilot…) still agrees with the canonical one (optional `threshold`, default 0.95; `require` lists tools that must have their own file) | +| `instruction-refs` | every path an instruction file points at (`@imports`, relative links, `src/...` code spans) still exists | | `no-new` | the count of `pattern` matches in `glob` did not **increase** versus the base ref (skips, `eslint-disable`, TODOs) | | `no-fewer` | the count of `pattern` matches in `glob` did not **decrease** versus the base ref (test cases, assertions) | | `no-deleted` | every file matching `glob` at the base ref still exists | +| `unchanged` | every file matching `glob` at the base ref still exists **byte-identical** (snapshots, golden outputs, applied migrations, CI workflows) | | `deps-locked` | every dependency declared in `package.json` / `pyproject.toml` is in the lockfile, so a hallucinated package can't slip in | +| `deps-declared` | every package a JS/TS file in `glob` imports is declared in the nearest `package.json` | +| `signed-commits` | every commit between the base ref and HEAD is signed (`trust: verified` for a good, checkable signature) | | `phase` | the gates required by the active phase and every earlier one pass now (plan → build → review) | A glob that matches no files fails its gate instead of passing as a silent no-op @@ -312,6 +316,34 @@ on push or publish, and only when source changed: A condition skillgate cannot decide runs the gate. See the [spec reference](docs/spec-reference.md#conditional-gates-when). +**Lint only what changed.** Command gates get `SKILLGATE_CHANGED_FILES`, a file +listing the changed files versus the base (filtered by the gate's `when.changed`), +so a linter can run on the diff instead of the whole repo: + +```yaml + - id: lint-changed + type: command + run: 'if [ -n "$SKILLGATE_CHANGED_FILES" ]; then xargs -r npx eslint < "$SKILLGATE_CHANGED_FILES"; else npx eslint .; fi' + when: + changed: ["src/**/*.ts"] +``` + +**Protect the files that define "correct".** An agent can make a check pass by +editing the snapshot, the expected output or the CI workflow instead of the code. +`unchanged` blocks any change to files that existed at the base: + +```yaml + - id: protected + type: unchanged + glob: "{**/__snapshots__/**,migrations/**,.github/workflows/**}" +``` + +**See what the gate did.** Set `SKILLGATE_LOG=.git/skillgate/decisions.jsonl` (or +pass `--log`) and every `gate` and `check` verdict is appended as one JSON line. +`skillgate log` summarizes it: how often each gate blocked and which commands it +stopped. `skillgate explain --commands ` replays a command list, or the log +itself, against the current `finishLine` before you change it. + **Phases, checked live.** A `phase` gate orders work (plan → build → review) and requires each phase's gates, plus every earlier phase's, to pass at the moment they are checked. The active phase is a plain marker file, and @@ -326,7 +358,9 @@ CLI and opencode. `when.tool` scopes a gate to them. **Trivy security gate.** Add `type: trivy` when the finish line should stop on leaked secrets or critical CVEs. skillgate runs Trivy's secret scan separately from the vulnerability scan, so `severity: ["CRITICAL"]` filters CVEs without -masking secrets. By default it also verifies that Trivy can emit a CycloneDX +masking secrets. A passing scan reports what it did not block (`not blocking: +12 HIGH`), so "no critical" never reads as "nothing found"; set `summary: false` +to skip that count. By default it also verifies that Trivy can emit a CycloneDX SBOM; set `sbom: false` if your workflow only needs the blocking scan. **The `evidence` escape hatch.** Gates only see machine-observable output. For a step like "research the API first," have the agent write `.skillgate/evidence/research.md` as it works and gate on that file. Otherwise the step is invisible and the deviation hides. diff --git a/docs/spec-reference.md b/docs/spec-reference.md index ac3284f..b0b4c06 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -98,6 +98,26 @@ test/lint/build commands, not anything that hits the network. On timeout the gate returns a deterministic `command timed out after Nms` reason. +When a base ref resolves, the command also receives the files changed against it: + +| Variable | Value | +|----------|-------| +| `SKILLGATE_CHANGED_FILES` | path of a temporary file listing the changed files, one per line, relative to the policy's directory | +| `SKILLGATE_CHANGED_COUNT` | how many files the list holds | + +The list covers committed, staged, unstaged and untracked changes, leaves out +deleted files, and keeps only files matching the gate's `when.changed` globs when +it has them. Without a base both variables are unset, so a command can fall back +to a full run instead of checking nothing: + +```yaml +- id: lint-changed + type: command + run: 'if [ -n "$SKILLGATE_CHANGED_FILES" ]; then xargs -r npx eslint < "$SKILLGATE_CHANGED_FILES"; else npx eslint .; fi' + when: + changed: ["src/**/*.ts"] +``` + ### `trivy` Run Trivy as a first-class security gate. By default this blocks on any leaked @@ -114,7 +134,10 @@ secret, any `CRITICAL` vulnerability, or failure to generate a CycloneDX SBOM. ``` The secret scan runs separately from the vulnerability scan, so CVE severity -filtering does not hide leaked credentials. Set `trivy` when the binary is not on +filtering does not hide leaked credentials. After a passing vulnerability scan, +one more JSON scan counts the findings below the blocking severities and the gate +reports them (`not blocking: 12 HIGH, 3 MEDIUM`). The count is informational and +never changes the verdict; set `summary: false` to skip it. Set `trivy` when the binary is not on `PATH`, or `ignoreUnfixed: true` when the vulnerability policy should ignore unfixed CVEs. @@ -154,8 +177,37 @@ agents are reading different rulebooks. Run `skillgate sync` to fix, or - id: instructions-in-sync type: instruction-sync threshold: 0.95 # optional, 0..1, default 0.95 + require: [claude-code, gemini-cli] # optional ``` +`require` names tools that must have their own instruction file, in sync with or +linked to the canonical one. Without it, a repository with only AGENTS.md passes, +yet some tools read AGENTS.md only through a fallback that can be switched off +(Claude Code skips it when, for example, only a personal `CLAUDE.local.md` is +present). Tool ids: `agents-md`, `claude-code`, `cursor`, `github-copilot`, +`gemini-cli`, `cline`, `windsurf`, `jetbrains-junie`. `skillgate sync --create +claude-code,gemini-cli` writes the missing files as `@AGENTS.md` pointers (or +synced copies for tools without import support). + +### `instruction-refs` + +Every repository path an instruction file points at must exist: `@imports` +(`@docs/rules.md`), relative Markdown links, and code spans that name a +directory-qualified path (`src/cli.ts`, `docs/`). Rules about files that are gone +send the agent after a repository that no longer exists. + +```yaml +- id: instruction-refs + type: instruction-refs + ignore: ["dist/**"] # optional: referenced paths to skip +``` + +Fenced code blocks, URLs, absolute and home paths, and bare file names are not +judged. A slashed code span counts as a path only when it has a file extension, +ends with `/`, or its first segment exists, so `origin/main` and `owner/repo` are +left alone. References resolve against the instruction file's directory or the +repository root. Failures point at `file:line`. + ### `no-new`, `no-fewer`, `no-deleted` (diff-aware) These gates compare the working tree with the commit the change forked from @@ -188,6 +240,64 @@ and extglobs. Historical reads are scoped to the policy's workspace. `no-fewer` catches a suite made green by deleting test cases inside files that still exist, which `no-deleted` (whole files) and `no-new` (added skips) miss. +### `unchanged` (diff-aware) + +Every file matching `glob` that existed at the base must still exist with +identical content. Use it for the files that define "correct": snapshots and +golden outputs, migrations that already ran, CI workflows, lint configuration. +An agent that edits them to make a check pass is blocked; new files are allowed. + +```yaml +- id: protected + type: unchanged + glob: "{**/__snapshots__/**,migrations/**,.github/workflows/**}" + ignore: ["migrations/README.md"] # optional +``` + +Content is compared the way Git would store it, so line-ending normalization +from `.gitattributes` does not count as a change. Like the other diff-aware +gates it fails closed without a base. Change a protected file in a separate, +reviewed commit, or pin the policy with `--pin`. + +### `signed-commits` (diff-aware) + +Every commit between the base and HEAD must be signed. Scope it to the push: + +```yaml +- id: signed + type: signed-commits + trust: signed # optional: signed (default) or verified + when: + command: ["git push"] +``` + +`signed` accepts any signature Git did not reject, including one whose key is not +in the local keyring. `verified` requires a good signature Git can check (GPG +trust, or `gpg.ssh.allowedSignersFile` for SSH signing). Unsigned commits, bad +signatures and revoked keys always fail. No commits since the base passes. + +### `deps-declared` + +Every package a JavaScript or TypeScript file imports must be declared in the +nearest `package.json` (dependencies, devDependencies, optionalDependencies or +peerDependencies). An import that works only because another package hoisted it, +or because it is installed globally, fails for the next user; this catches it +offline. Pair it with `deps-locked`, which checks the manifest against the lockfile. + +```yaml +- id: deps-declared + type: deps-declared + glob: "src/**/*.{ts,tsx,js,mjs,cjs}" + allow: ["@/*", "virtual:*"] # optional: aliases treated as declared +``` + +`import … from`, `export … from`, side-effect imports, `require()` and dynamic +`import()` with a literal specifier are read; comments are ignored. Relative +paths, `#subpath` imports, protocol specifiers (`node:`, `bun:`) and Node +builtins are skipped, as are a package's imports of itself. A type-only import is +also satisfied by `@types/`. Workspace packages answer to their own +manifest. + ### `deps-locked` Every dependency declared in a manifest must be present in its lockfile. A @@ -363,7 +473,31 @@ explicit ignored gate inputs and symlink file contents. Failing or malformed results are never reused. A workspace change during evaluation blocks a passing receipt. Receipt outputs must not overlap gate input paths or globs. -Cache reuse applies to local file, directory, pattern, diff, and phase checks. -Commands, Trivy, TruffleHog, reviews, instruction selection, and dependency checks -always execute again because their complete inputs are not represented by the -snapshot. With `--json`, `cacheDisabledReason` explains a requested cache bypass. +Cache reuse applies to local file, directory, pattern, diff (including +`unchanged`), and phase checks. Commands, Trivy, TruffleHog, reviews, instruction +selection and references, signed commits, and dependency checks always execute +again because their complete inputs are not represented by the snapshot. With `--json`, `cacheDisabledReason` explains a requested cache bypass. + +## Decision log + +`--log ` on `check` and `gate`, or `SKILLGATE_LOG=`, appends every +verdict as one JSON line: time, `via` (`gate` or `check`), `event` (`command`, +`tool`, `stop`, `check`), `decision`, `reason`, the judged command or tool, the +failed gate ids with their reasons, and the duration. Agent hooks call `gate` for +every shell command, so the log also records the commands that were allowed. + +Keep the log outside the worktree, or under `.git/` (`.git/skillgate/decisions.jsonl` +in a regular checkout). A path inside the worktree would change the snapshot the +gates judge, so it is refused. Logging never changes a verdict: a log that cannot +be written is reported on stderr and skipped. + +```bash +skillgate log .git/skillgate/decisions.jsonl # blocks per gate, blocked commands +skillgate log --json # same, from SKILLGATE_LOG +skillgate explain --commands .git/skillgate/decisions.jsonl # replay against today's finishLine +``` + +`explain --commands ` reads one command per line (`#` comments allowed) or +decision-log records, and reports which commands cross the finish line, which +pattern matched, and which gates their `when.command` scope would run. Pass `-` +to read stdin. Use it before you change `finishLine` or a `when` block. diff --git a/package-lock.json b/package-lock.json index c081d56..524ab8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@reneza/skillgate", - "version": "0.13.1", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reneza/skillgate", - "version": "0.13.1", + "version": "0.14.0", "license": "MIT", "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", diff --git a/package.json b/package.json index b6423b5..7fedb57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/skillgate", - "version": "0.13.1", + "version": "0.14.0", "publishConfig": { "access": "public" }, diff --git a/schema/done.schema.json b/schema/done.schema.json index ba0e25b..cac298e 100644 --- a/schema/done.schema.json +++ b/schema/done.schema.json @@ -175,6 +175,10 @@ "items": { "enum": ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] }, "description": "Vulnerability severities that block. Default [\"CRITICAL\"]." }, + "summary": { + "type": "boolean", + "description": "After a passing vulnerability scan, report the counts below the blocking severities. Default true." + }, "sbom": { "type": "boolean", "description": "Also require Trivy to generate a CycloneDX SBOM. Default true." @@ -212,9 +216,27 @@ "minimum": 0, "maximum": 1, "description": "Similarity required to count as in sync (default 0.95)." + }, + "require": { + "type": "array", + "minItems": 1, + "items": { "enum": ["agents-md", "claude-code", "cursor", "github-copilot", "gemini-cli", "cline", "windsurf", "jetbrains-junie"] }, + "description": "Tools that must have their own instruction file, in sync or linked to the canonical one." } } }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "instruction-refs" }, + "ignore": { "type": "array", "items": { "type": "string" }, "description": "Globs of referenced paths to skip." } + } + }, { "type": "object", "additionalProperties": false, @@ -335,6 +357,48 @@ }, "current": { "type": "string", "description": "Plain-text file naming the active phase. Default .skillgate/phase; missing = the first phase." } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "glob"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "unchanged" }, + "glob": { "type": "string", "description": "Files that exist at the base ref and must stay byte-identical." }, + "ignore": { "type": "array", "items": { "type": "string" }, "description": "Extra globs that may change." }, + "allowEmpty": { "type": "boolean", "description": "Allow the glob to match no files. Default false." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "glob"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "deps-declared" }, + "glob": { "type": "string", "description": "JavaScript/TypeScript source files to scan." }, + "ignore": { "type": "array", "items": { "type": "string" }, "description": "Extra globs to exclude." }, + "allow": { "type": "array", "items": { "type": "string" }, "description": "Package names (globs) treated as declared: path aliases, virtual modules." }, + "maxBytes": { "type": "integer", "minimum": 1, "description": "Largest file (bytes) the gate reads. Default 10485760 (10 MiB)." }, + "allowEmpty": { "type": "boolean", "description": "Allow the glob to match no files. Default false." } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "signed-commits" }, + "trust": { "enum": ["signed", "verified"], "description": "signed (default): any signature git did not reject. verified: a good signature git can check." } + } } ] } diff --git a/skills/skillgate/SKILL.md b/skills/skillgate/SKILL.md index 741c829..ff3bb30 100644 --- a/skills/skillgate/SKILL.md +++ b/skills/skillgate/SKILL.md @@ -87,11 +87,15 @@ prefixes that trigger them (`git commit`, `git push`, `npm publish`). | `trivy` | Trivy finds no leaked secrets and no blocking CVEs | | `evidence` | a named `file` exists and is non-empty | | `not-empty` | a directory at `path` holds at least `min` entries | -| `instruction-sync` | the agent instruction files still agree with the canonical one | +| `instruction-sync` | the agent instruction files still agree with the canonical one (`require` names tools that need their own file) | +| `instruction-refs` | every path the instruction files point at still exists | | `no-new` | matches of `pattern` in `glob` did not increase versus the base ref | | `no-fewer` | matches of `pattern` in `glob` did not decrease versus the base ref (deleted test cases) | | `no-deleted` | every file matching `glob` at the base ref still exists | +| `unchanged` | every file matching `glob` at the base ref is still byte-identical (snapshots, migrations, CI) | | `deps-locked` | every declared dependency is in the lockfile (catches invented packages) | +| `deps-declared` | every package imported by JS/TS files in `glob` is declared in the nearest package.json | +| `signed-commits` | every commit since the base ref is signed | | `phase` | the active phase's required gates, and every earlier phase's, pass now | Any gate can carry `when: { command: [...], tool: [...], changed: [...], branch: [...] }`. A gate whose diff --git a/src/cli.ts b/src/cli.ts index 308bb7f..875b985 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,8 @@ import os from "node:os"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { findSpecPath, loadSpec, parseSpec, specRoot, DEFAULT_SPEC_PATHS, DEFAULT_PHASE_FILE, DEFAULT_RUN_TIMEOUT_MS, type Spec, type PhaseGate } from "./spec.js"; -import { runGates, decideCommand, decideTool, currentPhase, evaluatePhase, type GateResult, type RunResult } from "./core.js"; +import { runGates, decideCommand, decideTool, currentPhase, evaluatePhase, gatesForCommand, type GateResult, type RunResult } from "./core.js"; +import { appendLog, logPath, logPathProblem, summarizeLog, toFailed, type LogRecord } from "./log.js"; import { checkDrift, formatDiff, DEFAULT_THRESHOLD, discover, pickCanonical } from "./drift.js"; import { runSync } from "./link.js"; import { runScaffold, listTemplates } from "./scaffold.js"; @@ -100,11 +101,14 @@ Usage: opencode), github-actions, or pre-commit enforcement skillgate doctor verify policy discovery and one or more integrations skillgate explain --command show structural finish-line matching without running gates + skillgate explain --commands replay a command corpus (lines or a decision log; - = stdin) + skillgate log [file] summarize the decision log: blocks per gate and command skillgate scaffold [--template] generate .skillgate/evidence/ with stack templates skillgate drift report AI instruction-file drift, exit 1 if drifted skillgate diff-instructions show line-level diff between drifted instruction files skillgate canonical set which file is the canonical instruction source skillgate sync make AGENTS.md canonical and link the rest + skillgate sync --create also create pointer files for tools without one (claude-code,...) skillgate --version Flags: @@ -126,6 +130,8 @@ Flags: --cache check: reuse a passing result only for the exact repo snapshot --receipt check: write a machine-readable execution receipt --allow-on-error gate: allow instead of fail-closed if evaluation errors + --log check/gate: append each verdict as a JSON line (or SKILLGATE_LOG); + keep it outside the worktree or under .git/ --threshold <0..1> drift: similarity required to count as in sync (default 0.95) --dry-run sync: show what would change without writing --symlink sync: use symlinks instead of pointer files and copies @@ -328,6 +334,14 @@ function resolvedZKSpec(): Resolved { return resolveSpecAndBase(specPath && fs.existsSync(specPath) ? specPath : null); } +/** Append a verdict to the opt-in decision log; never changes the verdict. */ +function recordDecision(workspace: string, record: Omit): void { + const file = logPath(option("--log"), cwd); + if (!file) return; + const problem = logPathProblem(file, workspace) ?? appendLog(file, { ts: new Date().toISOString(), workspace, ...record }); + if (problem) console.error(c(C.dim, `skillgate: ${problem}`)); +} + function printResults(results: GateResult[]): void { for (const r of results) { const mark = r.status === "skipped" ? c(C.dim, "−") : r.ok ? c(C.green, "✓") : c(C.red, "✗"); @@ -510,20 +524,64 @@ if (cmd === "doctor") { process.exit(checks.every((check) => check.ok) ? 0 : 1); } +/** Commands from a corpus: one per line, or decision-log JSON lines (their `command`). */ +function readCorpus(source: string): string[] { + const text = source === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(cwd, source), "utf8"); + const commands: string[] = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("{")) { + try { + const record = JSON.parse(trimmed); + if (typeof record?.command === "string" && record.command.trim() && !record.tool) commands.push(record.command.trim()); + continue; + } catch { + /* a command that starts with "{" */ + } + } + commands.push(trimmed); + } + return commands; +} + if (cmd === "explain") { const command = option("--command") ?? ""; - if (!command) die(2, "explain requires --command "); + const corpus = option("--commands"); + if (!command && !corpus) die(2, "explain requires --command or --commands "); const specPath = findSpecPath(cwd); if (!specPath) die(2, "no spec found — run `skillgate init`"); try { const spec = loadSpec(specPath); + if (corpus) { + const commands = readCorpus(corpus); + const rows = commands.map((cmdText) => { + const analysis = analyzeCommand(cmdText, spec.finishLine ?? []); + return { command: cmdText, matched: analysis.matched, patterns: analysis.patterns, gates: analysis.matched ? gatesForCommand(spec, cmdText) : [] }; + }); + const gated = rows.filter((row) => row.matched); + const byPattern = new Map(); + for (const row of gated) for (const p of row.patterns) byPattern.set(p, (byPattern.get(p) ?? 0) + 1); + if (json) { + console.log(JSON.stringify({ finishLine: spec.finishLine ?? [], total: rows.length, gated: gated.length, byPattern: Object.fromEntries(byPattern), commands: rows }, null, 2)); + } else { + for (const row of rows) { + console.log(`${row.matched ? c(C.red, "MATCH ") : c(C.dim, "no match")} ${row.command}${row.matched ? c(C.dim, ` → ${row.gates.join(", ") || "no gates"}`) : ""}`); + } + console.log(""); + console.log(`${gated.length} of ${rows.length} commands cross the finish line` + (byPattern.size ? c(C.dim, ` (${[...byPattern].map(([p, n]) => `${p}: ${n}`).join(", ")})`) : "")); + } + process.exit(0); + } const analysis = analyzeCommand(command, spec.finishLine ?? []); + const gates = analysis.matched ? gatesForCommand(spec, command) : []; if (json) { - console.log(JSON.stringify({ command, finishLine: spec.finishLine ?? [], ...analysis }, null, 2)); + console.log(JSON.stringify({ command, finishLine: spec.finishLine ?? [], ...analysis, gates }, null, 2)); } else { console.log(`${analysis.matched ? c(C.red, "MATCH") : c(C.green, "NO MATCH")} · ${command}`); console.log(` policy: ${path.relative(cwd, specPath)}`); console.log(` matched: ${analysis.patterns.length ? analysis.patterns.join(", ") : "none"}`); + if (analysis.matched) console.log(` gates: ${gates.length ? gates.join(", ") : "none"}`); for (const segment of analysis.segments) console.log(` segment: ${segment.normalized.join(" ") || "(empty)"}`); } process.exit(0); @@ -532,6 +590,34 @@ if (cmd === "explain") { } } +if (cmd === "log") { + const file = args[1] && !args[1].startsWith("-") ? path.resolve(cwd, args[1]) : logPath(undefined, cwd); + if (!file) die(2, "usage: skillgate log (or set SKILLGATE_LOG)"); + let text: string; + try { + text = fs.readFileSync(file, "utf8"); + } catch (error: any) { + die(2, `cannot read ${file}: ${error.message}`); + } + const summary = summarizeLog(text); + if (json) { + console.log(JSON.stringify(summary, null, 2)); + process.exit(0); + } + console.log(`${c(C.bold, "skillgate log")} ${c(C.dim, `· ${summary.records} decisions${summary.first ? ` · ${summary.first} → ${summary.last}` : ""}${summary.malformed ? ` · ${summary.malformed} malformed lines skipped` : ""}`)}`); + console.log(` allowed ${summary.allowed} · blocked ${summary.blocked}${summary.errors ? ` (${summary.errors} on evaluation errors)` : ""}`); + for (const [event, n] of Object.entries(summary.byEvent)) console.log(c(C.dim, ` ${event}: ${n.allowed} allowed, ${n.blocked} blocked`)); + if (summary.blockingGates.length) { + console.log("\n gates that blocked most:"); + for (const g of summary.blockingGates) console.log(` ${String(g.count).padStart(4)} ${g.id}`); + } + if (summary.blockedActions.length) { + console.log("\n blocked commands and tools:"); + for (const a of summary.blockedActions) console.log(` ${String(a.count).padStart(4)} ${a.action}`); + } + process.exit(0); +} + if (cmd === "audit") { // Zero-config, read-only: see what your agent could cut in this repo right now. // If there's no spec we evaluate against the built-in defaults WITHOUT writing @@ -633,6 +719,15 @@ if (cmd === "check") { if (cache && snapshot) writeCachedResult(r.workspace, snapshot, result); } if (receiptFile && snapshot) writeReceipt(receiptFile, snapshot, result, cacheHit ? "cache" : "executed"); + recordDecision(r.workspace, { + via: "check", + event: "check", + decision: result.passed ? "allow" : "block", + reason: result.passed ? `all ${applied(result)} applicable gates passed` : `${result.failed.length} of ${result.results.length} gates unmet`, + ...(forCommand != null ? { command: forCommand } : {}), + failed: toFailed(result.failed), + durationMs: result.durationMs, + }); } catch (e: any) { console.error(c(C.red, `skillgate: ${e.message}`)); process.exit(2); @@ -693,9 +788,12 @@ if (cmd === "drift") { } if (cmd === "sync") { + const create = option("--create"); + if (args.includes("--create") && (!create || create.startsWith("--"))) die(2, "sync --create requires tool ids, e.g. claude-code,gemini-cli"); const { lines } = runSync(cwd, { dryRun: args.includes("--dry-run"), symlink: args.includes("--symlink"), + create: create?.split(",").map((id) => id.trim()).filter(Boolean), }); for (const l of lines) console.log(l); process.exit(0); @@ -852,9 +950,20 @@ if (cmd === "gate") { : option("--tool") ?? (payloadTool && !SHELL_TOOLS.has(payloadTool) ? payloadTool : undefined); const command = event === "stop" ? "" : tool ?? ((option("--command") ?? commandFromPayload(payload)) ?? "").trim(); const specPath = findSpecPath(cwd); + let logWorkspace = specPath ? specRoot(specPath) : (repoRoot(cwd) ?? cwd); + const started = Date.now(); /** Answer in the host's protocol and exit. */ const answer = (decision: "allow" | "block", reason: string, failed: GateResult[] = [], extra: Record = {}): never => { + recordDecision(logWorkspace, { + via: "gate", + event: event === "stop" ? "stop" : tool ? "tool" : "command", + decision, + reason, + ...(tool ? { tool } : event === "stop" ? {} : { command }), + failed: toFailed(failed), + durationMs: Date.now() - started, + }); const details = failed.map((f) => ` · ${f.id}: ${f.reason}`).join("\n"); const guidance = event === "stop" ? `skillgate: the work is not done — ${reason}.\n${details}\nFix these before you finish. Do not weaken or bypass the gates.` @@ -885,6 +994,7 @@ if (cmd === "gate") { try { const r = resolveSpecAndBase(specPath && fs.existsSync(specPath) ? specPath : null); + logWorkspace = r.workspace; const timeoutArg = option("--timeout"); const timeoutMs = timeoutArg == null ? undefined : Number(timeoutArg); if (timeoutMs != null && (!Number.isInteger(timeoutMs) || timeoutMs < 1)) throw new Error("--timeout must be a positive integer in milliseconds"); diff --git a/src/core.ts b/src/core.ts index a92deaf..afe93b8 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { globSync } from "tinyglobby"; @@ -8,14 +9,19 @@ import { type GateWhen, type PhaseGate, type TrivyGate, + type CommandGate, + INSTRUCTION_TOOL_IDS, + toolId, DEFAULT_PHASE_FILE, DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_MAX_BYTES, DEFAULT_NOT_EMPTY_MIN, DEFAULT_RUN_TIMEOUT_MS, } from "./spec.js"; -import { checkDrift, DEFAULT_THRESHOLD } from "./drift.js"; -import { readFileAtRef, listFilesAtRef, matchesGlob, changedFiles, currentBranch, baseLabel, repoRelativePath } from "./git.js"; +import { checkDrift, DEFAULT_THRESHOLD, TOOL_SPECS } from "./drift.js"; +import { readFileAtRef, listFilesAtRef, matchesGlob, changedFiles, currentBranch, baseLabel, repoRelativePath, blobsAtRef, hashBlob, hashWorkingFiles, commitSignatures } from "./git.js"; +import { findImports, packageName, nearestManifest, isDeclared } from "./imports.js"; +import { checkInstructionRefs } from "./refs.js"; import { checkManifest, SUPPORTED_MANIFESTS } from "./deps.js"; import { isStructuredCommandMatch } from "./command.js"; import { runShellCommand } from "./process.js"; @@ -138,6 +144,31 @@ function countMatchingLines(text: string, re: RegExp, onFirst?: (i: number) => v return n; } +/** + * Changed files for a command gate: `SKILLGATE_CHANGED_FILES` names a temporary + * file listing the existing files changed versus the base (one per line, filtered + * by the gate's `when.changed` globs), and `SKILLGATE_CHANGED_COUNT` holds the + * count. Both are unset when no base resolves, so a command can fall back to a + * full run instead of checking nothing. + */ +function changedFilesEnv(gate: CommandGate, cwd: string, baseRef: string | undefined): { env: NodeJS.ProcessEnv; cleanup: () => void } | null { + if (!baseRef) return null; + const all = changedFiles(cwd, baseRef); + if (all == null) return null; + const globs = gate.when?.changed; + const files = all + .filter((file) => !globs || globs.some((glob) => matchesGlob(file, glob))) + .filter((file) => fs.existsSync(path.resolve(cwd, file))) + .sort(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-changed-")); + const list = path.join(dir, "changed-files.txt"); + fs.writeFileSync(list, files.map((file) => file + "\n").join("")); + return { + env: { SKILLGATE_CHANGED_FILES: list, SKILLGATE_CHANGED_COUNT: String(files.length) }, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + /** A pattern gate hit a read limit; the gate fails with this message. */ class ScanLimit extends Error {} @@ -225,6 +256,35 @@ function runTrivyCommand(bin: string, args: string[], cwd: string, timeout: numb return { ok: true, stdout: String(result.stdout || "") }; } +const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]; + +/** + * Count vulnerabilities at every severity after the blocking scan passed, so a pass + * never reads as "nothing found" while lower-severity findings ship. Informational: + * the blocking decision was already made by the severity-filtered scan. + */ +function trivySummary(bin: string, target: string, blocking: string[], ignoreUnfixed: boolean | undefined, cwd: string, timeout: number): string { + const args = ["fs", "--scanners", "vuln", "--format", "json", "--exit-code", "0", "--no-progress"]; + if (ignoreUnfixed) args.push("--ignore-unfixed"); + args.push(target); + const res = runTrivyCommand(bin, args, cwd, timeout); + if (!res.ok) return "severity summary unavailable"; + const counts = new Map(); + try { + const report = JSON.parse(res.stdout); + for (const result of Array.isArray(report?.Results) ? report.Results : []) { + for (const vuln of Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : []) { + const severity = String(vuln?.Severity ?? "UNKNOWN").toUpperCase(); + counts.set(severity, (counts.get(severity) ?? 0) + 1); + } + } + } catch { + return "severity summary unavailable"; + } + const below = SEVERITY_ORDER.filter((s) => !blocking.includes(s) && counts.get(s)).map((s) => `${counts.get(s)} ${s}`); + return below.length ? `not blocking: ${below.join(", ")}` : "no findings below the threshold"; +} + function checkTrivyGate(gate: TrivyGate, cwd: string, remainingMs?: number): GateResult { const base = { id: gate.id, type: gate.type }; const trivy = gate.trivy ?? "trivy"; @@ -250,6 +310,7 @@ function checkTrivyGate(gate: TrivyGate, cwd: string, remainingMs?: number): Gat ran.push(`vuln:${severity.join(",")}`); const res = runTrivyCommand(trivy, args, cwd, nextTimeout()); if (!res.ok) return { ...base, ok: false, reason: res.reason }; + if (gate.summary !== false) ran.push(trivySummary(trivy, target, severity, gate.ignoreUnfixed, cwd, nextTimeout())); } if (gate.sbom !== false) { @@ -303,7 +364,13 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { } case "command": { const timeout = Math.max(1, Math.min(gate.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS, opts.remainingMs ?? Number.POSITIVE_INFINITY)); - const result = runShellCommand(gate.run, cwd, timeout); + const changed = changedFilesEnv(gate, cwd, opts.baseRef); + let result; + try { + result = runShellCommand(gate.run, cwd, timeout, changed?.env); + } finally { + changed?.cleanup(); + } if (result.timedOut) return { ...base, ok: false, reason: `command timed out after ${timeout}ms` }; if (result.outputLimited) return { ...base, ok: false, reason: "command exceeded output limit" }; if (result.error) return { ...base, ok: false, reason: `\`${gate.run}\` failed: ${result.error.message}` }; @@ -333,6 +400,21 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { case "instruction-sync": { const threshold = gate.threshold ?? DEFAULT_THRESHOLD; const res = checkDrift(cwd, threshold); + const uncovered = (gate.require ?? []).map(toolId).filter((id) => !res.entries.some((e) => toolId(e.tool) === id)); + if (uncovered.length) { + const names = uncovered.map((id) => { + const spec = TOOL_SPECS[INSTRUCTION_TOOL_IDS.indexOf(id)]; + return `${spec.name} (${spec.patterns[0]})`; + }); + const shadow = uncovered.includes("claude-code") && fs.existsSync(path.resolve(cwd, "CLAUDE.local.md")) + ? "; CLAUDE.local.md without CLAUDE.md also turns off Claude Code's AGENTS.md fallback" + : ""; + return { + ...base, + ok: false, + reason: `no instruction file for ${names.join(", ")} — the tool may never load the shared rules${shadow} (run \`skillgate sync --create ${uncovered.join(",")}\`)`, + }; + } if (res.entries.length === 0) { return { ...base, ok: true, reason: `no agent instruction files found` }; } @@ -465,6 +547,118 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { } return { ...base, ok: true, reason: `${declared} declared dependencies locked (${manifests.join(", ")})` }; } + case "instruction-refs": { + const res = checkInstructionRefs(cwd, gate.ignore ?? []); + if (res.files.length === 0) return { ...base, ok: true, reason: "no agent instruction files found" }; + if (res.missing.length === 0) { + return { ...base, ok: true, reason: `${res.checked} path references in ${res.files.length} instruction files resolve` }; + } + const first = res.missing[0]; + const sample = res.missing.slice(0, 3).map((m) => `${m.file}:${m.line} → ${m.ref}`).join(", "); + return { + ...base, + ok: false, + reason: `${res.missing.length} stale reference${res.missing.length === 1 ? "" : "s"} in instruction files: ${sample}${res.missing.length > 3 ? " …" : ""} — fix the path or add it to ignore`, + location: { file: first.file, line: first.line }, + }; + } + case "unchanged": { + if (!opts.baseRef) { + return { ...base, ok: false, reason: `no git base ref to diff against — pass --base or run in a repo with an upstream (fail-closed)` }; + } + const ignore = gate.ignore ?? []; + const baseline = [...blobsAtRef(cwd, opts.baseRef)].filter(([p]) => matchesGlob(p, gate.glob, ignore) && !IGNORE.some((ig) => matchesGlob(p, ig))); + const short = baseLabel(opts.baseRef); + if (baseline.length === 0 && !gate.allowEmpty && !opts.allowEmptyGlobs) { + const workFiles = globSync(gate.glob, { cwd, dot: true, ignore: [...IGNORE, ...ignore] }); + if (workFiles.length === 0) return { ...base, ok: false, reason: noOpReason(gate.glob) }; + return { ...base, ok: true, reason: `no ${gate.glob} files at ${short} to protect (${workFiles.length} new)` }; + } + const deleted = new Set(); + const modified: string[] = []; + const files: [string, string][] = []; + for (const [p, entry] of baseline) { + const full = path.resolve(cwd, p); + let stat: fs.Stats; + try { + stat = fs.lstatSync(full); + } catch { + deleted.add(p); + continue; + } + // A symlink is stored as its target path; compare that, never the file it points at. + if (entry.mode === "120000" || stat.isSymbolicLink()) { + if (!(entry.mode === "120000" && stat.isSymbolicLink() && hashBlob(cwd, fs.readlinkSync(full)) === entry.oid)) modified.push(p); + } else if (!stat.isFile()) { + modified.push(p); + } else { + files.push([p, entry.oid]); + } + } + const hashes = hashWorkingFiles(cwd, files.map(([p]) => p)); + files.forEach(([p, oid], i) => { if (hashes[i] !== oid) modified.push(p); }); + const touched = [...modified, ...deleted].sort(); + const deletedLabel = (p: string) => (deleted.has(p) ? " (deleted)" : ""); + return touched.length + ? { + ...base, + ok: false, + reason: `${touched.length} protected file(s) matching ${gate.glob} changed since ${short}: ${touched.slice(0, 3).map((p) => `${p}${deletedLabel(p)}`).join(", ")}${touched.length > 3 ? " …" : ""} — restore them; change them only in a separate, reviewed commit`, + location: { file: touched[0] }, + } + : { ...base, ok: true, reason: `${baseline.length} ${gate.glob} file(s) unchanged vs ${short}` }; + } + case "deps-declared": { + const ignore = gate.ignore ?? []; + const files = globSync(gate.glob, { cwd, dot: true, ignore: [...IGNORE, ...ignore] }).sort(); + if (files.length === 0 && !gate.allowEmpty && !opts.allowEmptyGlobs) return { ...base, ok: false, reason: noOpReason(gate.glob) }; + const scan = new Scanner(gate.maxBytes, opts.remainingMs); + const manifests = new Map>(); + const root = path.resolve(cwd); + const missing = new Map(); + let imports = 0; + for (const f of files) { + const text = scan.file(cwd, f, files.length); + if (text == null) return { ...base, ok: false, reason: `matched file disappeared: ${f}`, location: { file: f } }; + const manifest = nearestManifest(path.dirname(path.resolve(cwd, f)), root, manifests); + if (!manifest) return { ...base, ok: false, reason: `no package.json at or above ${f}`, location: { file: f } }; + for (const ref of findImports(text)) { + const pkg = packageName(ref.specifier); + if (!pkg) continue; + imports++; + if (isDeclared(pkg, manifest, ref.typeOnly) || gate.allow?.some((glob) => matchesGlob(pkg, glob))) continue; + const key = `${path.relative(root, manifest.file).split(path.sep).join("/") || "package.json"}\0${pkg}`; + if (!missing.has(key)) missing.set(key, { file: f, line: ref.line }); + } + } + if (missing.size === 0) return { ...base, ok: true, reason: `${imports} package imports declared (${files.length} files)` }; + const entries = [...missing].map(([key, at]) => ({ manifest: key.split("\0")[0], pkg: key.split("\0")[1], ...at })); + const sample = entries.slice(0, 5).map((e) => `${e.pkg} (${e.file}:${e.line})`).join(", "); + return { + ...base, + ok: false, + reason: `${entries.length} imported package${entries.length === 1 ? " is" : "s are"} not declared in ${entries[0].manifest}: ${sample}${entries.length > 5 ? " …" : ""} — add it to the manifest or remove the import`, + location: { file: entries[0].file, line: entries[0].line }, + }; + } + case "signed-commits": { + if (!opts.baseRef) { + return { ...base, ok: false, reason: `no git base ref to diff against — pass --base or run in a repo with an upstream (fail-closed)` }; + } + const accepted = gate.trust === "verified" ? ["G", "U"] : ["G", "U", "X", "Y", "E"]; + const commits = commitSignatures(cwd, opts.baseRef); + const short = baseLabel(opts.baseRef); + if (commits.length === 0) return { ...base, ok: true, reason: `no commits since ${short}` }; + const bad = commits.filter((commit) => !accepted.includes(commit.status)); + const label = (status: string) => ({ N: "unsigned", B: "bad signature", R: "revoked key", E: "cannot be verified", X: "expired signature", Y: "expired key" } as Record)[status] ?? `status ${status}`; + return bad.length + ? { + ...base, + ok: false, + reason: `${bad.length} of ${commits.length} commit(s) since ${short} not ${gate.trust === "verified" ? "verified" : "signed"}: ${bad.slice(0, 3).map((c) => `${c.sha.slice(0, 12)} ${label(c.status)} "${c.subject}"`).join(", ")}${bad.length > 3 ? " …" : ""}`, + } + : { ...base, ok: true, reason: `${commits.length} commit(s) since ${short} ${gate.trust === "verified" ? "verified" : "signed"}` }; + } default: return { ...base, ok: false, reason: `unknown gate type` }; } @@ -582,3 +776,17 @@ export function decideCommand(spec: Spec, cwd: string, command: string, opts: Ru result, }; } + +/** + * Gates that apply to `command` by their `when.command` / `when.tool` scope alone. + * `when.changed` and `when.branch` depend on the workspace and are not judged here. + */ +export function gatesForCommand(spec: Spec, command: string): string[] { + return spec.gates + .filter((gate) => { + const when = gate.when; + if (!when?.command && !when?.tool) return true; + return !!when.command && isStructuredCommandMatch(command, when.command); + }) + .map((gate) => gate.id); +} diff --git a/src/git.ts b/src/git.ts index 33861f1..c976f1e 100644 --- a/src/git.ts +++ b/src/git.ts @@ -195,3 +195,79 @@ export function currentBranch(cwd: string): string | undefined { } return process.env.GITHUB_HEAD_REF?.trim() || process.env.GITHUB_REF_NAME?.trim() || undefined; } + +export interface BaselineEntry { + /** Git file mode: 100644, 100755, or 120000 for a symlink. */ + mode: string; + oid: string; +} + +/** + * Every blob (regular file or symlink) at `ref`, keyed by path relative to cwd. + * Submodules are left out. Strict: Git errors throw. + */ +export function blobsAtRef(cwd: string, ref: string): Map { + const blobs = new Map(); + if (ref === EMPTY_TREE) return blobs; + let out: string; + try { + out = execFileSync("git", ["ls-tree", "-rz", ref], { cwd, ...GIT_OPTS }); + } catch { + throw new Error(`cannot enumerate baseline tree: ${ref}`); + } + for (const entry of out.split("\0")) { + const tab = entry.indexOf("\t"); + if (tab < 0) continue; + const [mode, type, oid] = entry.slice(0, tab).split(" "); + if (type === "blob") blobs.set(entry.slice(tab + 1), { mode, oid }); + } + return blobs; +} + +/** Object id of `content` as a blob in this repository's hash format. Strict. */ +export function hashBlob(cwd: string, content: string): string { + try { + return execFileSync("git", ["hash-object", "--stdin"], { cwd, ...GIT_OPTS, input: content }).trim(); + } catch { + throw new Error("cannot hash content"); + } +} + +/** + * Object ids the working-tree files would get if committed now (clean filters and + * line-ending normalization applied), in input order. Strict: Git errors throw. + */ +export function hashWorkingFiles(cwd: string, files: string[]): string[] { + if (files.length === 0) return []; + try { + return execFileSync("git", ["hash-object", "--stdin-paths"], { cwd, ...GIT_OPTS, input: files.join("\n") + "\n" }) + .split("\n") + .filter(Boolean); + } catch { + throw new Error("cannot hash working-tree files"); + } +} + +export interface CommitSignature { + sha: string; + /** git's %G? code: G good, U good/unknown validity, X/Y expired, R revoked, E unverifiable, B bad, N none. */ + status: string; + subject: string; +} + +/** Signature status of every commit reachable from HEAD but not from `ref`. Strict. */ +export function commitSignatures(cwd: string, ref: string): CommitSignature[] { + const range = ref === EMPTY_TREE ? ["HEAD"] : [`${ref}..HEAD`]; + let out: string; + try { + out = execFileSync("git", ["log", "-z", "--format=%H%x1f%G?%x1f%s", ...range, "--"], { cwd, ...GIT_OPTS }); + } catch (error: any) { + // A repository whose HEAD is unborn has no commits to judge. + if (ref === EMPTY_TREE && /does not have any commits|unknown revision|bad default revision/.test(String(error?.stderr ?? ""))) return []; + throw new Error(`cannot read commits since ${baseLabel(ref)}`); + } + return out.split("\0").filter(Boolean).map((record) => { + const [sha, status, subject] = record.replace(/^\n/, "").split("\x1f"); + return { sha, status, subject: subject ?? "" }; + }); +} diff --git a/src/imports.ts b/src/imports.ts new file mode 100644 index 0000000..61d8612 --- /dev/null +++ b/src/imports.ts @@ -0,0 +1,116 @@ +// Source imports versus declared dependencies. deps-locked proves every declared +// dependency resolved into the lockfile; this proves every package the code +// imports is declared at all. Pure: reads files, never resolves modules. +import fs from "node:fs"; +import path from "node:path"; +import { builtinModules } from "node:module"; + +const BUILTINS = new Set(builtinModules.flatMap((name) => [name, name.replace(/^node:/, "")])); + +const DECLARING_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; + +export interface ImportRef { + specifier: string; + line: number; + typeOnly: boolean; +} + +/** Blank out comments so commented-out imports are not judged; keeps line numbers. */ +function stripComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")) + .replace(/(^|[^:"'`\\])\/\/[^\n]*/g, (_m, lead) => lead); +} + +// Keywords must not follow an identifier character, a dot or a quote, so `"import"` +// in a string and `obj.import(` are not statements. Specifiers never hold whitespace. +const KEYWORD = String.raw`(? boolean }[] = [ + // import x from "a"; import { y } from "a"; export * from "a"; import type { T } from "a" + { re: new RegExp(String.raw`${KEYWORD}(import|export)\s+(type\s+)?[^"'\`;]*?\sfrom\s*["']([^"'\s]+)["']`, "g"), typeOnly: (m) => !!m[2] }, + // import "a" + { re: new RegExp(String.raw`${KEYWORD}import\s*["']([^"'\s]+)["']`, "g") }, + // require("a"), import("a"), require.resolve("a") + { re: new RegExp(String.raw`${KEYWORD}(?:require(?:\.resolve)?|import)\s*\(\s*["']([^"'\s]+)["']\s*\)`, "g") }, +]; + +/** Static import/require specifiers in JavaScript or TypeScript source. */ +export function findImports(text: string): ImportRef[] { + const clean = stripComments(text); + const found: ImportRef[] = []; + for (const { re, typeOnly } of PATTERNS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(clean))) { + const specifier = m[m.length - 1]; + const line = clean.slice(0, m.index).split("\n").length; + found.push({ specifier, line, typeOnly: typeOnly?.(m) ?? false }); + } + } + return found.sort((a, b) => a.line - b.line); +} + +/** + * The npm package a specifier names, or null for relative paths, absolute paths, + * subpath imports (`#x`), URLs and protocol specifiers (`node:`, `bun:`, + * `virtual:`), Node builtins, and path aliases that cannot be package names. + */ +export function packageName(specifier: string): string | null { + if (!specifier || /^[./#~]/.test(specifier) || specifier.includes(":") || specifier.includes("\\")) return null; + const parts = specifier.split("/"); + if (specifier.startsWith("@")) { + if (parts.length < 2 || parts[0] === "@" || !parts[1]) return null; + return `${parts[0]}/${parts[1]}`; + } + if (BUILTINS.has(parts[0]) || BUILTINS.has(specifier)) return null; + return parts[0]; +} + +interface Manifest { + file: string; + name?: string; + declared: Set; +} + +/** Nearest package.json from `dir` up to `root`, cached per directory. */ +export function nearestManifest(dir: string, root: string, cache: Map): Manifest | null { + const visited: string[] = []; + let current = dir; + let found: Manifest | null = null; + while (true) { + if (cache.has(current)) { + found = cache.get(current)!; + break; + } + visited.push(current); + const file = path.join(current, "package.json"); + if (fs.existsSync(file)) { + let data: any; + try { + data = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error: any) { + throw new Error(`${path.relative(root, file) || "package.json"}: invalid JSON (${error.message})`); + } + const declared = new Set(); + for (const field of DECLARING_FIELDS) { + const deps = data?.[field]; + if (deps && typeof deps === "object") for (const name of Object.keys(deps)) declared.add(name); + } + found = { file, name: typeof data?.name === "string" ? data.name : undefined, declared }; + break; + } + const parent = path.dirname(current); + if (current === root || parent === current || path.relative(root, current).startsWith("..")) break; + current = parent; + } + for (const d of visited) cache.set(d, found); + return found; +} + +/** Whether `pkg` is satisfied by the manifest (a type-only import may use @types/). */ +export function isDeclared(pkg: string, manifest: Manifest, typeOnly: boolean): boolean { + if (manifest.declared.has(pkg) || manifest.name === pkg) return true; + if (!typeOnly) return false; + const types = pkg.startsWith("@") ? `@types/${pkg.slice(1).replace("/", "__")}` : `@types/${pkg}`; + return manifest.declared.has(types); +} diff --git a/src/link.ts b/src/link.ts index b09b293..0f5704c 100644 --- a/src/link.ts +++ b/src/link.ts @@ -9,12 +9,20 @@ import { pointsTo, IMPORT_CAPABLE, LINK_HEADER, + TOOL_SPECS, type Source, } from "./drift.js"; +import { toolId } from "./spec.js"; export interface SyncOptions { dryRun?: boolean; symlink?: boolean; + /** + * Tool ids (`claude-code`, `gemini-cli`, ...) to give their own instruction file + * when they have none: an `@AGENTS.md` pointer for import-capable tools, a synced + * copy otherwise. Tools that only reach AGENTS.md through a fallback may skip it. + */ + create?: string[]; } export interface SyncResult { @@ -105,6 +113,24 @@ export function runSync(root: string, opts: SyncOptions = {}): SyncResult { changed++; } + for (const id of opts.create ?? []) { + const spec = TOOL_SPECS.find((t) => toolId(t.name) === toolId(id)); + if (!spec) { + lines.push(` ! ${id} unknown tool (known: ${TOOL_SPECS.map((t) => toolId(t.name)).join(", ")})`); + continue; + } + if (spec.name === "AGENTS.md" || sources.some((src) => src.tool === spec.name)) continue; + const rel = spec.patterns.find((p) => !/[*?{}[\]]/.test(p))!; + const filePath = path.join(root, rel.split("/").join(path.sep)); + const content = IMPORT_CAPABLE.has(spec.name) ? "@AGENTS.md\n" : LINK_HEADER + "\n\n" + canonContent; + lines.push(` + ${rel} ${verb}created ${IMPORT_CAPABLE.has(spec.name) ? "as an @AGENTS.md pointer" : "as a synced copy"} for ${spec.name}`); + if (!dryRun) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + changed++; + } + lines.push(`\n${changed} instruction files ${verb}linked to AGENTS.md`); return { lines, changed }; } diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..5f785e8 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,111 @@ +// Opt-in decision log. Every `gate` and `check` verdict can be appended as one +// JSON line, so you can see how often the gate fired, what it blocked and why — +// and tell "ran and allowed" apart from "never ran". Logging never changes a +// verdict: a log that cannot be written is reported on stderr and skipped. +import fs from "node:fs"; +import path from "node:path"; +import type { GateResult } from "./core.js"; + +export interface LogRecord { + /** ISO timestamp. */ + ts: string; + /** The skillgate command that decided: gate or check. */ + via: "gate" | "check"; + /** gate: command | stop | tool. check: check. */ + event: string; + decision: "allow" | "block"; + reason: string; + command?: string; + tool?: string; + /** Workspace the policy is rooted in. */ + workspace: string; + failed: { id: string; reason: string }[]; + durationMs?: number; +} + +/** Log destination: `--log ` wins over `SKILLGATE_LOG`. Undefined = off. */ +export function logPath(flag: string | undefined, cwd: string): string | undefined { + const value = flag?.trim() || process.env.SKILLGATE_LOG?.trim(); + return value ? path.resolve(cwd, value) : undefined; +} + +/** + * Why `file` must not be used, or null when it may. A log inside the worktree + * (outside `.git/`) would change the snapshot the gates judge and make a clean + * worktree dirty, so it is refused. + */ +export function logPathProblem(file: string, workspace: string): string | null { + const rel = path.relative(workspace, file); + if (rel.startsWith("..") || path.isAbsolute(rel)) return null; + const parts = rel.split(path.sep); + if (parts[0] === ".git") return null; + return `decision log ${rel} is inside the worktree — use a path outside it or under .git/ (logging skipped)`; +} + +export function toFailed(results: GateResult[]): { id: string; reason: string }[] { + return results.map((r) => ({ id: r.id, reason: r.reason })); +} + +/** Append one record. Returns an error message instead of throwing. */ +export function appendLog(file: string, record: LogRecord): string | null { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, JSON.stringify(record) + "\n"); + return null; + } catch (error: any) { + return `decision log not written: ${error.message}`; + } +} + +export interface LogSummary { + records: number; + malformed: number; + first?: string; + last?: string; + allowed: number; + blocked: number; + byEvent: Record; + /** Gate ids by how often they blocked, most first. */ + blockingGates: { id: string; count: number }[]; + /** Blocked commands or tools by frequency, most first. */ + blockedActions: { action: string; count: number }[]; + /** Records that blocked on an evaluation error rather than a failed gate. */ + errors: number; +} + +/** Summarize a decision log. Malformed lines are counted, never fatal. */ +export function summarizeLog(text: string, top = 10): LogSummary { + const summary: LogSummary = { records: 0, malformed: 0, allowed: 0, blocked: 0, byEvent: {}, blockingGates: [], blockedActions: [], errors: 0 }; + const gates = new Map(); + const actions = new Map(); + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + let record: LogRecord; + try { + record = JSON.parse(line); + if (!record || (record.decision !== "allow" && record.decision !== "block")) throw new Error("not a decision"); + } catch { + summary.malformed++; + continue; + } + summary.records++; + if (!summary.first || record.ts < summary.first) summary.first = record.ts; + if (!summary.last || record.ts > summary.last) summary.last = record.ts; + const event = (summary.byEvent[record.event] ??= { allowed: 0, blocked: 0 }); + if (record.decision === "allow") { + summary.allowed++; + event.allowed++; + continue; + } + summary.blocked++; + event.blocked++; + if (!record.failed?.length && /^error: /.test(record.reason ?? "")) summary.errors++; + for (const f of record.failed ?? []) gates.set(f.id, (gates.get(f.id) ?? 0) + 1); + const action = record.tool ?? record.command ?? record.event; + if (action) actions.set(action, (actions.get(action) ?? 0) + 1); + } + const ranked = (m: Map) => [...m].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, top); + summary.blockingGates = ranked(gates).map(([id, count]) => ({ id, count })); + summary.blockedActions = ranked(actions).map(([action, count]) => ({ action, count })); + return summary; +} diff --git a/src/process.ts b/src/process.ts index b0d446d..4cee412 100644 --- a/src/process.ts +++ b/src/process.ts @@ -54,9 +54,10 @@ child.once("close", (code, signal) => { }); `; -export function runShellCommand(command: string, cwd: string, timeout: number): CommandExecution { +export function runShellCommand(command: string, cwd: string, timeout: number, env?: NodeJS.ProcessEnv): CommandExecution { const result = spawnSync(process.execPath, ["-e", SUPERVISOR, command, cwd, String(timeout)], { cwd, + ...(env ? { env: { ...process.env, ...env } } : {}), encoding: "utf8", timeout: timeout + 5_000, killSignal: "SIGKILL", diff --git a/src/receipt.ts b/src/receipt.ts index bcd299e..2796b4b 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -10,7 +10,7 @@ import { currentBranch, matchesGlob } from "./git.js"; import { findDependencyLockfile } from "./deps.js"; const SCAN_IGNORE = ["**/node_modules/**", "**/.git/**", "dist/**"]; -const CACHE_TYPES = new Set(["file-exists", "file-contains", "evidence", "not-empty", "absent", "no-new", "no-fewer", "no-deleted", "phase"]); +const CACHE_TYPES = new Set(["file-exists", "file-contains", "evidence", "not-empty", "absent", "no-new", "no-fewer", "no-deleted", "unchanged", "phase"]); /** Reuse only checks whose complete inputs can be observed in the local snapshot. */ export function cacheDisabledReason(spec: Spec): string | undefined { diff --git a/src/refs.ts b/src/refs.ts new file mode 100644 index 0000000..70dc208 --- /dev/null +++ b/src/refs.ts @@ -0,0 +1,112 @@ +// Stale-reference check for AI instruction files. A rule that names a file the +// repository no longer has is a rule about a different repository: the agent +// follows it anyway. Pure: same workspace, same verdict. +import fs from "node:fs"; +import path from "node:path"; +import { discover } from "./drift.js"; +import { matchesGlob } from "./git.js"; + +export interface InstructionRef { + /** Instruction file, workspace-relative. */ + file: string; + line: number; + /** The path as written. */ + ref: string; + kind: "import" | "link" | "code"; +} + +export interface RefsResult { + files: string[]; + checked: number; + missing: InstructionRef[]; +} + +/** Extensions that mark a reference as a file rather than a branch or package name. */ +const KNOWN_EXT = /\.(md|mdc|mdx|txt|json|jsonc|ya?ml|toml|ini|lock|[cm]?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|cs|php|sh|ps1|sql|html|css|scss|svg|png)$/i; + +/** True when `text` reads as a repository path rather than a command, identifier or prose. */ +function looksLikePath(text: string): boolean { + if (!text || /\s/.test(text) || /[*?{}[\]<>$|;`'"(),=@]/.test(text)) return false; + if (/^[a-z][a-z0-9+.-]*:/i.test(text) || text.startsWith("/") || text.startsWith("~") || text.startsWith("-")) return false; + const stripped = text.replace(/\/$/, ""); + if (!stripped || stripped.split("/").some((part) => part === "")) return false; + // Bare file names (`tsconfig.json`, `camelCase.js`) are too often examples or files + // in some package directory; only directory-qualified paths are judged. + return stripped.includes("/"); +} + +/** References in one instruction file, skipping fenced code blocks. */ +export function extractRefs(content: string, file: string): InstructionRef[] { + const refs: InstructionRef[] = []; + let fenced = false; + content.split(/\r?\n/).forEach((raw, index) => { + const line = index + 1; + if (/^\s*(```|~~~)/.test(raw)) { + fenced = !fenced; + return; + } + if (fenced) return; + // Claude Code / Gemini CLI imports: "@path/to/file.md" at the start or after whitespace. + for (const m of raw.matchAll(/(?:^|\s)@((?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)*)/g)) { + const ref = m[1].replace(/[.,:;]+$/, ""); + if (KNOWN_EXT.test(ref) || ref.startsWith(".")) refs.push({ file, line, ref, kind: "import" }); + } + // Markdown links and images with a relative target. + for (const m of raw.matchAll(/!?\[[^\]]*\]\(\s*]+)>?(?:\s+"[^"]*")?\s*\)/g)) { + let target = m[1].split("#")[0].split("?")[0]; + try { + target = decodeURI(target); + } catch { + /* keep the raw target */ + } + if (target && !/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith("/") && !target.startsWith("#")) { + refs.push({ file, line, ref: target, kind: "link" }); + } + } + // Inline code spans that look like repository paths: `src/cli.ts`, `docs/`. + for (const m of raw.replace(/\[[^\]]*\]\([^)]*\)/g, "").matchAll(/`([^`\n]+)`/g)) { + const text = m[1].trim().replace(/:\d+(?::\d+)?$/, ""); + if (looksLikePath(text)) refs.push({ file, line, ref: text, kind: "code" }); + } + }); + return refs; +} + +function exists(root: string, fromDir: string, rel: string): boolean { + return [path.resolve(fromDir, rel), path.resolve(root, rel)].some((candidate) => fs.existsSync(candidate)); +} + +/** + * A slashed code span is judged only when it is clearly a repository path: a known + * file extension, a trailing slash, or a first segment that exists. That keeps + * `origin/main` and `owner/repo` out while `src/gone.ts` is still caught. + */ +function isRepoPath(root: string, fromDir: string, ref: InstructionRef): boolean { + if (ref.kind !== "code") return true; + const text = ref.ref; + if (KNOWN_EXT.test(text.replace(/\/$/, "")) || text.endsWith("/")) return true; + const first = text.replace(/^\.\//, "").split("/")[0]; + return first !== ".." && exists(root, fromDir, first); +} + +/** Check every reference in every discovered instruction file under `root`. */ +export function checkInstructionRefs(root: string, ignore: string[] = []): RefsResult { + const files = [...new Set(discover(root).flatMap((source) => source.files))]; + const result: RefsResult = { files, checked: 0, missing: [] }; + const seen = new Set(); + for (const file of files) { + const full = path.resolve(root, file); + const fromDir = path.dirname(full); + for (const ref of extractRefs(fs.readFileSync(full, "utf8"), file)) { + const normalized = path.posix.normalize(ref.ref.replace(/^\.\//, "")).replace(/\/$/, ""); + if (ignore.some((glob) => matchesGlob(normalized, glob) || matchesGlob(ref.ref, glob))) continue; + if (!isRepoPath(root, fromDir, ref)) continue; + const key = `${file}\0${ref.ref}`; + if (seen.has(key)) continue; + seen.add(key); + result.checked++; + if (!exists(root, fromDir, ref.ref)) result.missing.push(ref); + } + } + return result; +} diff --git a/src/spec.ts b/src/spec.ts index 27939c9..daa8fc5 100644 --- a/src/spec.ts +++ b/src/spec.ts @@ -118,6 +118,11 @@ export interface TrivyGate extends BaseGate { scanners?: ("vuln" | "secret")[]; /** Vulnerability severities that block. Default ["CRITICAL"]. */ severity?: ("UNKNOWN" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL")[]; + /** + * After a passing vulnerability scan, count findings at every severity and report + * the ones below the blocking threshold. Informational only. Default true. + */ + summary?: boolean; /** Also require Trivy to generate a CycloneDX SBOM. Default true. */ sbom?: boolean; /** Pass --ignore-unfixed to the vulnerability scan. Default false. */ @@ -146,6 +151,23 @@ export interface InstructionSyncGate extends BaseGate { type: "instruction-sync"; /** Similarity ratio required to count as in sync (0..1). Default 0.95. */ threshold?: number; + /** + * Tools that must have their own instruction file, in sync or linked to the + * canonical one (`claude-code`, `gemini-cli`, `cursor`, ...). A tool that only + * finds AGENTS.md through a fallback may not load it at all. + */ + require?: string[]; +} + +/** + * Every repository path an instruction file points at — `@imports`, relative + * Markdown links, and code spans such as `src/cli.ts` — must exist. Stale + * references mean agents follow rules about files that are gone. + */ +export interface InstructionRefsGate extends BaseGate { + type: "instruction-refs"; + /** Globs of referenced paths to skip (generated output, examples). */ + ignore?: string[]; } /** A directory must contain at least `min` entries. Default 1. */ @@ -199,6 +221,44 @@ export interface NoFewerGate extends BaseGate, ScanOptions, GlobOptions { ignore?: string[]; } +/** + * Every file matching `glob` that existed at the base ref must still exist with + * identical content. Protects the files that define "correct" — snapshots, + * golden outputs, applied migrations, CI workflows — from an agent that edits + * them to make a check pass. Diff-aware; fails closed without a resolvable base. + */ +export interface UnchangedGate extends BaseGate, GlobOptions { + type: "unchanged"; + glob: string; + /** Extra globs that may change. */ + ignore?: string[]; +} + +/** + * Every package a JavaScript/TypeScript source file imports must be declared in + * the nearest package.json. Catches imports that only resolve through hoisting + * or a global install, which break for the next user. + */ +export interface DepsDeclaredGate extends BaseGate, ScanOptions, GlobOptions { + type: "deps-declared"; + /** Source files to scan, e.g. "src/**\/*.{ts,tsx,js,mjs}". */ + glob: string; + /** Extra globs to exclude. */ + ignore?: string[]; + /** Package names (globs) treated as declared: path aliases, virtual modules. */ + allow?: string[]; +} + +/** + * Every commit between the base and HEAD must carry a signature. `signed` + * (default) accepts any signature git did not reject; `verified` requires a good + * signature git can check. + */ +export interface SignedCommitsGate extends BaseGate { + type: "signed-commits"; + trust?: "signed" | "verified"; +} + /** * Every dependency declared in a manifest must be present in its lockfile. A * package an agent invented (or never installed) cannot have resolved into the @@ -227,10 +287,14 @@ export type Gate = | NoDeletedGate | NoFewerGate | DepsLockedGate + | DepsDeclaredGate + | InstructionRefsGate + | UnchangedGate + | SignedCommitsGate | PhaseGate; /** Gate types that compare the working tree to a git base ref. */ -export const DIFF_GATE_TYPES = new Set(["no-new", "no-deleted", "no-fewer"]); +export const DIFF_GATE_TYPES = new Set(["no-new", "no-deleted", "no-fewer", "unchanged", "signed-commits"]); export interface Spec { /** @@ -307,6 +371,14 @@ export function findSpecPath(dir: string): string | null { } } +/** Tool ids accepted by `instruction-sync.require` (see drift.ts TOOL_SPECS). */ +export const INSTRUCTION_TOOL_IDS = ["agents-md", "claude-code", "cursor", "github-copilot", "gemini-cli", "cline", "windsurf", "jetbrains-junie"]; + +/** Normalize a tool name ("Claude Code", "claude-code", "AGENTS.md") to its id. */ +export function toolId(name: string): string { + return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} + type UnknownRecord = Record; function asRecord(value: unknown, where: string): UnknownRecord { @@ -407,7 +479,7 @@ function validateGate(value: unknown, index: number): void { timeout(); break; case "trivy": { - allow("target", "trivy", "scanners", "severity", "sbom", "ignoreUnfixed", "timeout"); + allow("target", "trivy", "scanners", "severity", "sbom", "summary", "ignoreUnfixed", "timeout"); optionalNonEmptyString(gate.target, `${where}.target`); optionalNonEmptyString(gate.trivy, `${where}.trivy`); if (gate.scanners != null) { @@ -420,7 +492,7 @@ function validateGate(value: unknown, index: number): void { throw new Error(`${where}.severity contains an unsupported severity`); } } - for (const field of ["sbom", "ignoreUnfixed"]) { + for (const field of ["sbom", "summary", "ignoreUnfixed"]) { if (gate[field] != null && typeof gate[field] !== "boolean") throw new Error(`${where}.${field} must be a boolean`); } timeout(); @@ -442,10 +514,37 @@ function validateGate(value: unknown, index: number): void { nonEmptyString(gate.file, `${where}.file`); break; case "instruction-sync": - allow("threshold"); + allow("threshold", "require"); if (gate.threshold != null && (typeof gate.threshold !== "number" || !Number.isFinite(gate.threshold) || gate.threshold < 0 || gate.threshold > 1)) { throw new Error(`${where}.threshold must be between 0 and 1`); } + if (gate.require != null) { + stringArray(gate.require, `${where}.require`); + const unknown = gate.require.filter((name) => !INSTRUCTION_TOOL_IDS.includes(toolId(name))); + if (unknown.length) throw new Error(`${where}.require has unknown tool${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} (known: ${INSTRUCTION_TOOL_IDS.join(", ")})`); + } + break; + case "instruction-refs": + allow("ignore"); + optionalStringArray(gate.ignore, `${where}.ignore`); + break; + case "unchanged": + allow("glob", "ignore", "allowEmpty"); + nonEmptyString(gate.glob, `${where}.glob`); + optionalStringArray(gate.ignore, `${where}.ignore`); + allowEmpty(); + break; + case "deps-declared": + allow("glob", "ignore", "allow", "maxBytes", "allowEmpty"); + nonEmptyString(gate.glob, `${where}.glob`); + optionalStringArray(gate.ignore, `${where}.ignore`); + optionalStringArray(gate.allow, `${where}.allow`); + maxBytes(); + allowEmpty(); + break; + case "signed-commits": + allow("trust"); + if (gate.trust != null && gate.trust !== "signed" && gate.trust !== "verified") throw new Error(`${where}.trust must be signed or verified`); break; case "not-empty": allow("path", "min"); diff --git a/test/core.test.ts b/test/core.test.ts index 082a5e8..0164876 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -104,10 +104,11 @@ exit 0 const r = runGates({ gates: [{ id: "trivy-clean", type: "trivy", trivy }] }, dir); assert.equal(r.passed, true); - assert.match(r.results[0].reason, /secret, vuln:CRITICAL, sbom:cyclonedx/); + assert.match(r.results[0].reason, /secret, vuln:CRITICAL, severity summary unavailable, sbom:cyclonedx/); const invocations = fs.readFileSync(path.join(dir, "invocations.txt"), "utf8"); assert.match(invocations, /fs --scanners secret --exit-code 1 --no-progress \./); assert.match(invocations, /fs --scanners vuln --severity CRITICAL --exit-code 1 --no-progress \./); + assert.match(invocations, /fs --scanners vuln --format json --exit-code 0 --no-progress \./); assert.match(invocations, /fs --format cyclonedx --no-progress \./); }); diff --git a/test/release-014.test.ts b/test/release-014.test.ts new file mode 100644 index 0000000..ee21a78 --- /dev/null +++ b/test/release-014.test.ts @@ -0,0 +1,418 @@ +// Tests for the 0.14.0 gates and commands: unchanged, deps-declared, +// instruction-refs, instruction-sync `require`, signed-commits, changed files +// for command gates, the trivy severity summary, the decision log, and +// `explain --commands`. Fixtures are real throwaway directories and git repos. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { runGates, gatesForCommand } from "../src/core.js"; +import { findImports, packageName } from "../src/imports.js"; +import { extractRefs } from "../src/refs.js"; +import { summarizeLog, logPathProblem } from "../src/log.js"; +import { runSync } from "../src/link.js"; +import { parseSpec, type Spec } from "../src/spec.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +function git(dir: string, args: string[]): string { + return execFileSync("git", ["-c", "user.email=t@t.dev", "-c", "user.name=t", "-c", "commit.gpgsign=false", ...args], { + cwd: dir, + stdio: "pipe", + encoding: "utf8", + }); +} + +function write(dir: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } +} + +function tmpProject(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-014-")); + write(dir, files); + return dir; +} + +/** A throwaway git repo on `main` with `files` committed; returns [dir, base sha]. */ +function gitProject(files: Record): [string, string] { + const dir = tmpProject(files); + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-q", "-m", "base"]); + return [dir, git(dir, ["rev-parse", "HEAD"]).trim()]; +} + +function sg(args: string[], cwd: string, env?: NodeJS.ProcessEnv, input?: string): { status: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, [CLI, ...args], { + cwd, + env: { ...process.env, SKILLGATE_LOG: "", ...env }, + encoding: "utf8", + input, + stdio: [input == null ? "ignore" : "pipe", "pipe", "pipe"], + }); + return { status: 0, stdout, stderr: "" }; + } catch (e: any) { + return { status: e.status ?? 1, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? "") }; + } +} + +// ---- unchanged -------------------------------------------------------------- + +test("unchanged: passes when protected files are untouched and new files are added", () => { + const [dir, base] = gitProject({ "migrations/001.sql": "create table a;\n", "src/a.ts": "x\n" }); + write(dir, { "migrations/002.sql": "create table b;\n", "src/a.ts": "y\n" }); + const r = runGates({ gates: [{ id: "migrations", type: "unchanged", glob: "migrations/**" }] }, dir, { baseRef: base }); + assert.equal(r.passed, true, r.results[0].reason); + assert.match(r.results[0].reason, /1 migrations\/\*\* file\(s\) unchanged/); +}); + +test("unchanged: blocks an edited or deleted protected file", () => { + const [dir, base] = gitProject({ "__snapshots__/a.snap": "old\n", "__snapshots__/b.snap": "keep\n", "ci.yml": "x\n" }); + write(dir, { "__snapshots__/a.snap": "new\n" }); + fs.rmSync(path.join(dir, "ci.yml")); + const spec: Spec = { + gates: [ + { id: "snapshots", type: "unchanged", glob: "**/*.snap" }, + { id: "ci", type: "unchanged", glob: "ci.yml" }, + ], + }; + const r = runGates(spec, dir, { baseRef: base }); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /1 protected file\(s\) matching \*\*\/\*\.snap changed/); + assert.match(r.results[0].reason, /__snapshots__\/a\.snap/); + assert.deepEqual(r.results[0].location, { file: "__snapshots__/a.snap" }); + assert.match(r.results[1].reason, /ci\.yml \(deleted\)/); +}); + +test("unchanged: ignores line-ending-only churn that git would normalize", () => { + const [dir, base] = gitProject({ ".gitattributes": "*.txt text eol=lf\n", "golden/out.txt": "a\nb\n" }); + write(dir, { "golden/out.txt": "a\r\nb\r\n" }); + const r = runGates({ gates: [{ id: "golden", type: "unchanged", glob: "golden/**" }] }, dir, { baseRef: base }); + assert.equal(r.passed, true, r.results[0].reason); +}); + +test("unchanged: compares a symlink by its target, not the file it points at", { skip: process.platform === "win32" }, () => { + const dir = tmpProject({ "a.txt": "a\n", "b.txt": "b\n" }); + fs.symlinkSync("a.txt", path.join(dir, "link.txt")); + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-q", "-m", "base"]); + const base = git(dir, ["rev-parse", "HEAD"]).trim(); + const spec: Spec = { gates: [{ id: "link", type: "unchanged", glob: "link.txt" }] }; + write(dir, { "a.txt": "edited\n" }); + assert.equal(runGates(spec, dir, { baseRef: base }).passed, true); + fs.rmSync(path.join(dir, "link.txt")); + fs.symlinkSync("b.txt", path.join(dir, "link.txt")); + const r = runGates(spec, dir, { baseRef: base }); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /link\.txt/); +}); + +test("unchanged: fails closed without a base and on a glob that matches nothing", () => { + const [dir, base] = gitProject({ "a.txt": "x\n" }); + assert.match(runGates({ gates: [{ id: "u", type: "unchanged", glob: "a.txt" }] }, dir).results[0].reason, /fail-closed/); + const r = runGates({ gates: [{ id: "u", type: "unchanged", glob: "nope/**" }] }, dir, { baseRef: base }); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /matches no files/); + const ok = runGates({ gates: [{ id: "u", type: "unchanged", glob: "nope/**", allowEmpty: true }] }, dir, { baseRef: base }); + assert.equal(ok.passed, true); +}); + +// ---- deps-declared ---------------------------------------------------------- + +test("deps-declared: parses import forms and package names", () => { + const refs = findImports(`import a from "alpha"; +import { b } from '@scope/beta/sub'; +import type { T } from "gamma"; +export * from "delta"; +import "side-effect"; +const e = require("epsilon/x"); +const f = await import("zeta"); +// import nope from "commented"; +/* require("blocked") */ +import rel from "./local"; +import fs from "node:fs"; +import path from "path"; +const kind: "import" | "link" = "import"; +obj.import("not-a-module");`); + assert.deepEqual(refs.map((r) => r.specifier), ["alpha", "@scope/beta/sub", "gamma", "delta", "side-effect", "epsilon/x", "zeta", "./local", "node:fs", "path"]); + assert.equal(refs.find((r) => r.specifier === "gamma")?.typeOnly, true); + assert.equal(packageName("@scope/beta/sub"), "@scope/beta"); + assert.equal(packageName("epsilon/x"), "epsilon"); + assert.equal(packageName("./local"), null); + assert.equal(packageName("node:fs"), null); + assert.equal(packageName("path"), null); + assert.equal(packageName("fs/promises"), null); + assert.equal(packageName("#internal"), null); + assert.equal(packageName("@/components/x"), null); +}); + +test("deps-declared: blocks an import missing from the nearest package.json", () => { + const dir = tmpProject({ + "package.json": JSON.stringify({ name: "app", dependencies: { yaml: "^2" }, devDependencies: { "@types/picomatch": "^4" } }), + "src/a.ts": 'import { parse } from "yaml";\nimport type { Options } from "picomatch";\nimport self from "app/x";\n', + "src/b.ts": 'import { format } from "date-fns";\n', + }); + const r = runGates({ gates: [{ id: "deps", type: "deps-declared", glob: "src/**/*.ts" }] }, dir); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /1 imported package is not declared in package\.json: date-fns \(src\/b\.ts:1\)/); + assert.deepEqual(r.results[0].location, { file: "src/b.ts", line: 1 }); + + const allowed = runGates({ gates: [{ id: "deps", type: "deps-declared", glob: "src/**/*.ts", allow: ["date-*"] }] }, dir); + assert.equal(allowed.passed, true, allowed.results[0].reason); +}); + +test("deps-declared: workspace packages answer to their own manifest", () => { + const dir = tmpProject({ + "package.json": JSON.stringify({ name: "root", devDependencies: { typescript: "^5" } }), + "packages/web/package.json": JSON.stringify({ name: "web", dependencies: { react: "^19" } }), + "packages/web/src/app.tsx": 'import React from "react";\nimport ts from "typescript";\n', + }); + const r = runGates({ gates: [{ id: "deps", type: "deps-declared", glob: "packages/**/*.tsx" }] }, dir); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /typescript/); + assert.match(r.results[0].reason, /packages\/web\/package\.json/); +}); + +test("deps-declared: spec validation rejects unknown fields", () => { + assert.throws(() => parseSpec("gates:\n - id: d\n type: deps-declared\n glob: src/**\n manifest: x\n", "t", false), /unknown field/); +}); + +// ---- instruction-refs --------------------------------------------------------- + +test("instruction-refs: extracts imports, links and path-like code spans, skipping fences", () => { + const refs = extractRefs(`@docs/rules.md +See [the guide](docs/guide.md#setup) and https://example.com. +Run \`npm test\`, edit \`src/cli.ts:12\`, read \`package.json\`, not \`process.env\`. +Branch \`origin/main\`, package @reneza/skillgate, mail me@example.com. +\`\`\` +cat src/fenced.ts +\`\`\` +`, "AGENTS.md"); + assert.deepEqual(refs.map((r) => `${r.kind}:${r.ref}`), ["import:docs/rules.md", "link:docs/guide.md", "code:src/cli.ts", "code:origin/main"]); +}); + +test("instruction-refs: fails on a stale path with its line, passes once fixed or ignored", () => { + const dir = tmpProject({ + "AGENTS.md": "# Rules\n\nTests live in `test/`.\nThe entry point is `src/old-cli.ts`.\nSee [docs](docs/x.md). Use `origin/main` as base.\n", + "test/a.test.ts": "", + "docs/x.md": "", + "src/cli.ts": "", + }); + const r = runGates({ gates: [{ id: "refs", type: "instruction-refs" }] }, dir); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /1 stale reference in instruction files: AGENTS\.md:4 → src\/old-cli\.ts/); + assert.deepEqual(r.results[0].location, { file: "AGENTS.md", line: 4 }); + const ignored = runGates({ gates: [{ id: "refs", type: "instruction-refs", ignore: ["src/old-*"] }] }, dir); + assert.equal(ignored.passed, true, ignored.results[0].reason); + assert.match(ignored.results[0].reason, /path references in 1 instruction files resolve/); +}); + +test("instruction-refs: a broken @import in CLAUDE.md fails", () => { + const dir = tmpProject({ "CLAUDE.md": "@AGENTS.md\n" }); + const r = runGates({ gates: [{ id: "refs", type: "instruction-refs" }] }, dir); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /CLAUDE\.md:1 → AGENTS\.md/); +}); + +// ---- instruction-sync require + sync --create ----------------------------------- + +test("instruction-sync require: AGENTS.md alone does not cover Claude Code", () => { + const dir = tmpProject({ "AGENTS.md": "# Rules\nrun tests\n", "CLAUDE.local.md": "my notes\n" }); + const spec: Spec = { gates: [{ id: "sync", type: "instruction-sync", require: ["claude-code"] }] }; + const r = runGates(spec, dir); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /no instruction file for Claude Code \(CLAUDE\.md\)/); + assert.match(r.results[0].reason, /CLAUDE\.local\.md/); + assert.match(r.results[0].reason, /skillgate sync --create claude-code/); + + const { lines } = runSync(dir, { create: ["claude-code", "Gemini CLI"] }); + assert.ok(lines.some((l) => /CLAUDE\.md\s+created as an @AGENTS\.md pointer/.test(l)), lines.join("\n")); + assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "@AGENTS.md\n"); + assert.equal(fs.readFileSync(path.join(dir, "GEMINI.md"), "utf8"), "@AGENTS.md\n"); + assert.equal(runGates(spec, dir).passed, true); +}); + +test("instruction-sync require: unknown tool names are rejected at load", () => { + assert.throws(() => parseSpec("gates:\n - id: s\n type: instruction-sync\n require: [claude]\n", "t", false), /unknown tool: claude/); + assert.doesNotThrow(() => parseSpec("gates:\n - id: s\n type: instruction-sync\n require: [Claude Code, gemini-cli]\n", "t", false)); +}); + +// ---- signed-commits --------------------------------------------------------------- + +test("signed-commits: blocks unsigned commits since the base, passes with none", () => { + const [dir, base] = gitProject({ "a.txt": "1\n" }); + assert.match(runGates({ gates: [{ id: "signed", type: "signed-commits" }] }, dir, { baseRef: base }).results[0].reason, /no commits since/); + write(dir, { "a.txt": "2\n" }); + git(dir, ["commit", "-qam", "unsigned change"]); + const r = runGates({ gates: [{ id: "signed", type: "signed-commits" }] }, dir, { baseRef: base }); + assert.equal(r.passed, false); + assert.match(r.results[0].reason, /1 of 1 commit\(s\) since .* not signed: [0-9a-f]{12} unsigned "unsigned change"/); + assert.match(runGates({ gates: [{ id: "signed", type: "signed-commits" }] }, dir).results[0].reason, /fail-closed/); +}); + +test("signed-commits: accepts an SSH-signed commit", { skip: process.platform === "win32" }, (t) => { + const [dir, base] = gitProject({ "a.txt": "1\n" }); + const key = path.join(dir, ".key"); + try { + execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key], { stdio: "ignore" }); + } catch { + t.skip("ssh-keygen not available"); + return; + } + const pub = fs.readFileSync(key + ".pub", "utf8").trim(); + fs.writeFileSync(path.join(dir, ".allowed"), `t@t.dev ${pub}\n`); + fs.writeFileSync(path.join(dir, ".git", "info", "exclude"), ".key*\n.allowed\n"); + for (const [k, v] of [["gpg.format", "ssh"], ["user.signingkey", key], ["gpg.ssh.allowedSignersFile", path.join(dir, ".allowed")]]) git(dir, ["config", k, v]); + write(dir, { "a.txt": "2\n" }); + try { + git(dir, ["-c", "commit.gpgsign=true", "commit", "-qam", "signed change"]); + } catch { + t.skip("git cannot sign with ssh here"); + return; + } + for (const trust of ["signed", "verified"] as const) { + const r = runGates({ gates: [{ id: "signed", type: "signed-commits", trust }] }, dir, { baseRef: base }); + assert.equal(r.passed, true, r.results[0].reason); + } +}); + +// ---- command gates receive changed files ------------------------------------------ + +test("command: SKILLGATE_CHANGED_FILES lists existing changed files filtered by when.changed", { skip: process.platform === "win32" }, () => { + const [dir, base] = gitProject({ "src/a.ts": "1\n", "src/gone.ts": "1\n", "README.md": "x\n" }); + write(dir, { "src/a.ts": "2\n", "src/new.ts": "1\n", "README.md": "y\n" }); + fs.rmSync(path.join(dir, "src/gone.ts")); + const spec: Spec = { + gates: [ + { id: "lint", type: "command", run: 'cp "$SKILLGATE_CHANGED_FILES" seen.txt && test "$SKILLGATE_CHANGED_COUNT" = 2', when: { changed: ["src/**"] } }, + ], + }; + const r = runGates(spec, dir, { baseRef: base }); + assert.equal(r.passed, true, r.results[0].reason); + assert.equal(fs.readFileSync(path.join(dir, "seen.txt"), "utf8"), "src/a.ts\nsrc/new.ts\n"); +}); + +test("command: changed-file variables are unset without a base", { skip: process.platform === "win32" }, () => { + const dir = tmpProject({}); + const r = runGates({ gates: [{ id: "c", type: "command", run: 'test -z "$SKILLGATE_CHANGED_FILES"' }] }, dir); + assert.equal(r.passed, true, r.results[0].reason); +}); + +// ---- trivy severity summary --------------------------------------------------------- + +test("trivy: a passing scan reports findings below the blocking severity", { skip: process.platform === "win32" }, () => { + const dir = tmpProject({}); + const trivy = path.join(dir, "fake-trivy.sh"); + fs.writeFileSync( + trivy, + `#!/bin/sh +case "$*" in + *"--format json"*) printf '{"Results":[{"Vulnerabilities":[{"Severity":"HIGH"},{"Severity":"HIGH"},{"Severity":"LOW"}]}]}\\n' ;; + *"--format cyclonedx"*) printf '{"bomFormat":"CycloneDX"}\\n' ;; +esac +exit 0 +`, + ); + fs.chmodSync(trivy, 0o755); + const r = runGates({ gates: [{ id: "trivy", type: "trivy", trivy, scanners: ["vuln"] }] }, dir); + assert.equal(r.passed, true); + assert.match(r.results[0].reason, /vuln:CRITICAL, not blocking: 2 HIGH, 1 LOW, sbom:cyclonedx/); + + const off = runGates({ gates: [{ id: "trivy", type: "trivy", trivy, scanners: ["vuln"], summary: false }] }, dir); + assert.doesNotMatch(off.results[0].reason, /not blocking/); +}); + +// ---- decision log --------------------------------------------------------------------- + +test("log: gate and check verdicts are appended and summarized", () => { + const dir = tmpProject({ + ".skillgate/done.yaml": 'finishLine: ["git push"]\ngates:\n - id: readme\n type: file-exists\n file: README.md\n', + }); + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-log-")); + const log = path.join(logDir, "decisions.jsonl"); + const env = { SKILLGATE_LOG: log }; + assert.equal(sg(["gate", "--command", "git push origin main"], dir, env).status, 2); + assert.equal(sg(["gate", "--command", "ls -la"], dir, env).status, 0); + assert.equal(sg(["check"], dir, env).status, 1); + const records = fs.readFileSync(log, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + assert.equal(records.length, 3); + assert.equal(records[0].decision, "block"); + assert.equal(records[0].command, "git push origin main"); + assert.deepEqual(records[0].failed.map((f: any) => f.id), ["readme"]); + assert.equal(records[1].decision, "allow"); + assert.equal(records[2].via, "check"); + + const out = sg(["log", log, "--json"], dir); + assert.equal(out.status, 0); + const summary = JSON.parse(out.stdout); + assert.equal(summary.blocked, 2); + assert.equal(summary.allowed, 1); + assert.deepEqual(summary.blockingGates, [{ id: "readme", count: 2 }]); +}); + +test("log: a log inside the worktree is refused without changing the verdict", () => { + const dir = tmpProject({ ".skillgate/done.yaml": 'finishLine: ["git push"]\ngates:\n - id: ok\n type: file-exists\n file: .skillgate/done.yaml\n' }); + const r = sg(["gate", "--command", "git push", "--log", "decisions.jsonl"], dir); + assert.equal(r.status, 0); + assert.equal(fs.existsSync(path.join(dir, "decisions.jsonl")), false); + assert.equal(logPathProblem(path.join(dir, ".git", "skillgate.jsonl"), dir), null); + assert.match(logPathProblem(path.join(dir, "x.jsonl"), dir) ?? "", /inside the worktree/); +}); + +test("log: summary skips malformed lines", () => { + const s = summarizeLog('{"ts":"1","via":"gate","event":"command","decision":"block","reason":"error: boom","failed":[]}\nnot json\n{"x":1}\n'); + assert.equal(s.records, 1); + assert.equal(s.malformed, 2); + assert.equal(s.errors, 1); +}); + +// ---- explain --commands --------------------------------------------------------------- + +test("explain --commands: replays a corpus and names the gates that would run", () => { + const dir = tmpProject({ + ".skillgate/done.yaml": `finishLine: ["git commit", "git push"] +gates: + - id: fast + type: file-exists + file: .skillgate/done.yaml + - id: full + type: file-exists + file: .skillgate/done.yaml + when: + command: ["git push"] +`, + "corpus.txt": "# replay\ngit status\ngit commit -m x\n{\"command\":\"env CI=1 git push\",\"decision\":\"allow\"}\n", + }); + const out = sg(["explain", "--commands", "corpus.txt", "--json"], dir); + assert.equal(out.status, 0, out.stderr); + const report = JSON.parse(out.stdout); + assert.equal(report.total, 3); + assert.equal(report.gated, 2); + assert.deepEqual(report.commands.map((c: any) => c.gates), [[], ["fast"], ["fast", "full"]]); + assert.deepEqual(report.byPattern, { "git commit": 1, "git push": 1 }); + + const stdin = sg(["explain", "--commands", "-"], dir, undefined, "git push\n"); + assert.match(stdin.stdout, /1 of 1 commands cross the finish line/); + assert.match(sg(["explain", "--command", "git push"], dir).stdout, /gates: fast, full/); +}); + +test("gatesForCommand: tool-only gates do not apply to commands", () => { + const spec: Spec = { + gates: [ + { id: "all", type: "file-exists", file: "x" }, + { id: "tool", type: "file-exists", file: "x", when: { tool: ["mcp__*"] } }, + { id: "push", type: "file-exists", file: "x", when: { command: ["git push"] } }, + ], + }; + assert.deepEqual(gatesForCommand(spec, "git commit -m x"), ["all"]); + assert.deepEqual(gatesForCommand(spec, "git push"), ["all", "push"]); +});