diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a05b5..5d60afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +## 0.12.0 - 2026-10-01 + +### Added +- `init --preset no-secrets` and optional `trufflehog` gate with verified credential + detection, a private literal infrastructure-name denylist, staged-file checks, + bounded execution, and redacted output. TruffleHog remains an external optional binary. +- Optional `review` report gate and `review-snapshot` command for OCR-managed or + delegated reviews. Reports must complete without findings or warnings and match + the working/staged snapshot. This is explicitly a trusted reviewer attestation. +- Processbench-style real-PR regression evidence and external-gate setup documentation. + +### Fixed +- Invalid opencode policies now block unknown/MCP publish tools while keeping + built-in file-repair tools available. +- External gates always re-evaluate with `--cache`; phase evaluation carries the + complete policy when checking a review report. + ### Documentation - Added a Worktrunk pre-merge hook recipe using the existing worktree-local policy discovery, with an explicit merge target for diff-aware gates. diff --git a/README.md b/README.md index 21deed9..e56ed64 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,11 @@ Prompt-level fixes ("always follow the process") do not close the gap, because t ![How skillgate works: an AI agent tries to commit, skillgate runs deterministic gates outside the model, and blocks the finish line until every gate passes](assets/skillgate-flow.png) -The check is a **pure function over the filesystem**: same inputs, same verdict, in milliseconds, with no model in the loop. That is the whole point. An LLM asked "is this done?" answers differently depending on the weather and has an incentive to say yes. A script does not. Because the judge is model-independent, it works the same whatever model you have plugged into your agent. +The default filesystem checks produce the same verdict for the same inputs, with +no model in the evaluator. Commands and optional credential verification may +depend on external state. The optional review gate checks a report supplied by a +trusted reviewer; it is an attestation with a different trust boundary. The gate +engine remains independent of the model plugged into your agent. ## Gate, not loop @@ -174,6 +178,11 @@ open. Re-running `install` upgrades it in place. ## Define your gates +Use `skillgate init --preset no-secrets` for credential and private infrastructure +checks. An optional `review` gate accepts a snapshot-bound OCR or host-agent +report. See [secrets and optional review](docs/external-gates.md) for setup, limits, +and reviewer trust. Both integrations are optional. + A gate is one deterministic, machine-checkable condition. Run `npx @reneza/skillgate init` to drop a starter `.skillgate/done.yaml` that includes drift detection and an evidence-gate example right out of the box, then run `skillgate scaffold` to generate the evidence file templates the agent must fill in: ```bash diff --git a/docs/external-gates.md b/docs/external-gates.md new file mode 100644 index 0000000..b093554 --- /dev/null +++ b/docs/external-gates.md @@ -0,0 +1,115 @@ +# Secrets and Optional Code Review + +## No-secrets preset + +Install [TruffleHog](https://github.com/trufflesecurity/trufflehog), then initialize: + +```sh +skillgate init --preset no-secrets +skillgate check +``` + +The preset refuses to overwrite an existing policy. For an existing policy, add: + +```yaml +- id: no-secrets + type: trufflehog + timeout: 10000 + namesFile: .skillgate/forbidden-names.json +``` + +Set the local denylist to a non-empty JSON array of literal server, domain, or +tenant names. Initialization starts with `[]` and blocks until you configure it. +Remove `namesFile` for credential scanning alone. The preset ignores the private +file and scanner clone directory. Provide the denylist separately to trusted CI. +An existing policy needs these ignore entries added explicitly: + +```gitignore +/.skillgate/forbidden-names.json +/.skillgate/scan-cache/ +``` + +The name check uses case-insensitive literal matching over tracked and unignored +untracked files and the staged index. Missing, malformed, or empty configuration, +symlinks, unreadable files, and size/time limits block. Matched names are redacted. + +The credential check runs `trufflehog git file:// --results=verified --fail +--fail-on-scan-errors --no-update --json` with temporary clones under the ignored +scan directory. The executable is optional and never downloaded by Skillgate. +Set `trufflehog` to override its path. A missing scanner, nonzero exit, malformed +output, or timeout blocks. Raw output never enters receipts. Policies containing +this gate always re-evaluate, including with `--cache`. + +Verified-only detection requires network access to credential providers. It does +not guarantee detection of fake, expired, unsupported, or unverifiable credentials. +Keep static `absent` gates where those are also forbidden. Verification responses +and scan times can change with identical files: this optional gate is not a pure +offline filesystem predicate. + +TruffleHog v3.97.9 took 3.7 seconds on a small TypeScript repository. The vendor's +public AWS/URI canaries triggered exit 183 in history and when staged before +commit. Published evidence contains no credential values. Measure your repository: +ten seconds is a budget, not a universal performance guarantee. + +## Optional OCR Review Adapter + +A review gate consumes a trusted reviewer's report. It does not run a model or +add [Open Code Review](https://github.com/alibaba/open-code-review) as a dependency: + +```yaml +- id: review + type: review + file: .skillgate/review.json +``` + +Ignore `.skillgate/review*` before taking a snapshot. Capture `skillgate +review-snapshot` **before** reviewing and retain that value in the report. The hash +covers policy, working files, and staged blob IDs. Changes require another review. +Use the same `--base`/`--pin` options for snapshot and evaluation. `--cache` never +skips this gate. + +OCR v1.12.11 has two paths: + +```sh +# OCR-managed model; a custom/local endpoint is also possible. +ocr review --format json --output .skillgate/review.raw.json + +# Host-agent review; no separate OCR API key or model configuration. +ocr delegate preview --format json +ocr delegate rule src/core.ts src/plugin.ts --format json +``` + +Delegation prepares file selection and rules. The host must inspect the actual +diff and complete the review. `delegate preview` alone is not a completed review. + +Wrap OCR JSON, or a delegated review using the same status/summary/comments shape, +in this envelope. Normalize absent `warnings` to an empty array: + +```json +{ + "format": 1, + "snapshot": "", + "review": { + "status": "success", + "summary": { "files_reviewed": 2, "comments": 0 }, + "comments": [], + "warnings": [] + } +} +``` + +Missing, stale, incomplete, skipped, budget-exceeded, or malformed reports block. +Any comment or warning blocks. Resolve findings and rerun review. Comment content +is never printed by this gate. + +This is a **reviewer attestation**, not independent proof that review ran or that +code is correct. An agent with report-write access can forge it. Put policy and +report production behind a trusted boundary for independent enforcement. + +The no-key thin slice used [Skillgate PR #39](https://github.com/renezander030/skillgate/pull/39). +OCR selected six source/schema files and excluded documentation and tests. The +host review identified an invalid-policy path allowing non-shell publish tools +through the opencode plugin. A regression test reproduced it; the fix blocks +unknown/MCP tools on invalid policy while allowing built-in repair tools. This is +one host-reviewed case, not a model-quality comparison. Review documentation and +tests separately when OCR excludes them. diff --git a/docs/spec-reference.md b/docs/spec-reference.md index 6dddee2..a3527d1 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -30,6 +30,11 @@ blocks agent hooks rather than being ignored. ## Gate types +`trufflehog` adds optional verified credential scanning and a private literal-name +denylist. `review` consumes a completed report bound to the current working/staged +snapshot. Their fields, setup, and trust limits are documented in +[secrets and optional review](external-gates.md). + Every gate has an `id` (string, shown in output), an optional `description`, and an optional [`when`](#conditional-gates-when) block. diff --git a/examples/review-regressions.jsonl b/examples/review-regressions.jsonl new file mode 100644 index 0000000..73e3533 --- /dev/null +++ b/examples/review-regressions.jsonl @@ -0,0 +1 @@ +{"id":"review-invalid-policy-publish","workflow":"agent-coding-approval-boundary","input":"Inspect Skillgate PR 39 at cbbd8e99fe19236297b78c052a9c685b54900d93. A malformed policy must not permit a non-shell publish tool through the opencode plugin.","expected":{"change_type":"invalid-policy-fail-closed","publish_blocked":true,"repair_tools_available":true},"hard_blockers":["publish_allowed_on_invalid_policy"],"observed":{"existing_ci":"pass","host_delegated_review":"finding","new_regression_test":"pass_after_fix"},"source":"https://github.com/renezander030/skillgate/pull/39","limitations":"One host-agent review using OCR file selection/rules. No OCR-managed model-quality comparison or recall/precision claim."} diff --git a/package-lock.json b/package-lock.json index 0a0825a..bf4c7bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@reneza/skillgate", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reneza/skillgate", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", diff --git a/package.json b/package.json index 62a7641..b4ad59b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/skillgate", - "version": "0.11.0", + "version": "0.12.0", "publishConfig": { "access": "public" }, diff --git a/schema/done.schema.json b/schema/done.schema.json index f762c72..ba0e25b 100644 --- a/schema/done.schema.json +++ b/schema/done.schema.json @@ -60,6 +60,33 @@ }, "gate": { "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "trufflehog" }, + "trufflehog": { "type": "string", "minLength": 1 }, + "namesFile": { "type": "string", "minLength": 1 }, + "timeout": { "type": "integer", "minimum": 1 }, + "maxBytes": { "type": "integer", "minimum": 1 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "file"], + "properties": { + "id": { "type": "string" }, + "description": { "type": "string" }, + "when": { "$ref": "#/definitions/when" }, + "type": { "const": "review" }, + "file": { "type": "string", "minLength": 1 } + } + }, { "type": "object", "additionalProperties": false, diff --git a/src/cli.ts b/src/cli.ts index aa2befc..4e3f43d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { runSync } from "./link.js"; 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 { resolveBaseRef, mergeBase, readFileAtRef, repoRelativePath, repoRoot, isClean } from "./git.js"; import { @@ -93,7 +94,8 @@ Usage: skillgate gate allow/block one command (any harness); exit 2 = block skillgate gate --event stop block an agent from ending its turn while gates fail skillgate phase [] show phase status, or move to if its gates pass - skillgate init write an example .skillgate/done.yaml + skillgate init [--preset no-secrets] write an example .skillgate/done.yaml + skillgate review-snapshot print the snapshot to bind an optional review report to skillgate install install agent hooks (claude-code, codex, gemini-cli, cursor, opencode), github-actions, or pre-commit enforcement skillgate doctor verify policy discovery and one or more integrations @@ -432,6 +434,8 @@ if (cmd === "zk-verify") { } if (cmd === "init") { + const preset = option("--preset"); + if (args.includes("--preset") && preset !== "no-secrets") die(2, "unknown preset; available: no-secrets"); const dir = path.join(cwd, ".skillgate"); fs.mkdirSync(dir, { recursive: true }); const target = path.join(dir, "done.yaml"); @@ -439,11 +443,39 @@ if (cmd === "init") { console.error(`${path.relative(cwd, target)} already exists`); process.exit(1); } - fs.writeFileSync(target, EXAMPLE); + const secretsSpec = `name: no-secrets +finishLine: ["git commit", "git push", "npm publish"] +gates: + - id: no-secrets + type: trufflehog + timeout: 10000 + namesFile: .skillgate/forbidden-names.json +`; + if (preset) { + const names = path.join(dir, "forbidden-names.json"); + if (!fs.existsSync(names)) fs.writeFileSync(names, "[]\n", { mode: 0o600 }); + const ignorePath = path.join(cwd, ".gitignore"); + const ignore = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : ""; + const entries = ["/.skillgate/forbidden-names.json", "/.skillgate/scan-cache/"]; + const missing = entries.filter(entry => !ignore.split(/\r?\n/).includes(entry)); + if (missing.length) fs.appendFileSync(ignorePath, (ignore && !ignore.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n"); + } + fs.writeFileSync(target, preset ? secretsSpec : EXAMPLE); console.log(`wrote ${path.relative(cwd, target)} — edit it, then \`skillgate check\``); + if (preset) console.log("Set local forbidden-names.json to a non-empty JSON array of server/domain/tenant names, or remove namesFile for credentials only."); process.exit(0); } +if (cmd === "review-snapshot") { + try { + const resolved = resolveSpecAndBase(findSpecPath(cwd)); + console.log(reviewSnapshot(resolved.spec, resolved.workspace, resolved.gateBase)); + process.exit(0); + } catch { + die(2, "cannot read policy or repository snapshot for review"); + } +} + function integrationTargets(value: string | undefined): IntegrationTarget[] { if (!value) die(2, `integration target required (${INTEGRATION_TARGETS.join(", ")}, or all)`); if (value === "all") return [...INTEGRATION_TARGETS]; @@ -574,7 +606,8 @@ 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"); - const cache = args.includes("--cache"); + // 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)); const receipt = option("--receipt"); const receiptFile = receipt ? path.resolve(cwd, receipt) : undefined; const receiptRel = receiptFile ? path.relative(r.workspace, receiptFile).split(path.sep).join("/") : ""; @@ -752,7 +785,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, gates: new Map(r.spec.gates.map((g) => [g.id, g])), memo: new Map() }; + const opts = { baseRef: r.gateBase, spec: r.spec, 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); diff --git a/src/core.ts b/src/core.ts index 4dfbeee..c8071f6 100644 --- a/src/core.ts +++ b/src/core.ts @@ -19,6 +19,8 @@ import { readFileAtRef, listFilesAtRef, matchesGlob, changedFiles, currentBranch import { checkManifest, SUPPORTED_MANIFESTS } from "./deps.js"; import { isStructuredCommandMatch } from "./command.js"; import { runShellCommand } from "./process.js"; +import { checkSecrets } from "./secrets.js"; +import { checkReview } from "./review.js"; export interface GateResult { id: string; @@ -57,6 +59,7 @@ export interface RunOptions { gates?: Map; /** Internal: results already computed in this run, so a required gate runs once. */ memo?: Map; + spec?: Spec; } export interface PhaseStatus { @@ -300,6 +303,10 @@ function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult { } case "trivy": return checkTrivyGate(gate, cwd, opts.remainingMs); + case "trufflehog": + return { ...base, ...checkSecrets(gate, cwd, opts.remainingMs) }; + case "review": + return { ...base, ...checkReview(gate.file, opts.spec ?? { gates: [gate] }, cwd, opts.baseRef) }; 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 } }; @@ -510,7 +517,7 @@ export function runGates(spec: Spec, cwd: string, opts: RunOptions = {}): RunRes continue; } const gateStarted = Date.now(); - const result = runOne(gate, cwd, { ...opts, remainingMs, gates, memo }); + const result = runOne(gate, cwd, { ...opts, remainingMs, gates, memo, spec }); results.push({ ...result, status: result.ok ? "pass" : "fail", diff --git a/src/plugin.ts b/src/plugin.ts index 992edaa..aff7c3d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -32,8 +32,8 @@ export const SkillGate = async (ctx: any): Promise => { try { spec = loadSpec(specPath); } catch (e: any) { - // Shell commands fail closed. Other tools stay usable so the policy can be repaired. - if (tool !== "bash") return; + // Keep built-in repair tools usable; unknown/MCP tools may publish and must fail closed. + if (["read", "edit", "write", "apply_patch", "glob", "grep"].includes(tool)) return; throw new Error(`skillgate blocked tool execution: configured policy is invalid (${e.message})`); } // Shell commands cross the finish line by `finishLine`; any other tool by `gatedTools`. diff --git a/src/review.ts b/src/review.ts new file mode 100644 index 0000000..3e89b27 --- /dev/null +++ b/src/review.ts @@ -0,0 +1,34 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import type { Spec } from "./spec.js"; +import { snapshotKey } from "./receipt.js"; + +export function reviewSnapshot(spec: Spec, cwd: string, baseRef?: string): string { + const reports = spec.gates.filter(g => g.type === "review").map(g => path.relative(cwd, path.resolve(cwd, g.file)).split(path.sep).join("/")); + const index = execFileSync("git", ["ls-files", "--stage", "-z"], { cwd, maxBuffer: 64 * 1024 * 1024 }).toString(); + const entries = index.split("\0").filter(entry => entry && !reports.includes(entry.split("\t")[1])); + return crypto.createHash("sha256").update(snapshotKey(spec, cwd, baseRef, reports)).update(entries.join("\0")).digest("hex"); +} + +export function checkReview(file: string, spec: Spec, cwd: string, baseRef?: string): { ok: boolean; reason: string } { + try { + const full = path.resolve(cwd, file); + if (fs.statSync(full).size > 10 * 1024 * 1024) return { ok: false, reason: "review report exceeds size limit" }; + const envelope = JSON.parse(fs.readFileSync(full, "utf8")); + if (envelope.format !== 1 || envelope.snapshot !== reviewSnapshot(spec, cwd, baseRef)) { + return { ok: false, reason: "review report is stale or has an unsupported format" }; + } + const review = envelope.review; + if (review?.status !== "success" || !Array.isArray(review.comments) || !Array.isArray(review.warnings) + || review.summary?.budget_exceeded || !Number.isInteger(review.summary?.files_reviewed) + || review.summary.files_reviewed < 1 || review.summary.comments !== review.comments.length) { + return { ok: false, reason: "review report is incomplete or invalid" }; + } + if (review.comments.length || review.warnings.length) return { ok: false, reason: "review has findings or warnings; resolve them and rerun review" }; + return { ok: true, reason: "completed review has no findings for this snapshot (reviewer attestation)" }; + } catch { + return { ok: false, reason: "review report or repository snapshot could not be read" }; + } +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..4f9fb78 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import type { TrufflehogGate } from "./spec.js"; + +export function checkSecrets(gate: TrufflehogGate, cwd: string, remainingMs?: number): { ok: boolean; reason: string } { + const deadline = Date.now() + Math.min(gate.timeout ?? 10000, remainingMs ?? Infinity); + const budget = () => Math.max(1, deadline - Date.now()); + const maxBytes = gate.maxBytes ?? 10 * 1024 * 1024; + const run = (bin: string, args: string[]) => spawnSync(bin, args, { + cwd, encoding: "utf8", timeout: budget(), maxBuffer: maxBytes, + }); + // Scanner output can contain live credentials. Never include it, or parser errors, in a receipt. + try { + if (gate.namesFile) { + const namesPath = path.resolve(cwd, gate.namesFile); + if (fs.statSync(namesPath).size > maxBytes) return { ok: false, reason: "infrastructure denylist exceeds size limit" }; + const names: unknown = JSON.parse(fs.readFileSync(namesPath, "utf8")); + if (!Array.isArray(names) || !names.length || names.some(n => typeof n !== "string" || !n.trim())) { + return { ok: false, reason: "infrastructure denylist must be a non-empty JSON array of literal names" }; + } + const listed = run("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]); + if (listed.error || listed.status !== 0) return { ok: false, reason: "cannot enumerate repository files" }; + for (const file of new Set(listed.stdout.split("\0").filter(Boolean))) { + if (Date.now() >= deadline) return { ok: false, reason: "secrets gate timed out" }; + const full = path.resolve(cwd, file); + if (full === namesPath) continue; + const stat = fs.existsSync(full) ? fs.lstatSync(full) : null; + if (stat?.isSymbolicLink()) return { ok: false, reason: "infrastructure scan refuses symlinks" }; + if (stat && stat.size > maxBytes) return { ok: false, reason: "repository file exceeds infrastructure scan size limit" }; + const staged = run("git", ["show", `:${file}`]); + if (staged.error) return { ok: false, reason: "cannot read staged file within scan limits" }; + const texts = [stat?.isFile() ? fs.readFileSync(full, "utf8") : "", staged.status === 0 ? staged.stdout : ""]; + if (texts.some(text => names.some(name => text.toLowerCase().includes(name.toLowerCase())))) { + return { ok: false, reason: "forbidden infrastructure name found (value redacted)" }; + } + } + } + if (Date.now() >= deadline) return { ok: false, reason: "secrets gate timed out" }; + const clones = path.join(cwd, ".skillgate", "scan-cache"); + fs.mkdirSync(clones, { recursive: true }); + const result = run(gate.trufflehog ?? "trufflehog", ["git", pathToFileURL(path.resolve(cwd)).href, + "--results=verified", "--fail", "--fail-on-scan-errors", "--no-update", "--json", "--clone-path", clones]); + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + return { ok: false, reason: code === "ENOENT" ? "TruffleHog is unavailable; install it or configure trufflehog" : "TruffleHog failed or exceeded scan limits" }; + } + if (result.status === 183) return { ok: false, reason: "verified credential found (scanner output redacted)" }; + if (result.status !== 0) return { ok: false, reason: "TruffleHog scan failed (scanner output redacted)" }; + for (const line of result.stdout.split("\n").filter(Boolean)) { + if (JSON.parse(line).Verified === true) return { ok: false, reason: "verified credential found (scanner output redacted)" }; + } + return { ok: true, reason: "no verified credentials or configured infrastructure names found" }; + } catch { + return { ok: false, reason: "secrets scan or infrastructure denylist could not be read safely" }; + } +} diff --git a/src/spec.ts b/src/spec.ts index 5b4e7ef..27939c9 100644 --- a/src/spec.ts +++ b/src/spec.ts @@ -92,6 +92,21 @@ export interface CommandGate extends BaseGate { timeout?: number; } +/** Optional external credential scanner, plus a local literal-name denylist. */ +export interface TrufflehogGate extends BaseGate { + type: "trufflehog"; + trufflehog?: string; + namesFile?: string; + timeout?: number; + maxBytes?: number; +} + +/** A completed OCR/delegated review bound to the current workspace snapshot. */ +export interface ReviewGate extends BaseGate { + type: "review"; + file: string; +} + /** Trivy must find no leaked secrets and no vulnerabilities at or above severity. */ export interface TrivyGate extends BaseGate { type: "trivy"; @@ -203,6 +218,8 @@ export type Gate = | AbsentGate | CommandGate | TrivyGate + | TrufflehogGate + | ReviewGate | EvidenceGate | InstructionSyncGate | NotEmptyGate @@ -409,6 +426,17 @@ function validateGate(value: unknown, index: number): void { timeout(); break; } + case "trufflehog": + allow("trufflehog", "namesFile", "timeout", "maxBytes"); + optionalNonEmptyString(gate.trufflehog, `${where}.trufflehog`); + optionalNonEmptyString(gate.namesFile, `${where}.namesFile`); + timeout(); + maxBytes(); + break; + case "review": + allow("file"); + nonEmptyString(gate.file, `${where}.file`); + break; case "evidence": allow("file"); nonEmptyString(gate.file, `${where}.file`); diff --git a/test/external-gates.test.ts b/test/external-gates.test.ts new file mode 100644 index 0000000..c1ece53 --- /dev/null +++ b/test/external-gates.test.ts @@ -0,0 +1,103 @@ +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 { runGates } from "../src/core.js"; +import { parseSpec, type Spec } from "../src/spec.js"; +import { reviewSnapshot } from "../src/review.js"; +import { SkillGate } from "../src/plugin.js"; + +function fixture(t: { after: (fn: () => void) => void }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-external-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + execFileSync("git", ["init", "--quiet"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "source.txt"), "safe baseline\n"); + execFileSync("git", ["add", "source.txt"], { cwd: dir }); + return dir; +} + +test("trufflehog fails closed on findings, missing binaries, malformed output, and timeout without exposing credentials", { skip: process.platform === "win32" }, t => { + const dir = fixture(t); + const bin = path.join(dir, "scanner"); + const spec: Spec = { gates: [{ id: "secrets", type: "trufflehog", trufflehog: bin, timeout: 150 }] }; + for (const body of ["echo private-credential-value; exit 183", "echo private-credential-value; exit 0", "sleep 2"]) { + fs.writeFileSync(bin, "#!/bin/sh\n" + body + "\n", { mode: 0o700 }); + const result = runGates(spec, dir); + assert.equal(result.passed, false); + assert.equal(JSON.stringify(result).includes("private-credential-value"), false); + } + fs.rmSync(bin); + assert.equal(runGates(spec, dir).passed, false); + fs.writeFileSync(bin, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + assert.equal(runGates(spec, dir).passed, true); +}); + +test("infrastructure names are blocked in working and staged files and never echoed", { skip: process.platform === "win32" }, t => { + const dir = fixture(t); + const bin = path.join(dir, "scanner"); + fs.writeFileSync(bin, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + fs.writeFileSync(path.join(dir, "names.json"), JSON.stringify(["internal-example.invalid", "TenantExample"])); + const spec: Spec = { gates: [{ id: "secrets", type: "trufflehog", trufflehog: bin, namesFile: "names.json" }] }; + fs.writeFileSync(path.join(dir, "source.txt"), "tenant=TENANTEXAMPLE\n"); + assert.equal(runGates(spec, dir).passed, false); + execFileSync("git", ["add", "source.txt"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "source.txt"), "scrubbed workspace\n"); + const result = runGates(spec, dir); + assert.equal(result.passed, false); + assert.equal(JSON.stringify(result).toLowerCase().includes("tenantexample"), false); + execFileSync("git", ["add", "source.txt"], { cwd: dir }); + assert.equal(runGates(spec, dir).passed, true); + fs.writeFileSync(path.join(dir, "names.json"), "[]"); + assert.equal(runGates(spec, dir).passed, false); +}); + +test("review reports require completion, no findings, and the exact working and staged snapshot", t => { + const dir = fixture(t); + const spec: Spec = { gates: [{ id: "review", type: "review", file: "review.json" }] }; + const report = { format: 1, snapshot: reviewSnapshot(spec, dir), review: { + status: "success", summary: { files_reviewed: 1, comments: 0 }, comments: [] as unknown[], warnings: [], + } }; + const write = () => fs.writeFileSync(path.join(dir, "review.json"), JSON.stringify(report)); + write(); + assert.equal(runGates(spec, dir).passed, true); + report.review.comments = [{ content: "a defect" }]; + report.review.summary.comments = 1; + write(); + assert.equal(runGates(spec, dir).passed, false); + report.review.comments = []; + report.review.summary.comments = 0; + report.review.status = "skipped"; + write(); + assert.equal(runGates(spec, dir).passed, false); + report.review.status = "success"; + write(); + fs.writeFileSync(path.join(dir, "source.txt"), "changed\n"); + assert.equal(runGates(spec, dir).passed, false); + execFileSync("git", ["add", "source.txt"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "source.txt"), "safe baseline\n"); + assert.equal(runGates(spec, dir).passed, false); +}); + +test("an invalid opencode policy blocks publish tools while keeping built-in repair tools available", async t => { + const dir = fixture(t); + fs.mkdirSync(path.join(dir, ".skillgate")); + fs.writeFileSync(path.join(dir, ".skillgate/done.yaml"), "gates: invalid\n"); + const hooks = await SkillGate({ directory: dir }); + await assert.rejects(() => hooks["tool.execute.before"]({ tool: "mcp__release__publish" }, { args: {} }), /policy is invalid/); + await hooks["tool.execute.before"]({ tool: "edit" }, { args: {} }); +}); + +test("named preset initializes private local configuration and refuses existing policy or unknown presets", t => { + const dir = fixture(t); + const cli = path.resolve("dist/src/cli.js"); + const run = (...args: string[]) => spawnSync(process.execPath, [cli, ...args, "--cwd", dir], { encoding: "utf8" }); + assert.equal(run("init", "--preset", "unknown").status, 2); + assert.equal(run("init", "--preset", "no-secrets").status, 0); + const spec = parseSpec(fs.readFileSync(path.join(dir, ".skillgate/done.yaml"), "utf8"), "preset", false); + assert.equal(spec.gates[0].type, "trufflehog"); + assert.match(fs.readFileSync(path.join(dir, ".gitignore"), "utf8"), /forbidden-names.json/); + assert.equal(run("init", "--preset", "no-secrets").status, 1); + assert.equal(run("review-snapshot").status, 0); +});