Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions schema/done.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,40 @@
"description": "Minimum number of entries. Default 1."
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "glob", "pattern"],
"properties": {
"id": { "type": "string" },
"description": { "type": "string" },
"type": { "const": "no-new" },
"glob": { "type": "string", "description": "Files to scan." },
"pattern": { "type": "string", "description": "Regex whose match count must not increase versus the base ref." },
"flags": { "type": "string" },
"ignore": {
"type": "array",
"items": { "type": "string" },
"description": "Extra globs to exclude."
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["id", "type", "glob"],
"properties": {
"id": { "type": "string" },
"description": { "type": "string" },
"type": { "const": "no-deleted" },
"glob": { "type": "string", "description": "Files that existed at the base ref and must still exist." },
"ignore": {
"type": "array",
"items": { "type": "string" },
"description": "Extra globs to exclude from the must-still-exist set."
}
}
}
]
}
Expand Down
141 changes: 135 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { findSpecPath, loadSpec } from "./spec.js";
import { runGates } from "./core.js";
import { findSpecPath, loadSpec, parseSpec, DEFAULT_SPEC_PATHS, type Spec } from "./spec.js";
import { runGates, decideCommand } from "./core.js";
import { checkDrift, formatDiff, DEFAULT_THRESHOLD, discover, pickCanonical } from "./drift.js";
import { runSync } from "./link.js";
import { runScaffold, listTemplates } from "./scaffold.js";
import { resolveBaseRef, mergeBase, readFileAtRef, repoRoot } from "./git.js";

const C = {
reset: "\x1b[0m",
Expand Down Expand Up @@ -68,6 +69,7 @@ function help(): void {
Usage:
skillgate audit one-shot read-only audit of this repo (no config needed)
skillgate check [spec] run gates, exit 1 if any fail
skillgate gate allow/block one command (any harness); exit 2 = block
skillgate init write an example .skillgate/done.yaml
skillgate scaffold [--template] generate .skillgate/evidence/ with stack templates
skillgate drift report AI instruction-file drift, exit 1 if drifted
Expand All @@ -77,8 +79,14 @@ Usage:
skillgate --version

Flags:
--json machine-readable output (check, drift)
--json machine-readable output (check, gate, drift)
--cwd <dir> run against another directory
--pin check/gate: read the spec from the base ref, not the
working tree, so a change can't loosen its own gate
--base <ref> ref for diff-aware gates (no-new, no-deleted) and, with
--pin, the pinned spec. Default: SKILLGATE_BASE or origin/HEAD
--command "<cmd>" gate: the command to judge (else read from stdin)
--allow-on-error gate: allow instead of fail-closed if evaluation errors
--threshold <0..1> drift: similarity required to count as in sync (default 0.95)
--dry-run sync: show what would change without writing
--symlink sync: use symlinks instead of pointer files and copies
Expand Down Expand Up @@ -114,6 +122,81 @@ const cwd = cwdIdx >= 0 ? path.resolve(args[cwdIdx + 1]) : process.cwd();

const updateAgents = args.includes("--update-agents");

const baseIdx = args.indexOf("--base");
const baseArg = baseIdx >= 0 ? args[baseIdx + 1] : undefined;
// Pinning is a deliberate opt-in and CLI/env-only — never a spec field, so a spec
// can't grant itself immunity. `--pin` reads the spec from the base ref; `--base`
// only names which ref (used for diff-aware gates too), it does not pin on its own.
const pin = args.includes("--pin");

function die(code: number, msg: string): never {
console.error(c(C.red, `skillgate: ${msg}`));
process.exit(code);
}

interface Resolved {
spec: Spec;
/** merge-base ref diff-aware gates compare against, if one could be resolved. */
gateBase?: string;
/** ref the spec itself was pinned to (only set in --pin mode). */
pinnedTo?: string;
}

/**
* Load the spec and the git base the gates judge against. Without `--pin` the spec
* comes from the working tree (a diff base is still resolved best-effort so
* diff-aware gates work). With `--pin` the spec is read from the base ref itself —
* so the change under review cannot edit or delete the policy it is judged by —
* and anything that prevents that (no base, no pinned spec) fails closed.
*/
function resolveSpecAndBase(specPathHint: string | null): Resolved {
const rawBase = resolveBaseRef(cwd, baseArg);
const gateBase = rawBase ? mergeBase(cwd, rawBase) : undefined;

if (!pin) {
if (!specPathHint) die(2, "no spec found — run `skillgate init` or pass a path");
return { spec: loadSpec(specPathHint), gateBase };
}

if (!rawBase) {
die(2, "--pin: cannot resolve a base ref (set SKILLGATE_BASE or pass --base <ref>) — refusing to run unpinned (fail-closed)");
}
const root = repoRoot(cwd);
if (!root) die(2, "--pin: not a git repository (fail-closed)");
const rels = specPathHint
? [path.relative(root, specPathHint).split(path.sep).join("/")]
: DEFAULT_SPEC_PATHS;
for (const rel of rels) {
const raw = readFileAtRef(cwd, gateBase!, rel);
if (raw != null) {
const label = `${gateBase!.slice(0, 12)}:${rel}`;
return { spec: parseSpec(raw, label, rel.endsWith(".json")), 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)`);
}

/** Read the command an agent is about to run from stdin — raw, or from a hook JSON payload. */
function readStdinCommand(): string {
if (process.stdin.isTTY) return "";
let raw = "";
try {
raw = fs.readFileSync(0, "utf8").trim();
} catch {
return "";
}
if (!raw) return "";
if (raw.startsWith("{")) {
try {
const o: any = JSON.parse(raw);
return String(o?.tool_input?.command ?? o?.command ?? o?.params?.command ?? o?.tool_input?.cmd ?? raw);
} catch {
return raw;
}
}
return raw;
}

if (cmd === "init") {
const dir = path.join(cwd, ".skillgate");
fs.mkdirSync(dir, { recursive: true });
Expand Down Expand Up @@ -184,24 +267,32 @@ if (cmd === "audit") {
if (cmd === "check") {
const explicit = args[1] && !args[1].startsWith("-") ? path.resolve(cwd, args[1]) : null;
const specPath = explicit ?? findSpecPath(cwd);
if (!specPath || !fs.existsSync(specPath)) {
// Without --pin the working-tree spec must exist; with --pin it may have been
// deleted in the change under review — resolveSpecAndBase reads it from the base.
if (!pin && (!specPath || !fs.existsSync(specPath))) {
console.error(c(C.red, "skillgate: no spec found") + " — run `skillgate init` or pass a path");
process.exit(2);
}

let result;
let pinnedTo: string | undefined;
try {
result = runGates(loadSpec(specPath), cwd);
const r = resolveSpecAndBase(specPath && fs.existsSync(specPath) ? specPath : null);
pinnedTo = r.pinnedTo;
result = runGates(r.spec, cwd, { baseRef: r.gateBase });
} catch (e: any) {
console.error(c(C.red, `skillgate: ${e.message}`));
process.exit(2);
}

if (json) {
console.log(JSON.stringify(result, null, 2));
console.log(JSON.stringify({ ...result, pinnedTo }, null, 2));
process.exit(result.passed ? 0 : 1);
}

if (pinnedTo) {
console.log(c(C.dim, ` policy pinned to ${pinnedTo.slice(0, 12)} (base ref) — this change cannot loosen it`));
}
for (const r of result.results) {
const mark = r.ok ? c(C.green, "✓") : c(C.red, "✗");
console.log(` ${mark} ${r.id} ${c(C.dim, r.reason)}`);
Expand Down Expand Up @@ -322,6 +413,44 @@ if (cmd === "canonical") {
process.exit(0);
}

if (cmd === "gate") {
// Harness-neutral entrypoint: pipe in (or pass) the command an agent is about to
// run; get back allow/block. Works from a Claude Code PreToolUse hook, a Cursor/
// Codex/git wrapper, or a bare shell — enforcement no longer needs one harness's
// plugin API. Exit 0 = allow, 2 = block. Fails closed on error (unless --allow-on-error).
const cIdx = args.indexOf("--command");
const command = ((cIdx >= 0 ? args[cIdx + 1] : readStdinCommand()) ?? "").trim();
const allowOnError = args.includes("--allow-on-error");
const specPath = findSpecPath(cwd);

if (!pin && !specPath) {
// No definition of done configured: nothing to enforce, let it through.
const d = { decision: "allow", reason: "no skillgate spec — nothing to enforce", command };
console.log(json ? JSON.stringify(d, null, 2) : c(C.dim, `allow · ${d.reason}`));
process.exit(0);
}

try {
const r = resolveSpecAndBase(specPath && fs.existsSync(specPath) ? specPath : null);
const decision = decideCommand(r.spec, cwd, command, { baseRef: r.gateBase });
if (json) {
console.log(JSON.stringify({ ...decision, pinnedTo: r.pinnedTo }, null, 2));
} else if (decision.decision === "block") {
console.error(c(C.red, `✗ blocked: `) + decision.reason);
for (const f of decision.result?.failed ?? []) console.error(c(C.dim, ` · ${f.id}: ${f.reason}`));
} else {
console.log(c(C.green, `✓ allow`) + c(C.dim, ` · ${decision.reason}`));
}
process.exit(decision.decision === "block" ? 2 : 0);
} catch (e: any) {
const decision = allowOnError ? "allow" : "block";
const payload = { decision, reason: `error: ${e.message}`, command };
if (json) console.log(JSON.stringify(payload, null, 2));
else console.error(c(allowOnError ? C.dim : C.red, `${decision}: ${payload.reason}`));
process.exit(allowOnError ? 0 : 2);
}
}

console.error(`unknown command: ${cmd}\n`);
help();
process.exit(2);
110 changes: 106 additions & 4 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { execSync } from "node:child_process";
import { globSync } from "tinyglobby";
import { type Spec, type Gate, DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_NOT_EMPTY_MIN } from "./spec.js";
import { checkDrift, DEFAULT_THRESHOLD } from "./drift.js";
import { readFileAtRef, listFilesAtRef, matchesGlob } from "./git.js";

export interface GateResult {
id: string;
Expand All @@ -18,9 +19,27 @@ export interface RunResult {
failed: GateResult[];
}

/** Evaluation context. `baseRef` is the git ref diff-aware gates compare against. */
export interface RunOptions {
baseRef?: string;
}

const IGNORE = ["**/node_modules/**", "**/.git/**", "dist/**"];

function checkGate(gate: Gate, cwd: string): GateResult {
/** Count lines of `text` that match `re`; calls `onFirst` with the 0-based index of the first hit. */
function countMatchingLines(text: string, re: RegExp, onFirst?: (i: number) => void): number {
const lines = text.split("\n");
let n = 0;
for (let i = 0; i < lines.length; i++) {
if (re.test(lines[i])) {
if (n === 0 && onFirst) onFirst(i);
n++;
}
}
return n;
}

function checkGate(gate: Gate, cwd: string, opts: RunOptions): GateResult {
const base = { id: gate.id, type: gate.type };
try {
switch (gate.type) {
Expand Down Expand Up @@ -105,6 +124,59 @@ function checkGate(gate: Gate, cwd: string): GateResult {
if (entries.length < min) return { ...base, ok: false, reason: `directory has ${entries.length} entries, expected at least ${min}` };
return { ...base, ok: true, reason: `directory has ${entries.length} entries` };
}
case "no-new": {
if (!opts.baseRef) {
return { ...base, ok: false, reason: `no git base ref to diff against — pass --base <ref> or run in a repo with an upstream (fail-closed)` };
}
// Fresh regex per line (drop g/y so lastIndex can't advance between .test calls).
const flags = (gate.flags ?? "").replace(/[gy]/g, "");
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(
(p) => matchesGlob(p, gate.glob, ignore) && !IGNORE.some((ig) => matchesGlob(p, ig)),
);
const union = new Set<string>([...workFiles, ...baseFiles]);
let baseCount = 0;
let workCount = 0;
let firstNew = "";
for (const f of union) {
const baseText = readFileAtRef(cwd, opts.baseRef, f);
baseCount += baseText == null ? 0 : countMatchingLines(baseText, re());
try {
const t = fs.readFileSync(path.resolve(cwd, f), "utf8");
workCount += countMatchingLines(t, re(), (i) => {
if (!firstNew) firstNew = `${f}:${i + 1}`;
});
} catch {
/* file gone in working tree — contributes 0, handled by no-deleted */
}
}
const short = opts.baseRef.slice(0, 12);
return workCount > baseCount
? {
...base,
ok: false,
reason: `+${workCount - baseCount} new /${gate.pattern}/ vs ${short} (base ${baseCount}, now ${workCount})${firstNew ? `, e.g. ${firstNew}` : ""}`,
}
: { ...base, ok: true, reason: `no new /${gate.pattern}/ vs ${short} (${workCount} ≤ ${baseCount})` };
}
case "no-deleted": {
if (!opts.baseRef) {
return { ...base, ok: false, reason: `no git base ref to diff against — pass --base <ref> 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 missing = baseFiles.filter((f) => !fs.existsSync(path.resolve(cwd, f)));
const short = opts.baseRef.slice(0, 12);
return missing.length
? {
...base,
ok: false,
reason: `${missing.length} file(s) matching ${gate.glob} deleted since ${short}: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? " …" : ""}`,
}
: { ...base, ok: true, reason: `no ${gate.glob} files deleted vs ${short} (${baseFiles.length} present)` };
}
default:
return { ...base, ok: false, reason: `unknown gate type` };
}
Expand All @@ -113,9 +185,9 @@ function checkGate(gate: Gate, cwd: string): GateResult {
}
}

/** Run every gate in the spec over `cwd`. Pure: same inputs, same verdict. */
export function runGates(spec: Spec, cwd: string): RunResult {
const results = (spec.gates ?? []).map((g) => checkGate(g, cwd));
/** Run every gate in the spec over `cwd`. Pure: same inputs (incl. git base), same verdict. */
export function runGates(spec: Spec, cwd: string, opts: RunOptions = {}): RunResult {
const results = (spec.gates ?? []).map((g) => checkGate(g, cwd, opts));
const failed = results.filter((r) => !r.ok);
return { passed: failed.length === 0, results, failed };
}
Expand All @@ -125,3 +197,33 @@ export function isFinishLine(command: string, patterns: string[] | undefined): b
if (!patterns || !patterns.length) return false;
return patterns.some((p) => command.includes(p));
}

export interface Decision {
decision: "allow" | "block";
reason: string;
command: string;
result?: RunResult;
}

/**
* The harness-neutral verdict: given a command an agent is about to run, decide
* whether to let it through. Any tool — a Claude Code hook, a Cursor/Codex
* wrapper, a git hook, a bare shell — can call this and read the same answer, so
* enforcement no longer depends on one harness's plugin API. A command that does
* not cross the finish line is always allowed; one that does is blocked unless
* every gate passes.
*/
export function decideCommand(spec: Spec, cwd: string, command: string, opts: RunOptions = {}): Decision {
if (!isFinishLine(command, spec.finishLine)) {
return { decision: "allow", reason: "not a finish-line command", command };
}
const result = runGates(spec, cwd, opts);
return result.passed
? { decision: "allow", reason: `all ${result.results.length} gates passed`, command, result }
: {
decision: "block",
reason: `${result.failed.length} of ${result.results.length} gates unmet: ${result.failed.map((f) => f.id).join(", ")}`,
command,
result,
};
}
Loading