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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

## Unreleased

## 0.14.0 - 2026-10-09

### Added
- `unchanged` gate: files matching a glob that existed at the base ref must stay
byte-identical. Protects snapshots, golden outputs, applied migrations and CI
workflows from being edited to make a check pass. Line-ending normalization does
not count as a change. Diff-aware and fails closed without a base.
- `deps-declared` gate: every package a JavaScript/TypeScript file imports must be
declared in the nearest `package.json`. Catches imports that resolve only through
hoisting or a global install. Builtins, relative paths, subpath imports and
self-references are skipped; `allow` covers path aliases.
- `instruction-refs` gate: every `@import`, relative Markdown link and
directory-qualified code-span path in the agent instruction files must exist.
Failures point at the instruction file's line.
- `instruction-sync` gains `require`: listed tools (`claude-code`, `gemini-cli`, ...)
must have their own instruction file in sync with or linked to the canonical one.
The reason names a `CLAUDE.local.md` that turns off the AGENTS.md fallback.
`skillgate sync --create <tools>` writes the missing `@AGENTS.md` pointers.
- `signed-commits` gate: every commit between the base ref and HEAD must be signed
(`trust: signed`, the default) or carry a good, checkable signature (`trust: verified`).
- Command gates receive `SKILLGATE_CHANGED_FILES` (a file listing the changed,
existing files, filtered by `when.changed`) and `SKILLGATE_CHANGED_COUNT`, so a
linter can run on the diff. Both are unset when no base resolves.
- Decision log: `--log <file>` or `SKILLGATE_LOG` appends every `gate` and `check`
verdict as a JSON line; `skillgate log` summarizes blocks per gate and blocked
commands. Logs inside the worktree (outside `.git/`) are refused, and logging
never changes a verdict.
- `skillgate explain --commands <file|->` replays a command list or a decision log
against the current `finishLine` and names the gates each command would run.
`explain --command` also lists those gates.

### Changed
- A passing `trivy` gate reports the vulnerabilities below the blocking severities
(`not blocking: 12 HIGH`). One extra JSON scan runs after a passing vulnerability
scan; it is informational and `summary: false` turns it off.
- `--cache` also reuses passing `unchanged` results.

## 0.13.1 - 2026-10-03

### Fixed
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,15 @@ A `file-contains` gate (e.g. require a touched changelog) and the other types ar
| `trivy` | Trivy finds no leaked secrets, no blocking CVEs, and can generate a CycloneDX SBOM |
| `evidence` | a named `file` exists and is non-empty |
| `not-empty` | a directory at `path` contains at least `min` entries (default 1) |
| `instruction-sync` | every AI agent instruction file (CLAUDE.md, AGENTS.md, Cursor, Copilot…) still agrees with the canonical one (optional `threshold`, default 0.95) |
| `instruction-sync` | every AI agent instruction file (CLAUDE.md, AGENTS.md, Cursor, Copilot…) still agrees with the canonical one (optional `threshold`, default 0.95; `require` lists tools that must have their own file) |
| `instruction-refs` | every path an instruction file points at (`@imports`, relative links, `src/...` code spans) still exists |
| `no-new` | the count of `pattern` matches in `glob` did not **increase** versus the base ref (skips, `eslint-disable`, TODOs) |
| `no-fewer` | the count of `pattern` matches in `glob` did not **decrease** versus the base ref (test cases, assertions) |
| `no-deleted` | every file matching `glob` at the base ref still exists |
| `unchanged` | every file matching `glob` at the base ref still exists **byte-identical** (snapshots, golden outputs, applied migrations, CI workflows) |
| `deps-locked` | every dependency declared in `package.json` / `pyproject.toml` is in the lockfile, so a hallucinated package can't slip in |
| `deps-declared` | every package a JS/TS file in `glob` imports is declared in the nearest `package.json` |
| `signed-commits` | every commit between the base ref and HEAD is signed (`trust: verified` for a good, checkable signature) |
| `phase` | the gates required by the active phase and every earlier one pass now (plan → build → review) |

A glob that matches no files fails its gate instead of passing as a silent no-op
Expand All @@ -312,6 +316,34 @@ on push or publish, and only when source changed:
A condition skillgate cannot decide runs the gate. See the
[spec reference](docs/spec-reference.md#conditional-gates-when).

**Lint only what changed.** Command gates get `SKILLGATE_CHANGED_FILES`, a file
listing the changed files versus the base (filtered by the gate's `when.changed`),
so a linter can run on the diff instead of the whole repo:

```yaml
- id: lint-changed
type: command
run: 'if [ -n "$SKILLGATE_CHANGED_FILES" ]; then xargs -r npx eslint < "$SKILLGATE_CHANGED_FILES"; else npx eslint .; fi'
when:
changed: ["src/**/*.ts"]
```

**Protect the files that define "correct".** An agent can make a check pass by
editing the snapshot, the expected output or the CI workflow instead of the code.
`unchanged` blocks any change to files that existed at the base:

```yaml
- id: protected
type: unchanged
glob: "{**/__snapshots__/**,migrations/**,.github/workflows/**}"
```

**See what the gate did.** Set `SKILLGATE_LOG=.git/skillgate/decisions.jsonl` (or
pass `--log`) and every `gate` and `check` verdict is appended as one JSON line.
`skillgate log` summarizes it: how often each gate blocked and which commands it
stopped. `skillgate explain --commands <file>` replays a command list, or the log
itself, against the current `finishLine` before you change it.

**Phases, checked live.** A `phase` gate orders work (plan → build → review) and
requires each phase's gates, plus every earlier phase's, to pass at the moment
they are checked. The active phase is a plain marker file, and
Expand All @@ -326,7 +358,9 @@ CLI and opencode. `when.tool` scopes a gate to them.
**Trivy security gate.** Add `type: trivy` when the finish line should stop on
leaked secrets or critical CVEs. skillgate runs Trivy's secret scan separately
from the vulnerability scan, so `severity: ["CRITICAL"]` filters CVEs without
masking secrets. By default it also verifies that Trivy can emit a CycloneDX
masking secrets. A passing scan reports what it did not block (`not blocking:
12 HIGH`), so "no critical" never reads as "nothing found"; set `summary: false`
to skip that count. By default it also verifies that Trivy can emit a CycloneDX
SBOM; set `sbom: false` if your workflow only needs the blocking scan.

**The `evidence` escape hatch.** Gates only see machine-observable output. For a step like "research the API first," have the agent write `.skillgate/evidence/research.md` as it works and gate on that file. Otherwise the step is invisible and the deviation hides.
Expand Down
144 changes: 139 additions & 5 deletions docs/spec-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ test/lint/build commands, not anything that hits the network.

On timeout the gate returns a deterministic `command timed out after Nms` reason.

When a base ref resolves, the command also receives the files changed against it:

| Variable | Value |
|----------|-------|
| `SKILLGATE_CHANGED_FILES` | path of a temporary file listing the changed files, one per line, relative to the policy's directory |
| `SKILLGATE_CHANGED_COUNT` | how many files the list holds |

The list covers committed, staged, unstaged and untracked changes, leaves out
deleted files, and keeps only files matching the gate's `when.changed` globs when
it has them. Without a base both variables are unset, so a command can fall back
to a full run instead of checking nothing:

```yaml
- id: lint-changed
type: command
run: 'if [ -n "$SKILLGATE_CHANGED_FILES" ]; then xargs -r npx eslint < "$SKILLGATE_CHANGED_FILES"; else npx eslint .; fi'
when:
changed: ["src/**/*.ts"]
```

### `trivy`

Run Trivy as a first-class security gate. By default this blocks on any leaked
Expand All @@ -114,7 +134,10 @@ secret, any `CRITICAL` vulnerability, or failure to generate a CycloneDX SBOM.
```

The secret scan runs separately from the vulnerability scan, so CVE severity
filtering does not hide leaked credentials. Set `trivy` when the binary is not on
filtering does not hide leaked credentials. After a passing vulnerability scan,
one more JSON scan counts the findings below the blocking severities and the gate
reports them (`not blocking: 12 HIGH, 3 MEDIUM`). The count is informational and
never changes the verdict; set `summary: false` to skip it. Set `trivy` when the binary is not on
`PATH`, or `ignoreUnfixed: true` when the vulnerability policy should ignore
unfixed CVEs.

Expand Down Expand Up @@ -154,8 +177,37 @@ agents are reading different rulebooks. Run `skillgate sync` to fix, or
- id: instructions-in-sync
type: instruction-sync
threshold: 0.95 # optional, 0..1, default 0.95
require: [claude-code, gemini-cli] # optional
```

`require` names tools that must have their own instruction file, in sync with or
linked to the canonical one. Without it, a repository with only AGENTS.md passes,
yet some tools read AGENTS.md only through a fallback that can be switched off
(Claude Code skips it when, for example, only a personal `CLAUDE.local.md` is
present). Tool ids: `agents-md`, `claude-code`, `cursor`, `github-copilot`,
`gemini-cli`, `cline`, `windsurf`, `jetbrains-junie`. `skillgate sync --create
claude-code,gemini-cli` writes the missing files as `@AGENTS.md` pointers (or
synced copies for tools without import support).

### `instruction-refs`

Every repository path an instruction file points at must exist: `@imports`
(`@docs/rules.md`), relative Markdown links, and code spans that name a
directory-qualified path (`src/cli.ts`, `docs/`). Rules about files that are gone
send the agent after a repository that no longer exists.

```yaml
- id: instruction-refs
type: instruction-refs
ignore: ["dist/**"] # optional: referenced paths to skip
```

Fenced code blocks, URLs, absolute and home paths, and bare file names are not
judged. A slashed code span counts as a path only when it has a file extension,
ends with `/`, or its first segment exists, so `origin/main` and `owner/repo` are
left alone. References resolve against the instruction file's directory or the
repository root. Failures point at `file:line`.

### `no-new`, `no-fewer`, `no-deleted` (diff-aware)

These gates compare the working tree with the commit the change forked from
Expand Down Expand Up @@ -188,6 +240,64 @@ and extglobs. Historical reads are scoped to the policy's workspace.
`no-fewer` catches a suite made green by deleting test cases inside files that
still exist, which `no-deleted` (whole files) and `no-new` (added skips) miss.

### `unchanged` (diff-aware)

Every file matching `glob` that existed at the base must still exist with
identical content. Use it for the files that define "correct": snapshots and
golden outputs, migrations that already ran, CI workflows, lint configuration.
An agent that edits them to make a check pass is blocked; new files are allowed.

```yaml
- id: protected
type: unchanged
glob: "{**/__snapshots__/**,migrations/**,.github/workflows/**}"
ignore: ["migrations/README.md"] # optional
```

Content is compared the way Git would store it, so line-ending normalization
from `.gitattributes` does not count as a change. Like the other diff-aware
gates it fails closed without a base. Change a protected file in a separate,
reviewed commit, or pin the policy with `--pin`.

### `signed-commits` (diff-aware)

Every commit between the base and HEAD must be signed. Scope it to the push:

```yaml
- id: signed
type: signed-commits
trust: signed # optional: signed (default) or verified
when:
command: ["git push"]
```

`signed` accepts any signature Git did not reject, including one whose key is not
in the local keyring. `verified` requires a good signature Git can check (GPG
trust, or `gpg.ssh.allowedSignersFile` for SSH signing). Unsigned commits, bad
signatures and revoked keys always fail. No commits since the base passes.

### `deps-declared`

Every package a JavaScript or TypeScript file imports must be declared in the
nearest `package.json` (dependencies, devDependencies, optionalDependencies or
peerDependencies). An import that works only because another package hoisted it,
or because it is installed globally, fails for the next user; this catches it
offline. Pair it with `deps-locked`, which checks the manifest against the lockfile.

```yaml
- id: deps-declared
type: deps-declared
glob: "src/**/*.{ts,tsx,js,mjs,cjs}"
allow: ["@/*", "virtual:*"] # optional: aliases treated as declared
```

`import … from`, `export … from`, side-effect imports, `require()` and dynamic
`import()` with a literal specifier are read; comments are ignored. Relative
paths, `#subpath` imports, protocol specifiers (`node:`, `bun:`) and Node
builtins are skipped, as are a package's imports of itself. A type-only import is
also satisfied by `@types/<name>`. Workspace packages answer to their own
manifest.

### `deps-locked`

Every dependency declared in a manifest must be present in its lockfile. A
Expand Down Expand Up @@ -363,7 +473,31 @@ explicit ignored gate inputs and symlink file contents. Failing or malformed
results are never reused. A workspace change during evaluation blocks a passing
receipt. Receipt outputs must not overlap gate input paths or globs.

Cache reuse applies to local file, directory, pattern, diff, and phase checks.
Commands, Trivy, TruffleHog, reviews, instruction selection, and dependency checks
always execute again because their complete inputs are not represented by the
snapshot. With `--json`, `cacheDisabledReason` explains a requested cache bypass.
Cache reuse applies to local file, directory, pattern, diff (including
`unchanged`), and phase checks. Commands, Trivy, TruffleHog, reviews, instruction
selection and references, signed commits, and dependency checks always execute
again because their complete inputs are not represented by the snapshot. With `--json`, `cacheDisabledReason` explains a requested cache bypass.

## Decision log

`--log <file>` on `check` and `gate`, or `SKILLGATE_LOG=<file>`, appends every
verdict as one JSON line: time, `via` (`gate` or `check`), `event` (`command`,
`tool`, `stop`, `check`), `decision`, `reason`, the judged command or tool, the
failed gate ids with their reasons, and the duration. Agent hooks call `gate` for
every shell command, so the log also records the commands that were allowed.

Keep the log outside the worktree, or under `.git/` (`.git/skillgate/decisions.jsonl`
in a regular checkout). A path inside the worktree would change the snapshot the
gates judge, so it is refused. Logging never changes a verdict: a log that cannot
be written is reported on stderr and skipped.

```bash
skillgate log .git/skillgate/decisions.jsonl # blocks per gate, blocked commands
skillgate log --json # same, from SKILLGATE_LOG
skillgate explain --commands .git/skillgate/decisions.jsonl # replay against today's finishLine
```

`explain --commands <file>` reads one command per line (`#` comments allowed) or
decision-log records, and reports which commands cross the finish line, which
pattern matched, and which gates their `when.command` scope would run. Pass `-`
to read stdin. Use it before you change `finishLine` or a `when` block.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reneza/skillgate",
"version": "0.13.1",
"version": "0.14.0",
"publishConfig": {
"access": "public"
},
Expand Down
Loading
Loading