diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d60afa..42e77c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ ## Unreleased +## 0.13.0 - 2026-10-03 + +### Fixed +- Phase requirements share the complete run's remaining time budget. Command + output is capped before supervisor overflow, and completed Unix shells clean + up background descendants. Installed agent hooks pass an internal run timeout + below the host hook's timeout. +- Invalid explicitly requested Git baselines and unreadable historical trees + fail closed instead of falling back to another branch or an empty file set. + Unrelated histories require a usable common ancestor. +- Passing-result caches re-evaluate commands, security scanners, reviews, + instruction selection, and dependency checks. Local snapshots include ignored + gate inputs, branch/index state, and symlink file contents; malformed cache + records are ignored. Receipts cannot overwrite gate inputs, and runs that + change their snapshot cannot produce a passing receipt. +- Pre-commit hooks run on deletion-only commits. Reinstallation upgrades the + existing Skillgate hook, preserves command flags, hook stages and other hooks, + and `doctor` flags old wiring. +- Installed Claude Stop hooks use stdout JSON decisions, including a blocking + fallback if the gate cannot launch. Missing-file diagnostics remain blocking. +- Hook installation is serialized per project, and complete configuration files + are replaced atomically with their existing permissions preserved. +- Historical path matching uses the same glob syntax as disk scans, including + character classes, extglobs, and newline-containing filenames. +- Nested workspace checks read historical contents using Git-root-relative paths. +- Evidence and pattern checks reject directories and unreadable file inputs. + +### Breaking +- Re-run `skillgate install ` to upgrade existing hook commands. For a + Claude Stop hook, use `skillgate install claude-code --stop`. +- `--cache` reports why fresh evaluation is required for gates with inputs outside + its local snapshot. Choose receipt outputs outside the policy's input paths and + globs, or exclude the output directory with the gate's `ignore` option. +- Correct invalid `--base` / `SKILLGATE_BASE` values and replace directory-shaped + evidence with non-empty regular files. + ## 0.12.0 - 2026-10-01 ### Added diff --git a/README.md b/README.md index e56ed64..6fe4916 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,22 @@ skillgate doctor claude-code # validates policy discovery and hook registr existing agent settings, refuses to overwrite unrelated workflows, and pins generated npm commands to the installed Skillgate version. +Pre-commit installation includes deletion-only commits and upgrades an existing +Skillgate hook when re-run. Installations are serialized per project and replace +complete config files atomically while preserving existing permissions and other +hooks. If another installation is active, retry after it finishes. + Agent hooks are installed **fail-closed**: if the gate itself cannot run (npx missing, offline, registry error), the hook blocks instead of letting the command through, and it gets a 10-minute budget so a slow test suite doesn't time out into an allow. `doctor` flags a hook installed by an older version that would fail open. Re-running `install` upgrades it in place. +Generated hooks give the evaluator a nine-minute run budget inside the ten-minute +host timeout. Claude Stop hooks use explicit stdout JSON decisions, so test output +such as a missing-file error cannot turn a block into a non-blocking hook error. +Upgrade an existing Stop hook with `skillgate install claude-code --stop`. + ## Define your gates Use `skillgate init --preset no-secrets` for credential and private infrastructure diff --git a/contrib/pre-commit-config.yaml b/contrib/pre-commit-config.yaml index 4d8dacd..72fd62a 100644 --- a/contrib/pre-commit-config.yaml +++ b/contrib/pre-commit-config.yaml @@ -8,4 +8,5 @@ repos: entry: npx @reneza/skillgate check language: system pass_filenames: false + always_run: true stages: [pre-commit] diff --git a/docs/architecture.md b/docs/architecture.md index 9a3f7ad..397c32a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,7 @@ README for the research basis (the Compliance Gap). | `command.ts` | Structural shell segmentation and finish-line matching across POSIX shells, PowerShell, wrappers, and Windows executable suffixes. | | `process.ts` | Supervised command execution with bounded wall time and process-tree cleanup. | | `receipt.ts` | Repository snapshot keys, passing-result cache storage, and versioned execution receipts. | +| `files.ts` | Atomic integration configuration writes and a per-project installation lock. | | `integrations.ts` | Idempotent installer and health checks for Claude Code, OpenCode, GitHub Actions, and pre-commit. | | `drift.ts` | Instruction-file drift detection (similarity of CLAUDE.md / AGENTS.md / Cursor / Copilot / …). Powers the `instruction-sync` gate and the `drift` / `diff-instructions` commands. Also exports `lineDiff()` and `formatDiff()` for showing line-level changes between instruction files. | | `link.ts` | `runSync()` — makes one instruction file canonical and links the rest. Powers `sync`. | diff --git a/docs/compatibility.md b/docs/compatibility.md index b6fd0bc..6327a41 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -33,6 +33,12 @@ These are stable; tools may rely on them. | `1` | A gate failed / drift detected — the finish line is blocked | | `2` | Usage error: no spec found, unknown command, spec failed to load | +Hook-specific output protocols (`gate --format cursor`, `gemini`, or +`claude-stop`) exit 0 after emitting the host's allow/block JSON. The default +text and generic `--json` gate modes retain exit 0 for allow and exit 2 for block. +`claude-stop` requires `--event stop` and emits `{}` for allow or +`{"decision":"block","reason":"..."}` for block. + ## How deprecations happen 1. **Deprecate before removing.** A gate field or flag that is going away is first diff --git a/docs/recipes.md b/docs/recipes.md index 2f7fd18..0109b05 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -27,6 +27,7 @@ Block local commits until gates pass. entry: npx @reneza/skillgate@latest check language: system pass_filenames: false + always_run: true ``` ## CI (GitHub Actions) diff --git a/docs/spec-reference.md b/docs/spec-reference.md index a3527d1..ef2b640 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -121,8 +121,8 @@ unfixed CVEs. ### `evidence` The escape hatch for steps that are not machine-observable ("research X first"): the -agent writes a named file as it works, and the gate verifies the file exists and is -non-empty. +agent writes a named file as it works, and the gate verifies it is a non-empty +regular file. Directories and unreadable paths fail the check. ```yaml - id: research-recorded @@ -163,6 +163,12 @@ These gates compare the working tree with the commit the change forked from base they fail closed. The one exception is a repository with no commits at all: its first commit is judged against the empty tree. +An explicit `--base` or `SKILLGATE_BASE` must resolve; Skillgate never replaces a +misspelled request with another branch. Unreadable baseline trees fail the gate, +and unrelated histories cannot supply a common ancestor. Globs use the same +syntax for on-disk and historical paths, including braces, character classes, +and extglobs. Historical reads are scoped to the policy's workspace. + ```yaml - id: no-new-skips # count must not increase type: no-new @@ -329,12 +335,23 @@ current Git worktree root. Gates run relative to the directory that owns the pol so calling `skillgate check` from a nested package cannot accidentally use paths from the main checkout or a parent repository. -The run budget applies across all gates. A per-command `timeout` is capped by the +The run budget applies across all gates and cumulative phase requirements. A per-command `timeout` is capped by the remaining total budget, and a timed-out command's supervised process tree is terminated. Any gates that could not start are returned as blocking `not-run` results, preserving one result per configured gate. +Commands have a combined 8 MiB output cap. On Unix, completion also terminates +background descendants in the shell's process group; command gates should finish +their work before exiting. Windows timeout cleanup uses `taskkill /T` when available. `skillgate check --receipt ` writes a versioned JSON receipt with the workspace snapshot, per-gate status/reason/duration, and total budget. `--cache` stores a passing receipt outside the working tree and reuses it only for the same parsed policy, -runtime, base commit, and repository snapshot. Failing results are never cached. +runtime, base commit, branch/index state, and repository snapshot, including +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. diff --git a/package-lock.json b/package-lock.json index bf4c7bd..c822197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@reneza/skillgate", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reneza/skillgate", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", + "picomatch": "^4.0.7", "tinyglobby": "^0.2.10", "yaml": "^2.6.0" }, @@ -18,6 +19,7 @@ }, "devDependencies": { "@types/node": "^26.0.0", + "@types/picomatch": "^4.0.3", "typescript": "^7.0.2" }, "engines": { @@ -43,6 +45,13 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -401,9 +410,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index b4ad59b..0ef6d62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/skillgate", - "version": "0.12.0", + "version": "0.13.0", "publishConfig": { "access": "public" }, @@ -66,11 +66,13 @@ }, "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", + "picomatch": "^4.0.7", "tinyglobby": "^0.2.10", "yaml": "^2.6.0" }, "devDependencies": { "@types/node": "^26.0.0", + "@types/picomatch": "^4.0.3", "typescript": "^7.0.2" } } diff --git a/src/cli.ts b/src/cli.ts index 4e3f43d..308bb7f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import path from "node:path"; 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, type Spec, type PhaseGate } from "./spec.js"; +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 { checkDrift, formatDiff, DEFAULT_THRESHOLD, discover, pickCanonical } from "./drift.js"; import { runSync } from "./link.js"; @@ -12,7 +12,7 @@ import { runScaffold, listTemplates } from "./scaffold.js"; import { doctor, installIntegration, INTEGRATION_TARGETS, type IntegrationTarget } from "./integrations.js"; import { analyzeCommand } from "./command.js"; import { reviewSnapshot } from "./review.js"; -import { readCachedResult, snapshotKey, writeCachedResult, writeReceipt } from "./receipt.js"; +import { cacheDisabledReason, receiptPathIsInput, readCachedResult, snapshotKey, writeCachedResult, writeReceipt } from "./receipt.js"; import { resolveBaseRef, mergeBase, readFileAtRef, repoRelativePath, repoRoot, isClean } from "./git.js"; import { createPrivatePassProof, @@ -117,12 +117,12 @@ Flags: --command "" gate: the command to judge (else read from stdin); check: evaluate gates as required for this command (when.command) --format check: github (workflow annotations on failures); - gate: text, json, cursor, or gemini hook protocol + gate: text, json, cursor, gemini, or claude-stop hook protocol --event stop gate: judge the agent's Stop event instead of a command --tool gate: judge an agent tool call (gatedTools) instead of a command --gate phase: which phase gate, when the spec has more than one --stop install claude-code: also register the Stop hook - --timeout check: cap the complete gate run (overrides spec timeout) + --timeout check/gate: cap the complete gate run (overrides spec timeout) --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 @@ -253,20 +253,23 @@ interface Resolved { function resolveSpecAndBase(specPathHint: string | null): Resolved { const workspace = specPathHint ? specRoot(specPathHint) : (repoRoot(cwd) ?? cwd); const rawBase = resolveBaseRef(workspace, baseArg); + if (!rawBase && (baseArg?.trim() || process.env.SKILLGATE_BASE?.trim())) { + throw new Error("requested base ref cannot be resolved — refusing to use a different baseline"); + } const gateBase = rawBase ? mergeBase(workspace, rawBase) : undefined; if (!pin) { - if (!specPathHint) die(2, "no spec found — run `skillgate init` or pass a path"); + if (!specPathHint) throw new Error("no spec found — run `skillgate init` or pass a path"); return { spec: loadSpec(specPathHint), workspace, gateBase }; } if (!rawBase) { - die(2, "--pin: cannot resolve a base ref (set SKILLGATE_BASE or pass --base ) — refusing to run unpinned (fail-closed)"); + throw new Error("--pin: cannot resolve a base ref (set SKILLGATE_BASE or pass --base ) — refusing to run unpinned (fail-closed)"); } const root = repoRoot(workspace); - if (!root) die(2, "--pin: not a git repository (fail-closed)"); + if (!root) throw new Error("--pin: not a git repository (fail-closed)"); const pinnedRel = specPathHint ? repoRelativePath(workspace, specPathHint) : null; - if (specPathHint && !pinnedRel) die(2, "--pin: spec is outside the active Git worktree (fail-closed)"); + if (specPathHint && !pinnedRel) throw new Error("--pin: spec is outside the active Git worktree (fail-closed)"); const rels = pinnedRel ? [pinnedRel] : DEFAULT_SPEC_PATHS; for (const rel of rels) { const raw = readFileAtRef(workspace, gateBase!, rel); @@ -275,7 +278,7 @@ function resolveSpecAndBase(specPathHint: string | null): Resolved { return { spec: parseSpec(raw, label, rel.endsWith(".json")), workspace, gateBase, pinnedTo: gateBase }; } } - return die(2, `--pin: no committed spec at ${gateBase!.slice(0, 12)} (looked for ${rels.join(", ")}) — commit your .skillgate/done.yaml to the base branch first (fail-closed)`); + throw new Error(`--pin: no committed spec at ${gateBase!.slice(0, 12)} (looked for ${rels.join(", ")}) — commit your .skillgate/done.yaml to the base branch first (fail-closed)`); } /** Shell tool names across agents; their calls are judged as commands by `finishLine`. */ @@ -597,6 +600,7 @@ if (cmd === "check") { let result; let pinnedTo: string | undefined; let cacheHit = false; + let cacheReason: string | undefined; let snapshot: string | undefined; let workspace = cwd; try { @@ -606,20 +610,26 @@ if (cmd === "check") { const timeoutArg = option("--timeout"); const timeoutMs = timeoutArg == null ? undefined : Number(timeoutArg); if (timeoutArg != null && (!Number.isInteger(timeoutMs) || timeoutMs! < 1)) die(2, "--timeout must be a positive integer in milliseconds"); - // External state and private denylist files are not covered by the snapshot cache. - const cache = args.includes("--cache") && !r.spec.gates.some(gate => ["trufflehog", "review"].includes(gate.type)); + cacheReason = args.includes("--cache") ? cacheDisabledReason(r.spec) : undefined; + const cache = args.includes("--cache") && !cacheReason; const receipt = option("--receipt"); const receiptFile = receipt ? path.resolve(cwd, receipt) : undefined; + if (receiptFile && receiptPathIsInput(r.spec, r.workspace, receiptFile)) throw new Error("--receipt must not overwrite or exclude a gate input; choose an output outside the gates' paths/globs"); const receiptRel = receiptFile ? path.relative(r.workspace, receiptFile).split(path.sep).join("/") : ""; const snapshotIgnore = receiptRel && !receiptRel.startsWith("../") ? [receiptRel] : []; // The judged command changes which gates apply, so it is part of the snapshot. - if (cache || receipt) snapshot = snapshotKey({ ...r.spec, timeout: timeoutMs ?? r.spec.timeout, ...(forCommand != null ? { forCommand } : {}) } as Spec, r.workspace, r.gateBase, snapshotIgnore); - const cached = cache && snapshot ? readCachedResult(r.workspace, snapshot) : null; + const snapshotSpec = { ...r.spec, timeout: timeoutMs ?? r.spec.timeout, ...(forCommand != null ? { forCommand } : {}) } as Spec; + if (cache || receipt) snapshot = snapshotKey(snapshotSpec, r.workspace, r.gateBase, snapshotIgnore); + const cached = cache && snapshot ? readCachedResult(r.workspace, snapshot, r.spec) : null; if (cached) { result = cached; cacheHit = true; } else { result = runGates(r.spec, r.workspace, { baseRef: r.gateBase, timeoutMs, command: forCommand }); + if (snapshot && snapshotKey(snapshotSpec, r.workspace, r.gateBase, snapshotIgnore) !== snapshot) { + const changed: GateResult = { id: "skillgate:snapshot", type: "snapshot", ok: false, status: "fail", reason: "workspace changed during evaluation; rerun checks against the final files" }; + result = { ...result, passed: false, results: [...result.results, changed], failed: [...result.failed, changed] }; + } if (cache && snapshot) writeCachedResult(r.workspace, snapshot, result); } if (receiptFile && snapshot) writeReceipt(receiptFile, snapshot, result, cacheHit ? "cache" : "executed"); @@ -629,7 +639,7 @@ if (cmd === "check") { } if (json) { - console.log(JSON.stringify({ ...result, pinnedTo, cacheHit, snapshot }, null, 2)); + console.log(JSON.stringify({ ...result, pinnedTo, cacheHit, cacheDisabledReason: cacheReason, snapshot }, null, 2)); process.exit(result.passed ? 0 : 1); } @@ -637,6 +647,7 @@ if (cmd === "check") { console.log(c(C.dim, ` policy pinned to ${pinnedTo.slice(0, 12)} (base ref) — this change cannot loosen it`)); } if (cacheHit) console.log(c(C.dim, " exact snapshot cache hit — reused prior passing receipt")); + if (cacheReason) console.log(c(C.dim, ` ${cacheReason}`)); printResults(result.results); console.log(""); if (format === "github") for (const line of githubAnnotations(result, workspace)) console.log(line); @@ -785,7 +796,7 @@ if (cmd === "phase") { ? `no phase gate with id ${wanted}` : phaseGates.length ? `spec has ${phaseGates.length} phase gates — pass --gate ` : "spec has no phase gate"); } - const opts = { baseRef: r.gateBase, spec: r.spec, gates: new Map(r.spec.gates.map((g) => [g.id, g])), memo: new Map() }; + const opts = { baseRef: r.gateBase, spec: r.spec, deadline: Date.now() + (r.spec.timeout ?? DEFAULT_RUN_TIMEOUT_MS), gates: new Map(r.spec.gates.map((g) => [g.id, g])), memo: new Map() }; const marker = path.resolve(r.workspace, gate.current ?? DEFAULT_PHASE_FILE); const current = currentPhase(gate, r.workspace); @@ -828,9 +839,10 @@ if (cmd === "gate") { // gemini formats answer in JSON on stdout instead). Fails closed on error unless // --allow-on-error. `--event stop` judges the agent ending its turn instead. const format = json ? "json" : (option("--format") ?? "text"); - if (!["text", "json", "cursor", "gemini"].includes(format)) die(2, `gate --format must be text, json, cursor, or gemini`); + if (!["text", "json", "cursor", "gemini", "claude-stop"].includes(format)) die(2, `gate --format must be text, json, cursor, gemini, or claude-stop`); const event = option("--event") ?? "command"; if (!["command", "stop"].includes(event)) die(2, `gate --event must be command or stop`); + if (format === "claude-stop" && event !== "stop") die(2, "--format claude-stop requires --event stop"); const allowOnError = args.includes("--allow-on-error"); const payload = readStdinPayload(); // A non-shell tool call (an MCP tool, say) is judged by `gatedTools`, not `finishLine`. @@ -847,7 +859,10 @@ if (cmd === "gate") { 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.` : `skillgate blocked ${tool ? `tool ${tool}` : `"${command}"`}: ${reason}.\n${details}\nComplete the unmet gates, then retry.`; - if (format === "json") { + if (format === "claude-stop") { + console.log(JSON.stringify(decision === "allow" ? {} : { decision: "block", reason: guidance })); + process.exit(0); + } else if (format === "json") { console.log(JSON.stringify({ decision, reason, command, event, ...extra }, null, 2)); } else if (format === "cursor") { console.log(JSON.stringify(decision === "allow" @@ -870,15 +885,18 @@ if (cmd === "gate") { try { const r = resolveSpecAndBase(specPath && fs.existsSync(specPath) ? specPath : null); + 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"); if (event === "stop") { if (isClean(r.workspace) === true) answer("allow", "no changes in the worktree — nothing to verify", [], { pinnedTo: r.pinnedTo }); - const result = runGates(r.spec, r.workspace, { baseRef: r.gateBase }); + const result = runGates(r.spec, r.workspace, { baseRef: r.gateBase, timeoutMs }); if (result.passed) answer("allow", `all ${applied(result)} applicable gates passed`, [], { pinnedTo: r.pinnedTo, result }); answer("block", `${result.failed.length} of ${applied(result)} gates unmet: ${result.failed.map((f) => f.id).join(", ")}`, result.failed, { pinnedTo: r.pinnedTo, result }); } const decision = tool - ? decideTool(r.spec, r.workspace, tool, { baseRef: r.gateBase }) - : decideCommand(r.spec, r.workspace, command, { baseRef: r.gateBase }); + ? decideTool(r.spec, r.workspace, tool, { baseRef: r.gateBase, timeoutMs }) + : decideCommand(r.spec, r.workspace, command, { baseRef: r.gateBase, timeoutMs }); answer(decision.decision, decision.reason, decision.result?.failed ?? [], { pinnedTo: r.pinnedTo, ...(decision.result ? { result: decision.result } : {}) }); } catch (e: any) { answer(allowOnError ? "allow" : "block", `error: ${e.message}`); diff --git a/src/core.ts b/src/core.ts index c8071f6..a92deaf 100644 --- a/src/core.ts +++ b/src/core.ts @@ -15,7 +15,7 @@ import { DEFAULT_RUN_TIMEOUT_MS, } from "./spec.js"; import { checkDrift, DEFAULT_THRESHOLD } from "./drift.js"; -import { readFileAtRef, listFilesAtRef, matchesGlob, changedFiles, currentBranch, baseLabel } from "./git.js"; +import { readFileAtRef, listFilesAtRef, matchesGlob, changedFiles, currentBranch, baseLabel, repoRelativePath } from "./git.js"; import { checkManifest, SUPPORTED_MANIFESTS } from "./deps.js"; import { isStructuredCommandMatch } from "./command.js"; import { runShellCommand } from "./process.js"; @@ -49,6 +49,8 @@ export interface RunOptions { timeoutMs?: number; /** Internal per-gate cap derived from the remaining total budget. */ remainingMs?: number; + /** Absolute run deadline, shared with phase dependencies. */ + deadline?: number; /** The finish-line command being judged, for `when.command`. Unset = every gate applies. */ command?: string; /** The agent tool call being judged (see `gatedTools`), for `when.tool`. */ @@ -85,6 +87,7 @@ export function currentPhase(gate: PhaseGate, cwd: string): string { * the workspace, so an agent cannot mark a phase done without doing it. */ export function evaluatePhase(gate: PhaseGate, target: string, cwd: string, opts: RunOptions): PhaseStatus { + const phaseOpts = { ...opts, deadline: opts.deadline ?? Date.now() + (opts.remainingMs ?? opts.timeoutMs ?? opts.spec?.timeout ?? DEFAULT_RUN_TIMEOUT_MS) }; const index = gate.phases.findIndex((phase) => phase.id === target); if (index < 0) { return { phase: target, failed: [], required: [], error: `unknown phase ${target} (phases: ${gate.phases.map((p) => p.id).join(", ")})` }; @@ -97,7 +100,7 @@ export function evaluatePhase(gate: PhaseGate, target: string, cwd: string, opts failed.push({ id, type: "missing", ok: false, reason: `no gate with id ${id}` }); continue; } - const result = runOne(dep, cwd, opts); + const result = runOne(dep, cwd, phaseOpts); if (!result.ok) failed.push(result); } return { phase: target, failed, required }; @@ -107,7 +110,13 @@ export function evaluatePhase(gate: PhaseGate, target: string, cwd: string, opts function runOne(gate: Gate, cwd: string, opts: RunOptions): GateResult { const cached = opts.memo?.get(gate.id); if (cached) return cached; - const result = checkGate(gate, cwd, opts); + const remainingMs = opts.deadline == null ? opts.remainingMs : opts.deadline - Date.now(); + const started = Date.now(); + const result: GateResult = remainingMs != null && remainingMs <= 0 + ? { id: gate.id, type: gate.type, ok: false, status: "not-run", reason: "not run: overall timeout exhausted", durationMs: 0 } + : checkGate(gate, cwd, { ...opts, remainingMs }); + result.status ??= result.ok ? "pass" : "fail"; + result.durationMs ??= Date.now() - started; opts.memo?.set(gate.id, result); return result; } @@ -160,10 +169,11 @@ class Scanner { let size: number; try { const stat = fs.statSync(full); - if (!stat.isFile()) return null; + if (!stat.isFile()) throw new ScanLimit(`${rel} is not a regular file`); size = stat.size; - } catch { - return null; + } catch (error: any) { + if (error?.code === "ENOENT") return null; + throw error; } this.cap(rel, size); this.scanned++; @@ -173,7 +183,9 @@ class Scanner { /** Text as of a git ref, or null when the file did not exist there. */ atRef(cwd: string, ref: string, rel: string, total: number): string | null { this.tick(total); - const text = readFileAtRef(cwd, ref, rel); + const historical = repoRelativePath(cwd, path.resolve(cwd, rel)); + if (!historical) throw new Error(`cannot resolve baseline path: ${rel}`); + const text = readFileAtRef(cwd, ref, historical, true); if (text != null) this.cap(`${rel} at ${ref.slice(0, 12)}`, Buffer.byteLength(text)); return text; } @@ -266,7 +278,8 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { const full = path.resolve(cwd, gate.file); if (!fs.existsSync(full)) return { ...base, ok: false, reason: `file not found: ${gate.file}`, location: { file: gate.file } }; const re = new RegExp(gate.pattern, gate.flags ?? ""); - const text = new Scanner(gate.maxBytes, opts.remainingMs).file(cwd, gate.file, 1) ?? ""; + const text = new Scanner(gate.maxBytes, opts.remainingMs).file(cwd, gate.file, 1); + if (text == null) return { ...base, ok: false, reason: `file could not be read: ${gate.file}`, location: { file: gate.file } }; return re.test(text) ? { ...base, ok: true, reason: `${gate.file} matches /${gate.pattern}/` } : { ...base, ok: false, reason: `${gate.file} missing /${gate.pattern}/`, location: { file: gate.file } }; @@ -278,7 +291,7 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { const scan = new Scanner(gate.maxBytes, opts.remainingMs); for (const f of files) { const text = scan.file(cwd, f, files.length); - if (text == null) continue; + if (text == null) return { ...base, ok: false, reason: `matched file disappeared: ${f}`, location: { file: f } }; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { @@ -292,6 +305,7 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { 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); 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}` }; if (result.status === 0) return { ...base, ok: true, reason: `\`${gate.run}\` exited 0` }; const tail = String(result.stderr || result.stdout || "") @@ -310,7 +324,10 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { case "evidence": { const full = path.resolve(cwd, gate.file); if (!fs.existsSync(full)) return { ...base, ok: false, reason: `evidence missing: ${gate.file}`, location: { file: gate.file } }; - if (fs.statSync(full).size === 0) return { ...base, ok: false, reason: `evidence empty: ${gate.file}`, location: { file: gate.file } }; + const stat = fs.statSync(full); + if (!stat.isFile()) return { ...base, ok: false, reason: `evidence is not a regular file: ${gate.file}`, location: { file: gate.file } }; + if (stat.size === 0) return { ...base, ok: false, reason: `evidence empty: ${gate.file}`, location: { file: gate.file } }; + fs.accessSync(full, fs.constants.R_OK); return { ...base, ok: true, reason: `evidence present: ${gate.file}` }; } case "instruction-sync": { @@ -347,7 +364,7 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { const re = () => new RegExp(gate.pattern, flags); const ignore = gate.ignore ?? []; const workFiles = globSync(gate.glob, { cwd, dot: true, ignore: [...IGNORE, ...ignore] }); - const baseFiles = listFilesAtRef(cwd, opts.baseRef).filter( + const baseFiles = listFilesAtRef(cwd, opts.baseRef, true).filter( (p) => matchesGlob(p, gate.glob, ignore) && !IGNORE.some((ig) => matchesGlob(p, ig)), ); const union = [...new Set([...workFiles, ...baseFiles])]; @@ -394,7 +411,7 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { 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 baseFiles = listFilesAtRef(cwd, opts.baseRef).filter((p) => matchesGlob(p, gate.glob, ignore)); + const baseFiles = listFilesAtRef(cwd, opts.baseRef, true).filter((p) => matchesGlob(p, gate.glob, ignore)); if (baseFiles.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) }; @@ -497,32 +514,14 @@ export function runGates(spec: Spec, cwd: string, opts: RunOptions = {}): RunRes const ctx: { changed?: string[] | null; branch?: string | null } = {}; const gates = new Map((spec.gates ?? []).map((gate) => [gate.id, gate])); const memo = new Map(); + const deadline = started + timeoutMs; for (const gate of spec.gates ?? []) { const skipped = skipReason(gate.when, cwd, opts, ctx); if (skipped) { results.push({ id: gate.id, type: gate.type, ok: true, status: "skipped", durationMs: 0, reason: skipped }); continue; } - const elapsed = Date.now() - started; - const remainingMs = timeoutMs - elapsed; - if (remainingMs <= 0) { - results.push({ - id: gate.id, - type: gate.type, - ok: false, - status: "not-run", - durationMs: 0, - reason: `not run: overall timeout exhausted after ${timeoutMs}ms`, - }); - continue; - } - const gateStarted = Date.now(); - const result = runOne(gate, cwd, { ...opts, remainingMs, gates, memo, spec }); - results.push({ - ...result, - status: result.ok ? "pass" : "fail", - durationMs: Date.now() - gateStarted, - }); + results.push(runOne(gate, cwd, { ...opts, deadline, gates, memo, spec })); } const failed = results.filter((r) => !r.ok); return { passed: failed.length === 0, results, failed, durationMs: Date.now() - started, timeoutMs }; diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..cbff2e4 --- /dev/null +++ b/src/files.ts @@ -0,0 +1,45 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** Replace one complete configuration, preserving the old file on failure. */ +export function writeTextAtomic(file: string, text: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + let mode = 0o600; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile()) throw new Error(`refusing to replace a non-regular configuration: ${file}`); + mode = stat.mode & 0o777; + } catch (error: any) { + if (error?.code !== "ENOENT") throw error; + } + const temp = `${file}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(temp, text, { mode, flag: "wx" }); + fs.chmodSync(temp, mode); + fs.renameSync(temp, file); + } finally { + fs.rmSync(temp, { force: true }); + } +} + +/** Serialize Skillgate installers for one real project root. Never break a live lock. */ +export function withInstallLock(root: string, action: () => T): T { + const identity = crypto.createHash("sha256").update(fs.realpathSync(root)).digest("hex"); + const file = path.join(os.tmpdir(), `skillgate-install-${identity}.lock`); + let fd: number; + try { + fd = fs.openSync(file, "wx", 0o600); + } catch (error: any) { + if (error?.code === "EEXIST") throw new Error(`another Skillgate installation holds ${file}; retry after it finishes (remove a stale lock only after confirming the installer stopped)`); + throw error; + } + try { + fs.writeFileSync(fd, String(process.pid)); + return action(); + } finally { + fs.closeSync(fd); + fs.rmSync(file, { force: true }); + } +} diff --git a/src/git.ts b/src/git.ts index 516304c..33861f1 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; +import picomatch from "picomatch"; /** * Git plumbing for base-pinned and diff-aware gates. Every call is read-only and @@ -32,7 +33,7 @@ export function repoRoot(cwd: string): string | null { /** Git-root-relative path for `file`, robust to Windows short/long path aliases. */ export function repoRelativePath(cwd: string, file: string): string | null { try { - const prefix = execFileSync("git", ["rev-parse", "--show-prefix"], { cwd, ...GIT_OPTS }).trim(); + const prefix = execFileSync("git", ["rev-parse", "--show-prefix"], { cwd, ...GIT_OPTS }).replace(/\r?\n$/, ""); const relative = path.relative(cwd, file).split(path.sep).join("/"); if (relative === ".." || relative.startsWith("../")) return null; return path.posix.normalize(path.posix.join(prefix, relative)); @@ -57,6 +58,8 @@ function refExists(cwd: string, ref: string): boolean { * when none do (the caller then fails closed). */ export function resolveBaseRef(cwd: string, requested?: string): string | null { + const explicit = requested?.trim() || process.env.SKILLGATE_BASE?.trim(); + if (explicit) return refExists(cwd, explicit) ? explicit : null; const originHead = (() => { try { return execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd, ...GIT_OPTS }) @@ -66,7 +69,7 @@ export function resolveBaseRef(cwd: string, requested?: string): string | null { return undefined; } })(); - const candidates = [requested, process.env.SKILLGATE_BASE, originHead, "origin/main", "origin/master", "main", "master"] + const candidates = [originHead, "origin/main", "origin/master", "main", "master"] .map((r) => r?.trim()) .filter((r): r is string => !!r); for (const ref of candidates) { @@ -101,78 +104,49 @@ function hasNoCommits(cwd: string): boolean { * The fork point of HEAD and `ref` — the commit the current change actually * branched from. Pinning to this (not the moving tip of `ref`) is what makes a * gate immune to the change under review: the same diff cannot edit the policy - * it is judged by. Falls back to `ref` itself if no common ancestor is found. + * it is judged by. Refuses baselines without a readable common ancestor. */ export function mergeBase(cwd: string, ref: string): string { if (ref === EMPTY_TREE) return ref; try { return execFileSync("git", ["merge-base", ref, "HEAD"], { cwd, ...GIT_OPTS }).trim(); } catch { - return ref; + throw new Error(`cannot determine a common ancestor with ${ref} — refusing an unrelated or unreadable baseline`); } } /** Contents of `relPath` (repo-root-relative, posix) at `ref`, or null if it did not exist there. */ -export function readFileAtRef(cwd: string, ref: string, relPath: string): string | null { +export function readFileAtRef(cwd: string, ref: string, relPath: string, strict = false): string | null { + if (ref === EMPTY_TREE) return null; + if (strict) { + const tree = execFileSync("git", ["ls-tree", "-z", ref, "--", relPath], { cwd: repoRoot(cwd) ?? cwd, ...GIT_OPTS }); + if (!tree) return null; + if (!/^100(?:644|755) blob /.test(tree)) throw new Error(`baseline path is not a regular file: ${relPath}`); + } try { return execFileSync("git", ["show", `${ref}:${relPath}`], { cwd, ...GIT_OPTS }); - } catch { + } catch (error) { + if (strict) throw new Error(`cannot read baseline file: ${relPath}`); return null; } } -/** Every tracked path at `ref` (repo-root-relative, posix), or [] on failure. */ -export function listFilesAtRef(cwd: string, ref: string): string[] { +/** Every tracked path at `ref`, relative to cwd; strict callers reject Git errors. */ +export function listFilesAtRef(cwd: string, ref: string, strict = false): string[] { + if (ref === EMPTY_TREE) return []; try { - return execFileSync("git", ["ls-tree", "-r", "--name-only", ref], { cwd, ...GIT_OPTS }) - .split("\n") - .map((s) => s.trim()) + return execFileSync("git", ["ls-tree", "-rz", "--name-only", ref], { cwd, ...GIT_OPTS }) + .split("\0") .filter(Boolean); - } catch { + } catch (error) { + if (strict) throw new Error(`cannot enumerate baseline tree: ${ref}`); return []; } } -/** - * Compile a shell-style glob to an anchored RegExp for matching paths that only - * exist at a git ref (so they can't be walked on disk). Supports `**`, `*`, `?` - * and `{a,b}` alternation — the subset that appears in gate globs. Kept small and - * dependency-free on purpose; tinyglobby handles the on-disk side. - */ +/** Compile the same path glob syntax used by the on-disk scanner. */ export function globToRegExp(glob: string): RegExp { - let re = ""; - for (let i = 0; i < glob.length; i++) { - const ch = glob[i]; - if (ch === "*") { - if (glob[i + 1] === "*") { - re += ".*"; - i++; - if (glob[i + 1] === "/") i++; // consume the slash after ** so **/x matches x at root - } else { - re += "[^/]*"; - } - } else if (ch === "?") { - re += "[^/]"; - } else if (ch === "{") { - const end = glob.indexOf("}", i); - if (end === -1) { - re += "\\{"; - } else { - const inner = glob - .slice(i + 1, end) - .split(",") - .map((s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&")) - .join("|"); - re += `(?:${inner})`; - i = end; - } - } else if (".+^$()|[]\\".includes(ch)) { - re += "\\" + ch; - } else { - re += ch; - } - } - return new RegExp("^" + re + "$"); + return picomatch.makeRe(glob, { dot: true, flags: "s" }); } /** True when `path` matches `glob` and none of the `ignore` globs. */ diff --git a/src/integrations.ts b/src/integrations.ts index 404ccfc..e31aa3f 100644 --- a/src/integrations.ts +++ b/src/integrations.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { findSpecPath, loadSpec, specRoot } from "./spec.js"; import { repoRoot } from "./git.js"; +import { writeTextAtomic, withInstallLock } from "./files.js"; export const INTEGRATION_TARGETS = ["claude-code", "codex", "gemini-cli", "cursor", "opencode", "github-actions", "pre-commit"] as const; export type IntegrationTarget = (typeof INTEGRATION_TARGETS)[number]; @@ -14,9 +15,11 @@ export interface InstallOptions { /** Hook budget in seconds. Long enough for a test suite; a hook that times out fails open in most agents. */ export const HOOK_TIMEOUT_SECONDS = 600; +export const GATE_TIMEOUT_MS = 540_000; const MARKER = "@reneza/skillgate@"; -/** Appended to every agent hook: any failure to run the gate (npx, network) becomes a block. */ +/** Command hooks block when the gate cannot run; Stop hooks use a JSON fallback. */ const FAIL_CLOSED = " || exit 2"; +const STOP_FAIL_CLOSED = ` || node -e "console.log(JSON.stringify({decision:'block',reason:'Skillgate could not run; restore the gate before finishing.'}))"`; export interface InstallResult { target: IntegrationTarget; @@ -32,8 +35,7 @@ export interface DoctorCheck { } function writeJson(file: string, value: unknown): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n"); + writeTextAtomic(file, JSON.stringify(value, null, 2) + "\n"); } function packageRef(): string { @@ -55,9 +57,9 @@ function readJson(file: string): any { } } -/** The shell command an agent hook runs. Exit 2 blocks in every supported agent. */ +/** The shell command an agent hook runs, with the protocol's blocking fallback. */ export function gateCommand(extra = ""): string { - return `npx --yes ${packageRef()} gate${extra}${FAIL_CLOSED}`; + return `npx --yes ${packageRef()} gate --timeout ${GATE_TIMEOUT_MS}${extra}${extra.includes("--format claude-stop") ? STOP_FAIL_CLOSED : FAIL_CLOSED}`; } /** Regex source for a tool-name glob (`*` any run, `?` one character). */ @@ -116,7 +118,7 @@ function installClaude(cwd: string, opts: InstallOptions): InstallResult { }, file)]; if (opts.stop) { outcomes.push(upsertHook(data.hooks, "Stop", { - hooks: [{ type: "command", command: gateCommand(" --event stop"), timeout: HOOK_TIMEOUT_SECONDS }], + hooks: [{ type: "command", command: gateCommand(" --event stop --format claude-stop"), timeout: HOOK_TIMEOUT_SECONDS }], }, file)); } return hookResult("claude-code", file, outcomes, opts.stop ? "PreToolUse and Stop hooks" : "PreToolUse hook", data); @@ -201,8 +203,7 @@ function installGitHubActions(cwd: string): InstallResult { if (!old.includes("@reneza/skillgate")) throw new Error(`${file} already exists and is not a Skillgate workflow`); return { target: "github-actions", changed: false, file, detail: "workflow already installed" }; } - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, actionWorkflow()); + writeTextAtomic(file, actionWorkflow()); return { target: "github-actions", changed: true, file, detail: "installed pull-request workflow" }; } @@ -212,27 +213,33 @@ function installPreCommit(cwd: string): InstallResult { if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(`${file}: expected a YAML object`); data.repos ??= []; if (!Array.isArray(data.repos)) throw new Error(`${file}: repos must be an array`); - const exists = JSON.stringify(data.repos).includes("@reneza/skillgate"); - if (!exists) { + const packagePattern = /@reneza\/skillgate(?:@[^\s"';&|]+)?(?=\s|["';&|]|$)/; + const owned = data.repos.flatMap((repo: any) => repo?.repo === "local" && Array.isArray(repo.hooks) ? repo.hooks : []) + .find((hook: any) => hook?.id === "skillgate" && typeof hook.entry === "string" && packagePattern.test(hook.entry)); + const wanted = { + id: "skillgate", name: owned?.name ?? "skillgate definition-of-done", + entry: owned ? owned.entry.replace(packagePattern, packageRef()) : `npx --yes ${packageRef()} check`, + language: "system", pass_filenames: false, always_run: true, + stages: [...new Set([...(Array.isArray(owned?.stages) ? owned.stages : []), "pre-commit"])], + }; + const changed = !owned || Object.entries(wanted).some(([key, value]) => JSON.stringify(owned[key]) !== JSON.stringify(value)); + if (owned) Object.assign(owned, wanted); + else { data.repos.push({ repo: "local", - hooks: [{ - id: "skillgate", - name: "skillgate definition-of-done", - entry: `npx --yes ${packageRef()} check`, - language: "system", - pass_filenames: false, - stages: ["pre-commit"], - }], + hooks: [wanted], }); - fs.writeFileSync(file, stringifyYaml(data)); } - return { target: "pre-commit", changed: !exists, file, detail: exists ? "hook already installed" : "installed pre-commit hook" }; + if (changed) { + writeTextAtomic(file, stringifyYaml(data)); + } + return { target: "pre-commit", changed, file, detail: changed ? "installed or upgraded always-run pre-commit hook" : "hook already installed" }; } export function installIntegration(target: IntegrationTarget, cwd: string, opts: InstallOptions = {}): InstallResult { const root = projectRoot(cwd); - switch (target) { + return withInstallLock(root, () => { + switch (target) { case "claude-code": return installClaude(root, opts); case "codex": return installCodex(root); case "gemini-cli": return installGemini(root); @@ -240,7 +247,8 @@ export function installIntegration(target: IntegrationTarget, cwd: string, opts: case "opencode": return installOpenCode(root); case "github-actions": return installGitHubActions(root); case "pre-commit": return installPreCommit(root); - } + } + }); } function fileContains(file: string, marker: string): boolean { @@ -287,9 +295,31 @@ export function doctor(cwd: string, targets: readonly IntegrationTarget[] = INTE checks.push({ id: target, ok: false, detail: `configured in ${rel} but fails open when the gate cannot run — re-run \`skillgate install ${target}\`` }); } else if (uncovered(target, file, root).length) { checks.push({ id: target, ok: false, detail: `hook in ${rel} does not cover gatedTools ${uncovered(target, file, root).join(", ")} — re-run \`skillgate install ${target}\`` }); + } else if (target === "pre-commit" && !preCommitHealthy(file)) { + checks.push({ id: target, ok: false, detail: `hook in ${rel} can skip commits — re-run \`skillgate install pre-commit\`` }); + } else if (target === "claude-code" && !stopHookHealthy(file)) { + checks.push({ id: target, ok: false, detail: `Stop hook in ${rel} needs Claude JSON output — re-run \`skillgate install claude-code --stop\`` }); + } else if (failClosed && !fileContains(file, `--timeout ${GATE_TIMEOUT_MS}`)) { + checks.push({ id: target, ok: false, detail: `hook in ${rel} lacks an internal timeout — re-run \`skillgate install ${target}\`` }); } else { checks.push({ id: target, ok: true, detail: `configured in ${rel}` }); } } return checks; } + +function preCommitHealthy(file: string): boolean { + try { + const data: any = parseYaml(fs.readFileSync(file, "utf8")); + return data.repos?.some((repo: any) => repo.repo === "local" && repo.hooks?.some((hook: any) => + hook.id === "skillgate" && hook.entry?.includes("@reneza/skillgate") && hook.always_run === true && hook.pass_filenames === false)); + } catch { return false; } +} + +function stopHookHealthy(file: string): boolean { + try { + const hooks = readJson(file).hooks?.Stop ?? []; + return !hooks.some((entry: any) => entry.hooks?.some((hook: any) => + hook.command?.includes(MARKER) && (!hook.command.includes("--format claude-stop") || !hook.command.includes(`--timeout ${GATE_TIMEOUT_MS}`) || !hook.command.includes(STOP_FAIL_CLOSED)))); + } catch { return false; } +} diff --git a/src/process.ts b/src/process.ts index f0dc04e..b0d446d 100644 --- a/src/process.ts +++ b/src/process.ts @@ -6,6 +6,7 @@ export interface CommandExecution { stdout: string; stderr: string; timedOut: boolean; + outputLimited: boolean; error?: Error; } @@ -18,23 +19,38 @@ const command = process.argv[1]; const cwd = process.argv[2]; const timeout = Number(process.argv[3]); const windows = process.platform === "win32"; -const child = spawn(command, { cwd, shell: true, detached: !windows, stdio: "inherit" }); +const child = spawn(command, { cwd, shell: true, detached: !windows, stdio: ["ignore", "pipe", "pipe"] }); let expired = false; -const timer = setTimeout(() => { - expired = true; +let limited = false; +let bytes = 0; +function terminate() { if (windows && child.pid) { const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); killer.once("error", () => { try { child.kill("SIGKILL"); } catch {} }); } else if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } } -}, timeout); +} +function forward(stream, data) { + bytes += data.length; + if (bytes > 8 * 1024 * 1024) { limited = true; terminate(); return; } + stream.write(data); +} +child.stdout.on("data", data => forward(process.stdout, data)); +child.stderr.on("data", data => forward(process.stderr, data)); +const timer = setTimeout(() => { expired = true; terminate(); }, timeout); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { terminate(); process.exit(128); }); +} child.once("error", error => { clearTimeout(timer); console.error(error.message); process.exit(126); }); child.once("exit", (code, signal) => { + // A completed shell must not leave background jobs holding its output pipes. + if (!windows) terminate(); +}); +child.once("close", (code, signal) => { clearTimeout(timer); - if (expired) process.exit(124); - if (signal) process.exit(128); - process.exit(code == null ? 1 : code); + const status = limited ? 125 : expired ? 124 : signal ? 128 : code == null ? 1 : code; + process.exitCode = status; }); `; @@ -53,6 +69,7 @@ export function runShellCommand(command: string, cwd: string, timeout: number): stdout: String(result.stdout || ""), stderr: String(result.stderr || ""), timedOut: result.status === 124 || result.signal === "SIGKILL" || error?.code === "ETIMEDOUT", + outputLimited: result.status === 125 || error?.code === "ENOBUFS", error, }; } diff --git a/src/receipt.ts b/src/receipt.ts index cc0f318..976efb7 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -5,6 +5,45 @@ import path from "node:path"; import { execFileSync } from "node:child_process"; import type { RunResult } from "./core.js"; import type { Spec } from "./spec.js"; +import { globSync } from "tinyglobby"; +import { currentBranch, matchesGlob } from "./git.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"]); + +/** Reuse only checks whose complete inputs can be observed in the local snapshot. */ +export function cacheDisabledReason(spec: Spec): string | undefined { + const unsafe = spec.gates.filter(gate => !CACHE_TYPES.has(gate.type)); + return unsafe.length ? `cache disabled: ${[...new Set(unsafe.map(gate => gate.type))].join(", ")} gates require fresh evaluation` : undefined; +} + +/** Explicit and ignored files read by local gates still participate in the snapshot. */ +function gateFiles(spec: Spec, cwd: string): string[] { + const files = new Set(); + const add = (file: string) => files.add(path.relative(cwd, path.resolve(cwd, file)).split(path.sep).join("/")); + for (const gate of spec.gates) { + if ("file" in gate) (Array.isArray(gate.file) ? gate.file : [gate.file]).forEach(add); + if ("glob" in gate) globSync(gate.glob, { cwd, dot: true, ignore: [...SCAN_IGNORE, ...(gate.ignore ?? [])] }).forEach(add); + if (gate.type === "not-empty") { + add(gate.path); + try { fs.readdirSync(path.resolve(cwd, gate.path)).forEach(file => add(path.join(gate.path, file))); } catch { /* existence is hashed below */ } + } + if (gate.type === "phase") add(gate.current ?? ".skillgate/phase"); + if (gate.type === "deps-locked") { + const manifests = gate.manifest == null ? ["package.json", "pyproject.toml"] : Array.isArray(gate.manifest) ? gate.manifest : [gate.manifest]; + for (const file of manifests) { + add(file); + for (const lock of ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "uv.lock", "poetry.lock", "pdm.lock"]) add(path.join(path.dirname(file), lock)); + } + } + } + return [...files]; +} + +export function receiptPathIsInput(spec: Spec, cwd: string, file: string): boolean { + const rel = path.relative(cwd, file).split(path.sep).join("/"); + return gateFiles(spec, cwd).includes(rel) || spec.gates.some(gate => "glob" in gate && matchesGlob(rel, gate.glob, [...SCAN_IGNORE, ...(gate.ignore ?? [])])); +} const EVALUATOR_VERSION = JSON.parse( fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), @@ -44,7 +83,20 @@ function filesForSnapshot(cwd: string): string[] { export function snapshotKey(spec: Spec, cwd: string, baseRef?: string, ignore: string[] = []): string { const hash = crypto.createHash("sha256"); - hash.update("skillgate-snapshot-v1\0"); + const deadline = Date.now() + (spec.timeout ?? 300_000); + const hashFile = (file: string) => { + const fd = fs.openSync(file, "r"); + const buffer = Buffer.allocUnsafe(64 * 1024); + try { + while (true) { + if (Date.now() >= deadline) throw new Error("snapshot exceeded its time budget"); + const length = fs.readSync(fd, buffer, 0, buffer.length, null); + if (!length) break; + hash.update(buffer.subarray(0, length)); + } + } finally { fs.closeSync(fd); } + }; + hash.update("skillgate-snapshot-v2\0"); hash.update(EVALUATOR_VERSION); hash.update("\0"); hash.update(JSON.stringify(spec)); @@ -54,14 +106,29 @@ export function snapshotKey(spec: Spec, cwd: string, baseRef?: string, ignore: s hash.update(process.version); hash.update("\0"); if (baseRef) hash.update(git(cwd, ["rev-parse", `${baseRef}^{commit}`]) ?? baseRef); - for (const rel of filesForSnapshot(cwd).filter((file) => !ignore.includes(file))) { + hash.update("\0" + (currentBranch(cwd) ?? "")); + hash.update("\0" + (git(cwd, ["rev-parse", "HEAD"]) ?? "")); + const index = git(cwd, ["ls-files", "--stage", "-z"]); + hash.update("\0" + (index == null ? "" : index.split("\0") + .filter(entry => entry && !ignore.includes(entry.slice(entry.indexOf("\t") + 1))).join("\0"))); + const files = [...new Set([...filesForSnapshot(cwd), ...gateFiles(spec, cwd)])].sort(); + for (const rel of files.filter((file) => !ignore.includes(file))) { hash.update("\0" + rel + "\0"); const full = path.join(cwd, rel); try { const stat = fs.lstatSync(full); - hash.update(stat.isSymbolicLink() ? `link:${fs.readlinkSync(full)}` : fs.readFileSync(full)); + if (stat.isSymbolicLink()) { + hash.update(`link:${fs.readlinkSync(full)}`); + const target = fs.statSync(full); + if (!target.isFile()) throw new Error(`snapshot cannot bind a symlink to a directory: ${rel}`); + hashFile(full); + } else if (stat.isDirectory()) { + hash.update("dir:" + JSON.stringify(fs.readdirSync(full).sort())); + } else if (stat.isFile()) hashFile(full); + else throw new Error(`snapshot cannot bind a non-regular path: ${rel}`); hash.update(stat.mode.toString(8)); - } catch { + } catch (error: any) { + if (error?.code !== "ENOENT") throw error; hash.update(""); } } @@ -75,18 +142,31 @@ function cacheFile(cwd: string): string { return path.join(os.tmpdir(), "skillgate-cache", id, "check.json"); } -function parseReceipt(file: string): StoredReceipt | null { +function parseReceipt(file: string, spec?: Spec): StoredReceipt | null { try { + if (fs.statSync(file).size > 10 * 1024 * 1024) return null; const value = JSON.parse(fs.readFileSync(file, "utf8")); - if (value?.format !== 1 || typeof value.snapshot !== "string" || typeof value.result?.passed !== "boolean") return null; + const result = value?.result; + if (value?.format !== 1 || !/^[a-f0-9]{64}$/.test(value.snapshot) || value.source !== "executed" + || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt)) + || result?.passed !== true || !Array.isArray(result.results) || !result.results.length + || !Array.isArray(result.failed) || result.failed.length) return null; + const ids = new Set(); + for (const gate of result.results) { + if (!gate || typeof gate.id !== "string" || ids.has(gate.id) || typeof gate.type !== "string" + || gate.ok !== true || typeof gate.reason !== "string" || ![undefined, "pass", "skipped"].includes(gate.status)) return null; + ids.add(gate.id); + } + if (spec && (result.results.length !== spec.gates.length || spec.gates.some((gate, i) => + result.results[i].id !== gate.id || result.results[i].type !== gate.type))) return null; return value as StoredReceipt; } catch { return null; } } -export function readCachedResult(cwd: string, snapshot: string): RunResult | null { - const receipt = parseReceipt(cacheFile(cwd)); +export function readCachedResult(cwd: string, snapshot: string, spec?: Spec): RunResult | null { + const receipt = parseReceipt(cacheFile(cwd), spec); return receipt?.snapshot === snapshot && receipt.result.passed ? receipt.result : null; } diff --git a/test/enforcement-release.test.ts b/test/enforcement-release.test.ts new file mode 100644 index 0000000..fb9b742 --- /dev/null +++ b/test/enforcement-release.test.ts @@ -0,0 +1,263 @@ +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, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; +import { globSync } from "tinyglobby"; +import { runGates } from "../src/core.js"; +import { resolveBaseRef, matchesGlob, mergeBase } from "../src/git.js"; +import { installIntegration, doctor, GATE_TIMEOUT_MS } from "../src/integrations.js"; +import { snapshotKey, readCachedResult, writeCachedResult } from "../src/receipt.js"; +import { runShellCommand } from "../src/process.js"; +import { withInstallLock, writeTextAtomic } from "../src/files.js"; +import type { Spec } from "../src/spec.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +function project(t: any, files: Record = {}, withGit = false): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-enforcement-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + write(dir, files); + if (withGit) { + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["add", "."]); + git(dir, ["commit", "-qm", "fixture", "--allow-empty"]); + } + return dir; +} +function write(dir: string, files: Record): void { + for (const [file, value] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), value); + } +} +function git(dir: string, args: string[]): string { + return execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", ...args], { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim(); +} +function sg(dir: string, args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [CLI, ...args], { cwd: dir, encoding: "utf8", env: { ...process.env, ...env } }); +} + +test("explicit missing baselines never fall back, and unreadable trees never pass empty diff gates", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ gates: [{ id: "scan", type: "no-new", glob: "*.ts", pattern: "skip", allowEmpty: true }] }) }, true); + assert.equal(resolveBaseRef(dir, "does-not-exist"), null); + assert.equal(sg(dir, ["check", "--base", "does-not-exist"]).status, 2); + assert.equal(sg(dir, ["check", "--pin", "--base", "does-not-exist"]).status, 2); + assert.equal(sg(dir, ["check"], { SKILLGATE_BASE: "does-not-exist" }).status, 2); + for (const type of ["no-new", "no-fewer", "no-deleted"] as const) { + const gate = { id: "scan", type, glob: "*.ts", pattern: "skip", allowEmpty: true }; + assert.equal(runGates({ gates: [gate] }, dir, { baseRef: "does-not-exist" }).passed, false); + } + git(dir, ["checkout", "--orphan", "unrelated"]); + git(dir, ["commit", "-qm", "unrelated root", "--allow-empty"]); + assert.throws(() => mergeBase(dir, "main"), /common ancestor/); +}); + +test("historical glob matching agrees with disk matching for classes, extglobs, braces, and root files", t => { + const dir = project(t, { "src/a1.ts": "x", "src/b2.js": "x", "src/deep/c3.ts": "x", "root.ts": "x", ".hidden.ts": "x" }, true); + const files = ["src/a1.ts", "src/b2.js", "src/deep/c3.ts", "root.ts", ".hidden.ts"]; + for (const pattern of ["src/[ab][0-9].{ts,js}", "src/**/+(a1|c3).ts", "**/*.ts", "src/**/!(*.js)"]) { + assert.deepEqual(files.filter(file => matchesGlob(file, pattern)).sort(), globSync(pattern, { cwd: dir, dot: true }).sort()); + } + fs.unlinkSync(path.join(dir, "src/a1.ts")); + const result = runGates({ gates: [{ id: "keep", type: "no-deleted", glob: "src/[ab][0-9].{ts,js}" }] }, dir, { baseRef: "main" }); + assert.equal(result.passed, false); + assert.equal(result.failed[0].location?.file, "src/a1.ts"); +}); + +test("nested policies scope historical reads and preserve Unix newline-containing names", t => { + const unusual = process.platform === "win32" ? "pkg/tests/spaced name.ts" : "pkg/tests/space \n name.ts"; + const dir = project(t, { "pkg/tests/a.ts": "test('one')\ntest('two')\n", [unusual]: "test('three')\n", "tests/a.ts": "unrelated\n" }, true); + write(dir, { "pkg/tests/a.ts": "test('one')\n" }); + const spec: Spec = { gates: [{ id: "keep", type: "no-fewer", glob: "tests/**", pattern: "^test" }] }; + const result = runGates(spec, path.join(dir, "pkg"), { baseRef: "main" }); + assert.equal(result.passed, false); + assert.match(result.failed[0].reason, /base 3, now 2/); +}); + +test("evidence and file-contains reject directories even when the pattern matches empty text", t => { + const dir = project(t); + fs.mkdirSync(path.join(dir, "report.md")); + const result = runGates({ gates: [ + { id: "report", type: "evidence", file: "report.md" }, + { id: "contents", type: "file-contains", file: "report.md", pattern: "^$" }, + ] }, dir); + assert.equal(result.failed.length, 2); + assert.ok(result.failed.every(gate => /regular file/.test(gate.reason))); +}); + +test("phase requirements share one deadline and completed requirements remain memoized", t => { + const dir = project(t); + const slow = `"${process.execPath}" -e "setTimeout(() => {}, 150)"`; + const spec: Spec = { timeout: 260, gates: [ + { id: "phases", type: "phase", phases: [{ id: "done", requires: ["one", "two"] }] }, + { id: "one", type: "command", run: slow, timeout: 1000 }, + { id: "two", type: "command", run: slow, timeout: 1000 }, + { id: "after", type: "file-exists", file: "missing" }, + ] }; + const result = runGates(spec, dir); + assert.equal(result.passed, false); + assert.equal(result.results[0].ok, false); + assert.equal(result.results[3].status, "not-run"); + assert.ok(result.durationMs! < 1200); +}); + +test("command supervision caps output before losing its process tree", t => { + const dir = project(t); + const result = runShellCommand(`"${process.execPath}" -e "process.stdout.write('x'.repeat(9 * 1024 * 1024))"`, dir, 5000); + assert.equal(result.outputLimited, true); + assert.ok(result.stdout.length <= 8 * 1024 * 1024); +}); + +test("completed Unix shells cannot leave background descendants writing after a pass", { skip: process.platform === "win32" }, t => { + const dir = project(t, { "worker.cjs": "setTimeout(() => require('fs').writeFileSync('late.txt', 'late'), 350)" }); + const result = runShellCommand(`"${process.execPath}" worker.cjs &`, dir, 2000); + assert.equal(result.status, 0); + execFileSync(process.execPath, ["-e", "setTimeout(() => {}, 500)"]); + assert.equal(fs.existsSync(path.join(dir, "late.txt")), false); +}); + +test("pre-commit installation upgrades deletion-only coverage and preserves unrelated hooks", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ gates: [{ id: "docs", type: "file-exists", file: "README.md" }] }), + ".pre-commit-config.yaml": "repos:\n - repo: local\n hooks:\n - id: other\n entry: echo other\n - id: skillgate\n name: pinned project policy\n entry: npx '@reneza/skillgate@0.9.0' check --pin --base main\n stages: [pre-push]\n custom: preserved\n" }); + assert.equal(doctor(dir, ["pre-commit"]).at(-1)?.ok, false); + assert.equal(installIntegration("pre-commit", dir).changed, true); + const data: any = parseYaml(fs.readFileSync(path.join(dir, ".pre-commit-config.yaml"), "utf8")); + const hooks = data.repos[0].hooks; + assert.equal(hooks.length, 2); + assert.equal(hooks[0].entry, "echo other"); + assert.equal(hooks[1].always_run, true); + assert.equal(hooks[1].pass_filenames, false); + assert.equal(hooks[1].custom, "preserved"); + assert.equal(hooks[1].name, "pinned project policy"); + assert.match(hooks[1].entry, /npx '@reneza\/skillgate@0\.13\.0' check --pin --base main$/); + assert.deepEqual(hooks[1].stages, ["pre-push", "pre-commit"]); + assert.equal(doctor(dir, ["pre-commit"]).at(-1)?.ok, true); + assert.equal(installIntegration("pre-commit", dir).changed, false); +}); + +test("Claude Stop output blocks on stdout JSON, including errors and missing-file diagnostics", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ gates: [{ id: "tests", type: "command", run: `"${process.execPath}" -e "console.error('No such file'); process.exit(1)"` }] }) }); + const args = ["gate", "--event", "stop", "--format", "claude-stop"]; + const blocked = sg(dir, args); + assert.equal(blocked.status, 0); + assert.equal(JSON.parse(blocked.stdout).decision, "block"); + assert.match(JSON.parse(blocked.stdout).reason, /No such file/); + write(dir, { ".skillgate.json": "{}" }); + assert.equal(JSON.parse(sg(dir, args).stdout).decision, "block"); + assert.equal(JSON.parse(sg(dir, [...args, "--pin", "--base", "missing-ref"]).stdout).decision, "block"); + write(dir, { ".skillgate.json": JSON.stringify({ gates: [{ id: "docs", type: "file-exists", file: ".skillgate.json" }] }) }); + assert.deepEqual(JSON.parse(sg(dir, args).stdout), {}); + installIntegration("claude-code", dir, { stop: true }); + const config = JSON.parse(fs.readFileSync(path.join(dir, ".claude/settings.json"), "utf8")); + assert.match(config.hooks.Stop[0].hooks[0].command, /--format claude-stop/); + assert.match(config.hooks.PreToolUse[0].hooks[0].command, new RegExp(`--timeout ${GATE_TIMEOUT_MS}`)); +}); + +test("the installed Claude Stop fallback blocks if npx cannot launch the gate", { skip: process.platform === "win32" }, t => { + const dir = project(t, { "bin/npx": "#!/bin/sh\necho 'No such file' >&2\nexit 127\n" }); + fs.chmodSync(path.join(dir, "bin/npx"), 0o755); + installIntegration("claude-code", dir, { stop: true }); + const data = JSON.parse(fs.readFileSync(path.join(dir, ".claude/settings.json"), "utf8")); + const result = spawnSync("sh", ["-c", data.hooks.Stop[0].hooks[0].command], { cwd: dir, encoding: "utf8", env: { ...process.env, PATH: [path.join(dir, "bin"), path.dirname(process.execPath), process.env.PATH].join(path.delimiter) } }); + assert.equal(result.status, 0); + assert.equal(JSON.parse(result.stdout).decision, "block"); +}); + +test("gate timeout stays inside the host hook timeout even with a longer policy", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ timeout: 900000, finishLine: ["git commit"], gates: [{ id: "hang", type: "command", timeout: 800000, run: `"${process.execPath}" -e "setTimeout(() => {}, 5000)"` }] }) }); + const result = sg(dir, ["gate", "--command", "git commit", "--timeout", "100", "--json"]); + assert.equal(result.status, 2); + assert.match(JSON.parse(result.stdout).result.failed[0].reason, /timed out/); +}); + +test("command and scanner cache requests always re-evaluate external state", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ gates: [{ id: "env", type: "command", run: `"${process.execPath}" -e "process.exit(process.env.SKILLGATE_FIXTURE_OK === 'yes' ? 0 : 1)"` }] }) }); + assert.equal(sg(dir, ["check", "--cache", "--json"], { SKILLGATE_FIXTURE_OK: "yes" }).status, 0); + const result = sg(dir, ["check", "--cache", "--json"], { SKILLGATE_FIXTURE_OK: "no" }); + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stdout).cacheHit, false); + assert.match(JSON.parse(result.stdout).cacheDisabledReason, /command/); +}); + +test("ignored files, branch changes, and symlink target changes invalidate cached passes", { skip: process.platform === "win32" }, t => { + const dir = project(t, { ".gitignore": "ignored.txt\n", "ignored.txt": "approved", "target.txt": "approved" }, true); + fs.symlinkSync("target.txt", path.join(dir, "link.txt")); + const spec: Spec = { gates: [{ id: "input", type: "file-contains", file: "ignored.txt", pattern: "approved" }] }; + const first = snapshotKey(spec, dir); + write(dir, { "ignored.txt": "changed" }); + assert.notEqual(snapshotKey(spec, dir), first); + const linked: Spec = { gates: [{ id: "link", type: "file-contains", file: "link.txt", pattern: "approved" }] }; + const linkKey = snapshotKey(linked, dir); + write(dir, { "target.txt": "changed" }); + assert.notEqual(snapshotKey(linked, dir), linkKey); + const branchKey = snapshotKey(spec, dir); + git(dir, ["checkout", "-qb", "release/test"]); + assert.notEqual(snapshotKey(spec, dir), branchKey); +}); + +test("malformed or incomplete cache receipts fall back to real checks", t => { + const dir = project(t, { "README.md": "ok" }, true); + const spec: Spec = { gates: [{ id: "docs", type: "file-exists", file: "README.md" }] }; + const snapshot = snapshotKey(spec, dir); + writeCachedResult(dir, snapshot, runGates(spec, dir)); + const file = git(dir, ["rev-parse", "--path-format=absolute", "--git-path", "skillgate-cache/check.json"]); + const receipt = JSON.parse(fs.readFileSync(file, "utf8")); + for (const invalid of [{ passed: true }, { passed: true, results: [], failed: [] }, { passed: true, results: [{ id: "wrong", type: "file-exists", ok: true, reason: "claimed" }], failed: [] }]) { + fs.writeFileSync(file, JSON.stringify({ ...receipt, result: invalid })); + assert.equal(readCachedResult(dir, snapshot, spec), null); + } +}); + +test("excluded review reports do not change their snapshot when staged", t => { + const dir = project(t, { "README.md": "ok", "review.json": "draft" }, true); + const spec: Spec = { gates: [{ id: "docs", type: "file-exists", file: "README.md" }] }; + const snapshot = snapshotKey(spec, dir, undefined, ["review.json"]); + write(dir, { "review.json": "approved" }); + git(dir, ["add", "review.json"]); + assert.equal(snapshotKey(spec, dir, undefined, ["review.json"]), snapshot); + git(dir, ["update-index", "--chmod=+x", "README.md"]); + assert.notEqual(snapshotKey(spec, dir, undefined, ["review.json"]), snapshot); +}); + +test("receipts cannot overwrite inputs or attest a workspace changed during evaluation", t => { + const dir = project(t, { "README.md": "approved", ".skillgate.json": JSON.stringify({ gates: [ + { id: "docs", type: "file-contains", file: "README.md", pattern: "approved" }, + { id: "mutate", type: "command", run: `"${process.execPath}" -e "require('fs').writeFileSync('README.md','changed')"` }, + ] }) }); + assert.equal(sg(dir, ["check", "--receipt", "README.md"]).status, 2); + assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "approved"); + const result = sg(dir, ["check", "--receipt", "result.json", "--json"]); + assert.equal(result.status, 1); + assert.match(JSON.parse(result.stdout).failed.at(-1).reason, /workspace changed/); + assert.equal(JSON.parse(fs.readFileSync(path.join(dir, "result.json"), "utf8")).result.passed, false); +}); + +test("an active installer cannot overwrite another installation and failures release the lock", t => { + const dir = project(t, { ".skillgate.json": JSON.stringify({ gates: [{ id: "docs", type: "file-exists", file: "README.md" }] }) }); + withInstallLock(dir, () => { + assert.throws(() => installIntegration("claude-code", dir), /another Skillgate installation/); + assert.equal(fs.existsSync(path.join(dir, ".claude/settings.json")), false); + }); + assert.throws(() => withInstallLock(dir, () => { throw new Error("interrupted"); }), /interrupted/); + assert.equal(installIntegration("claude-code", dir).changed, true); + assert.equal(installIntegration("claude-code", dir).changed, false); +}); + +test("atomic configuration writes preserve existing permissions and refuse symlink replacement", { skip: process.platform === "win32" }, t => { + const dir = project(t, { "config.json": "old", "target.json": "private" }); + const config = path.join(dir, "config.json"); + fs.chmodSync(config, 0o640); + const originalUmask = process.umask(0o077); + try { writeTextAtomic(config, "new"); } + finally { process.umask(originalUmask); } + assert.equal(fs.readFileSync(config, "utf8"), "new"); + assert.equal(fs.statSync(config).mode & 0o777, 0o640); + const link = path.join(dir, "link.json"); + fs.symlinkSync("target.json", link); + assert.throws(() => writeTextAtomic(link, "replacement"), /non-regular/); + assert.equal(fs.readFileSync(path.join(dir, "target.json"), "utf8"), "private"); + assert.equal(fs.readdirSync(dir).some(file => file.endsWith(".tmp")), false); +}); diff --git a/test/hooks.test.ts b/test/hooks.test.ts index 4e7c5b4..2b145a2 100644 --- a/test/hooks.test.ts +++ b/test/hooks.test.ts @@ -137,7 +137,7 @@ test("agent hooks are installed fail-closed with a timeout, for every agent", (t } const claude = JSON.parse(fs.readFileSync(path.join(dir, ".claude", "settings.json"), "utf8")); assert.equal(claude.hooks.PreToolUse[0].hooks[0].command, gateCommand()); - assert.match(gateCommand(), /gate \|\| exit 2$/); + assert.match(gateCommand(), /gate --timeout 540000 \|\| exit 2$/); assert.equal(claude.hooks.PreToolUse[0].hooks[0].timeout, 600); assert.equal(claude.hooks.Stop, undefined); @@ -177,7 +177,8 @@ test("install claude-code --stop adds the Stop hook; an old fail-open hook is up assert.equal(data.hooks.PreToolUse.length, 2); assert.equal(data.hooks.PreToolUse[0].hooks[0].command, "other-tool"); assert.equal(data.hooks.PreToolUse[1].hooks[0].command, gateCommand()); - assert.match(data.hooks.Stop[0].hooks[0].command, /gate --event stop \|\| exit 2$/); + assert.match(data.hooks.Stop[0].hooks[0].command, /gate --timeout 540000 --event stop --format claude-stop/); + assert.match(data.hooks.Stop[0].hooks[0].command, /decision:'block'/); assert.ok(doctor(dir, ["claude-code"]).every((c) => c.ok)); assert.equal(sg(["install", "cursor", "--stop"], dir).status, 2);