mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
investigate: opt-in gate that holds root cause before fix, through Bash too (GST-1461) (#4045)
* investigate: opt-in gate that holds root cause before fix, through Bash too (GST-1461) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * investigate gate: posix paths in edit blocks, CI fixes (GST-1461) - hook: the edit branch computes the repo-relative path once in posix form, so the marker and deny text read src/total.ts on Windows too - lib/review-depth.ts is back to main's pinned bytes; lib/investigate-gate.ts keeps its own copy of TEST_PATH - gstack-skill-gates report: destructure session_id (the pregate secrets scan read the old line as a secret-named assignment) - tests: pin core.autocrlf=false in the git fixtures, skip the symlink swap on Windows, give setup-hook-syntax-gate's fixture the shared TypeScript entry a shim runs, unit-test excludedReason, and check the smoke's pinned Claude Code version against docs/skill-gates.md Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-1461) - checkTree saves the bytes a restore replaces in the session directory and the block names the copy, so a concurrent editor save is never lost - a new commit is undone to its newest ancestor that existed before the baseline or that a remote-tracking ref holds, not to the baseline HEAD: a fix committed on another branch moves that branch back to its own tip, and pull/merge of fetched upstream commits is not undone; the block lists the commit's other changes left staged - runGateHook takes the switch reading from its caller, so the investigate_gate config key has a literal reader (gstack-config-defaults) - the slow checkout case gets an explicit 30 s test timeout Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: p1/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, round 2 (GST-1461) - an amend or rebase of commits from before the baseline on the same branch is undone to the baseline HEAD, not to its parent; on a different branch the gate blocks but leaves the commit in place - existedBefore answers reachability with one rev-list call instead of one merge-base per ref, so a fix commit in a repo with hundreds of refs is still undone inside the 5 s hook deadline Review-Finding: p2/specialist-batched#1 reader=testing Review-Finding: p2/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c454496143
commit
9e809ab5b1
23 files changed
+1742
-28
No files matched your search
+3
-2
@@ -347,6 +347,7 @@ lookup_default() {
|
||||
timeline_stop_hook) echo "yes" ;; # yes | no — controls ./setup timeline Stop hook registration (#2677)
|
||||
skill_routing) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_routing ;; # on | off — the skill-routing UserPromptSubmit hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
|
||||
skill_contracts) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default skill_contracts ;; # on | off — the skill-contract Stop hook; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-routing-and-contracts.md)
|
||||
investigate_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default investigate_gate ;; # on | off — the /investigate enforcement hooks; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-gates.md)
|
||||
|
||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||
redact_prepush_hook) echo "false" ;;
|
||||
@@ -1143,7 +1144,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
|
||||
skill_routing skill_contracts; do
|
||||
skill_routing skill_contracts investigate_gate; do
|
||||
gstack_config_select "$KEY"
|
||||
VALUE="$_gstack_cfg_value"
|
||||
SOURCE="default"
|
||||
@@ -1169,7 +1170,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \
|
||||
skill_routing skill_contracts; do
|
||||
skill_routing skill_contracts investigate_gate; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
||||
@@ -129,7 +129,11 @@ var KNOWN_HOOKS = {
|
||||
"gstack-session-update": { source: "gstack-session-update", event: "SessionStart", matcher: "", relpath: "bin/gstack-session-update" },
|
||||
"gstack-verify-gate": { source: "verify-gate", event: "Stop", matcher: "", relpath: "bin/gstack-verify-gate" },
|
||||
"skill-route-hook": { source: "gstack-skill-route", event: "UserPromptSubmit", matcher: "", relpath: "hosts/claude/hooks/skill-route-hook" },
|
||||
"skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" }
|
||||
"skill-contract-stop-hook": { source: "gstack-skill-contract", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/skill-contract-stop-hook" },
|
||||
"investigate-gate-pre": { source: "gstack-investigate-gate", event: "PreToolUse", matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash", relpath: "hosts/claude/hooks/investigate-gate-pre" },
|
||||
"investigate-gate-post": { source: "gstack-investigate-gate", event: "PostToolUse", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post" },
|
||||
"investigate-gate-post-failure": { source: "gstack-investigate-gate", event: "PostToolUseFailure", matcher: "Bash", relpath: "hosts/claude/hooks/investigate-gate-post-failure" },
|
||||
"investigate-gate-stop": { source: "gstack-investigate-gate", event: "Stop", matcher: "", relpath: "hosts/claude/hooks/investigate-gate-stop" }
|
||||
};
|
||||
function gsHadBashPrefix(c) { return String(c == null ? "" : c).trim().indexOf("bash ") === 0; }
|
||||
function gsStripWrap(c) {
|
||||
@@ -488,8 +492,8 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
case "$EVENT" in
|
||||
SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;;
|
||||
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
|
||||
SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification) ;;
|
||||
*) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|PostToolUseFailure|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
|
||||
esac
|
||||
DIFF_ONLY=""
|
||||
ENSURE=""
|
||||
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-skill-gates — the opt-in enforcement hooks' helper (GST-1461).
|
||||
*
|
||||
* gstack-skill-gates settings --investigate
|
||||
* Print a Claude Code settings JSON that registers the /investigate gate
|
||||
* and turns its switch on, for \`claude -p --settings <file>\` or the Agent
|
||||
* SDK's settings, so a headless run needs no settings.json edit.
|
||||
* gstack-skill-gates report [--session <id>] [--expect armed] [--json]
|
||||
* Count fired, blocked, released, observed and error markers per session
|
||||
* from $GSTACK_STATE_ROOT/skill-gates/<session>/record.json.
|
||||
*
|
||||
* Exit codes: 0 ok · 1 usage (SKILL_GATES_USAGE) · 2 GATE_NOT_ARMED (--expect
|
||||
* armed and a session never fired) · 3 GATE_ERRORED (a session recorded a hook
|
||||
* error). Docs: docs/skill-gates.md.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { gatesRoot, readGateRecord, recordPath, type GateHook, type GateRecord } from '../lib/skill-gates';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const USAGE = 'Usage: gstack-skill-gates settings --investigate\n gstack-skill-gates report [--session <id>] [--expect armed] [--json]';
|
||||
const HOOKS: Record<string, { env: string; source: GateHook; events: Array<{ event: string; shim: string; matcher?: string }> }> = {
|
||||
'--investigate': {
|
||||
env: 'GSTACK_INVESTIGATE_GATE',
|
||||
source: 'investigate-gate',
|
||||
events: [
|
||||
{ event: 'PreToolUse', shim: 'investigate-gate-pre', matcher: 'Edit|Write|MultiEdit|NotebookEdit|Bash' },
|
||||
{ event: 'PostToolUse', shim: 'investigate-gate-post', matcher: 'Bash' },
|
||||
{ event: 'PostToolUseFailure', shim: 'investigate-gate-post-failure', matcher: 'Bash' },
|
||||
{ event: 'Stop', shim: 'investigate-gate-stop' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function usage(why: string): never {
|
||||
process.stderr.write(`SKILL_GATES_USAGE: ${why}\n${USAGE}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
|
||||
|
||||
if (args[0] === 'settings') {
|
||||
const picked = args.slice(1);
|
||||
if (!picked.length || picked.some(a => !HOOKS[a])) usage(`settings takes ${Object.keys(HOOKS).join(' ')}`);
|
||||
const settings: { env: Record<string, string>; hooks: Record<string, unknown[]> } = { env: {}, hooks: {} };
|
||||
for (const flag of picked) {
|
||||
const h = HOOKS[flag];
|
||||
settings.env[h.env] = 'on';
|
||||
for (const e of h.events) {
|
||||
(settings.hooks[e.event] ??= []).push({ ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: 'command', command: path.join(ROOT, 'hosts', 'claude', 'hooks', e.shim), timeout: 10 }] });
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(settings, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] !== 'report') usage('expected settings or report');
|
||||
const flag = (name: string) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] ?? usage(`${name} needs a value`)) : undefined; };
|
||||
const session = flag('--session');
|
||||
const expect = flag('--expect');
|
||||
if (expect !== undefined && expect !== 'armed') usage('--expect takes armed');
|
||||
const asJson = args.includes('--json');
|
||||
|
||||
const records: GateRecord[] = [];
|
||||
if (session) {
|
||||
const file = recordPath(session);
|
||||
if (fs.existsSync(file)) records.push(readGateRecord(file, session));
|
||||
} else {
|
||||
let dirs: string[] = [];
|
||||
try { dirs = fs.readdirSync(gatesRoot()); } catch { /* none yet */ }
|
||||
for (const d of dirs.sort()) {
|
||||
const file = path.join(gatesRoot(), d, 'record.json');
|
||||
if (fs.existsSync(file)) records.push(readGateRecord(file, d));
|
||||
}
|
||||
}
|
||||
|
||||
const rows = records.flatMap(({ session_id: session, hooks }) => Object.entries(hooks).map(([hook, h]) => {
|
||||
const runs = Object.values(h!.runs);
|
||||
return {
|
||||
session, hook, runs: runs.length, fired: runs.length,
|
||||
blocked: runs.reduce((n, r) => n + r.blocked, 0), released: runs.filter(r => r.released_at).length,
|
||||
observed: runs.reduce((n, r) => n + r.observed, 0), errors: h!.errors.length, error_codes: [...new Set(h!.errors.map(e => e.code))],
|
||||
skipped: Object.keys(h!.skipped ?? {}),
|
||||
};
|
||||
}));
|
||||
|
||||
if (asJson) process.stdout.write(`${JSON.stringify({ rows }, null, 2)}\n`);
|
||||
else {
|
||||
process.stdout.write(`skill gates: ${rows.length} session-hook row(s) under ${gatesRoot()}\n`);
|
||||
for (const r of rows) {
|
||||
process.stdout.write(` ${r.session} ${r.hook} fired=${r.fired} blocked=${r.blocked} released=${r.released} observed=${r.observed} errors=${r.errors}${r.error_codes.length ? ` (${r.error_codes.join(',')})` : ''}${r.skipped.length ? ` skipped=${r.skipped.join(',')}` : ''}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.some(r => r.errors > 0)) {
|
||||
process.stderr.write('GATE_ERRORED: a gate hook recorded an error; its stops and edits were allowed (docs/skill-gates.md#gate-errored)\n');
|
||||
process.exit(3);
|
||||
}
|
||||
if (expect === 'armed' && (session ? !rows.some(r => r.fired > 0) : rows.length === 0 || rows.some(r => r.fired === 0))) {
|
||||
process.stderr.write('GATE_NOT_ARMED: no fired marker for the expected session; the hooks never engaged (docs/skill-gates.md#gate-not-armed)\n');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Usage:
|
||||
* gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]
|
||||
* gstack-skill-route --default <skill_routing|skill_contracts>
|
||||
* gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate>
|
||||
*
|
||||
* Prints the switch state, the threshold, every candidate skill with its
|
||||
* score and best trigger, the route the hook would take and the exact line it
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
ROUTE_THRESHOLD, SWITCH_DEFAULT, hintLine, installedSkillsDir, readSkillSwitch, routePrompt, scanInstalledSkills,
|
||||
} from '../lib/skill-route';
|
||||
|
||||
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts>';
|
||||
const USAGE = 'Usage: gstack-skill-route --explain "<prompt>" [--json] [--threshold <n>]\n gstack-skill-route --default <skill_routing|skill_contracts|investigate_gate>';
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(`${USAGE}\n`); process.exit(0); }
|
||||
if (args[0] === '--default') {
|
||||
const name = args[1] ?? '';
|
||||
if (!Object.hasOwn(SWITCH_DEFAULT, name)) {
|
||||
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing or skill_contracts\n${USAGE}\n`);
|
||||
process.stderr.write(`SKILL_ROUTE_USAGE: --default needs skill_routing, skill_contracts or investigate_gate\n${USAGE}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
process.stdout.write(`${SWITCH_DEFAULT[name as keyof typeof SWITCH_DEFAULT]}\n`);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
bump: minor
|
||||
---
|
||||
**An opt-in /investigate gate that holds "no fix before a root cause", through Bash too (GST-1461).** On Opus 5.5 the skill text alone gets that step followed 0 of 46 times, and GST-938's Edit/Write-only gate held in just 13 of 28 sessions because Opus edits through the shell. With `./setup --investigate-gate` (or `GSTACK_INVESTIGATE_GATE=on`, or `gstack-config set investigate_gate on`), a session that loaded /investigate can't change tracked source until it writes a `Root cause: <what is wrong>` line: Edit, Write, MultiEdit and NotebookEdit are denied, and any Bash command that changes a tracked source file (`sed -i`, redirects, `tee`, `python`, `perl`, `node`, `git apply`, a commit) is caught by comparing the tree with a baseline, put back and reported. Tests, docs, lockfiles and new untracked files are never blocked. Every decision is a `GSTACK_GATE` marker (`fired`, `blocked`, `released`, `observed`, `skipped`, `error`) counted in `$GSTACK_STATE_ROOT/skill-gates/<session>/record.json`, traced in `hooks/investigate-gate.log` and recorded in the transcript; a hook error fails open with an `error code=…` marker. `gstack-skill-gates settings --investigate` prints a `--settings` JSON for headless `claude -p` runs and `gstack-skill-gates report --expect armed` checks a session engaged. Off by default; the /investigate skill text is unchanged. Docs: docs/skill-gates.md.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Skill gates: opt-in enforcement hooks
|
||||
|
||||
gstack's advisory skills say what a careful agent does; a gate makes one rule hold. Each gate is a Claude Code hook that is off unless you turn it on, never changes the skill's text, and leaves a countable marker for every decision it makes (GST-1461). They follow the contract hook in [skill-routing-and-contracts.md](skill-routing-and-contracts.md): the same switch order, the same per-session record, the same fail-open rule.
|
||||
|
||||
| Gate | Switch | What it holds |
|
||||
|---|---|---|
|
||||
| /investigate | `investigate_gate` | After /investigate loads, changes to tracked source wait until the session states a root cause |
|
||||
|
||||
## Turn a gate on
|
||||
|
||||
The switch resolves like the contract hook's, and the first one set wins:
|
||||
|
||||
1. The `./setup` flag: `--investigate-gate` / `--no-investigate-gate` (also `--investigate-gate=on|off`). A flag is saved to config.
|
||||
2. The environment: `GSTACK_INVESTIGATE_GATE=on|off`. Not saved.
|
||||
3. Config: `gstack-config set investigate_gate on|off`.
|
||||
4. The default, `SWITCH_DEFAULT` in `lib/skill-route.ts`: off.
|
||||
|
||||
Setup registers the gate's four shims in `~/.claude/settings.json` when the switch is on and removes them when it is off. The hooks also read the switch on every call, so `gstack-config set investigate_gate off` takes effect on the next tool call.
|
||||
|
||||
### Headless runs
|
||||
|
||||
For `claude -p` or an eval harness, skip settings.json:
|
||||
|
||||
gstack-skill-gates settings --investigate > gates.json
|
||||
claude -p "<task>" --settings gates.json
|
||||
|
||||
The JSON registers the hooks from this checkout and sets `GSTACK_INVESTIGATE_GATE=on` through its `env` block. Under the Agent SDK, pass the same JSON as settings or include `user` in `settingSources` after running setup; with neither, or with `--bare`, no hook loads and no `fired` marker appears. Check every session with `gstack-skill-gates report --session <id> --expect armed` ([exit codes](#exit-codes)).
|
||||
|
||||
## The /investigate gate
|
||||
|
||||
Once a session loads /investigate (the Skill tool, a typed `/investigate`, or a Read of its installed SKILL.md), the gate arms on the next Edit, Write, MultiEdit, NotebookEdit or Bash call. From then until the session states a root cause:
|
||||
|
||||
- **Edit, Write, MultiEdit and NotebookEdit** on a tracked source file are denied before they run.
|
||||
- **Bash** can't be judged from its command line (`sed -i`, a redirect, `tee`, `python -c`, `perl -pi`, `node -e`, `git apply`, a heredoc, a build step that rewrites a file). So the gate compares the tree instead. When it arms it pins a baseline: the repository root, HEAD, its refs and stash list, and the content of every tracked file that differs from HEAD. Every later Edit/Write/Bash hook call, every PostToolUse and PostToolUseFailure on Bash, and Stop compare the tree with that baseline under a session lock. A tracked source file whose content changed is put back and the call or stop is blocked with the files named. Because every check uses the same baseline, a write from a background command or a parallel call is caught at the next check whichever call made it.
|
||||
|
||||
A change is not a fix write, and is folded into the baseline, when the file's new content is its blob in HEAD (`git checkout -- f`, `git stash`), in a commit that existed before the baseline (`git checkout <branch>`, `git bisect`), in a commit a remote-tracking ref holds (`git pull`, `git merge origin/main`), or in a stash entry that existed before (`git stash pop`). A commit made after the baseline that touches a gated file is undone with `git reset --soft` to its newest ancestor that existed before (or that a remote-tracking ref holds), so a fix committed on another branch moves that branch back to its own tip. An amend or rebase of commits from before the baseline on the same branch is undone to the baseline HEAD; on a different branch the gate blocks but leaves the commit in place rather than move that branch. The commit stays in the reflog, those index entries are reset, the files are put back, and the block names the commit's other changes still staged. A commit pushed to a remote counts as upstream, so a fix pushed in the same command is not undone.
|
||||
|
||||
**What counts as source.** A file tracked by git that is not a test path (gstack-diff-scope's test rule plus `fixtures/`), a doc (`.md`, `.mdx`, `.rst`, `.txt`, `.adoc`), a lockfile (`bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, `go.sum`, `uv.lock`, `poetry.lock`, `Gemfile.lock` and the like), a `linguist-generated` path, or a tracked symlink. New untracked files are never blocked, so a repro script, a new failing test and temporary logging in a scratch file all work. Changes to excluded files are recorded as `observed`, never reverted.
|
||||
|
||||
**What releases it.** A line in the assistant's own reply, after at least one Read, Grep, Glob or Bash call since the load:
|
||||
|
||||
Root cause: <what is actually wrong, with file:line>
|
||||
|
||||
That is the `Root cause:` label of the skill's DEBUG REPORT. A placeholder (`[what was actually wrong]`, `TBD`) or a hedge (`unknown`, `unclear`, `still investigating`, `see below`, `n/a`) does not count, and neither does the text inside a tool result or a file. The block text names the form, and the rejected value when there was one. After the release the gate does nothing for the rest of that /investigate run; a new load arms it again.
|
||||
|
||||
**Restoring is careful.** The gate never writes through a symlink: it refuses a parent that is not a real directory inside the repository and replaces the path with a rename, so a tracked file swapped for a link to `~/.bashrc` comes back as the file and the link's target is untouched. Files clean at the baseline come back through `git cat-file --filters` (line endings and LFS smudging intact); files dirty at the baseline come back from a raw copy in the session directory. Before a file is put back, the content it replaced is copied into the session directory and the block names that copy, so an edit made in an editor or another session at the same time is never lost. A path that cannot be put back (it is now a directory, the disk is read-only) gives [GATE_RESTORE_FAILED](#gate-restore-failed).
|
||||
|
||||
**Known gaps.** Paths marked assume-unchanged or skip-worktree, untracked files that shadow a tracked module, and ignored directories are outside the check. While the gate is armed it also puts back an edit you make to tracked source in your editor, since it cannot tell who changed the file.
|
||||
|
||||
## Markers
|
||||
|
||||
Every decision is one marker line:
|
||||
|
||||
GSTACK_GATE <hook> <event> session=<session id> [key=value ...]
|
||||
|
||||
| Event | When | Fields |
|
||||
|---|---|---|
|
||||
| `fired` | the gate armed for a skill run (once per run) | `skill`, `on` (the hook event), `root` (the repository folder name) |
|
||||
| `blocked` | each refusal | `skill`, `via=edit|bash`, `files`, `n` (blocks so far in this run) |
|
||||
| `released` | the root cause was stated (once per run) | `skill`, `cause` (first 12 hex of the cause's sha256), `blocked` |
|
||||
| `observed` | an excluded file changed | `skill`, `files` |
|
||||
| `skipped` | the gate could not arm (once per session and reason) | `skill`, `reason=no-git|no-head` |
|
||||
| `error` | a hook error; the call or stop went through | `code`, `why` |
|
||||
|
||||
Where each one lands:
|
||||
|
||||
- **Session record** (the count source): `$GSTACK_STATE_ROOT/skill-gates/<session id>/record.json`, mode 0600, written atomically under the session lock. Per gate and skill run it holds `fired_at`, `released_at`, `cause`, `blocked`, `observed`, plus the gate's `errors` and `skipped` reasons. `gstack-skill-gates report` reads these.
|
||||
- **Trace log**: `$GSTACK_STATE_ROOT/hooks/investigate-gate.log`, one line per marker in the contract hook's shape (`<time> session=<id> event=<event> key=value ...`). It rotates at 1 MiB, so count from the record.
|
||||
- **Transcript**: a block puts the marker on the first line of its reason, which Claude Code shows the model (a denied tool's `tool_result`, or a `hook_blocking_error` attachment for PostToolUse and Stop). `fired`, `released` and `error` ride `systemMessage`, which the transcript records as a `hook_system_message` attachment and the model never sees, so counting them does not change the session.
|
||||
|
||||
The session directory also holds the baseline and the raw copies of files that were dirty when the gate armed. Session directories untouched for 7 days are deleted the next time a gate arms.
|
||||
|
||||
<a id="error-codes"></a>
|
||||
## Errors
|
||||
|
||||
A gate never blocks because it broke. On any error it writes an `error` marker with a code to the record, the trace log, stderr and the transcript (`systemMessage`), and exits 0 with no decision, so the call or stop goes through.
|
||||
|
||||
| Code | Cause | Fix |
|
||||
|---|---|---|
|
||||
| <a id="gate-bad-payload"></a>`GATE_BAD_PAYLOAD` | the hook input is not a JSON object with `session_id` and `transcript_path`, or is over 1 MiB | a Claude Code version change; update gstack |
|
||||
| <a id="gate-transcript"></a>`GATE_TRANSCRIPT` | the transcript is missing, over 32 MiB, or not a shape the parser knows | see [skill-routing-and-contracts.md](skill-routing-and-contracts.md#exit-4-unknown-shape) |
|
||||
| <a id="gate-timeout"></a>`GATE_TIMEOUT` | the hook ran past its 5 second deadline (`GSTACK_SKILL_GATE_DEADLINE_MS` changes it) | a very large dirty tree or a slow disk |
|
||||
| <a id="gate-git"></a>`GATE_GIT` | a git command failed or HEAD stopped resolving | run the named git command in the repository |
|
||||
| <a id="gate-state"></a>`GATE_STATE` | the session record or baseline could not be read or written | make `$GSTACK_STATE_ROOT` writable, or delete `skill-gates/<session id>/` |
|
||||
| <a id="gate-locked"></a>`GATE_LOCKED` | another hook held the session lock for over 3 seconds | transient; a lock older than 30 seconds is taken over |
|
||||
| <a id="gate-restore-failed"></a>`GATE_RESTORE_FAILED` | a changed file could not be put back | the block lists restored and failed files; run `git diff HEAD -- <files>` and fix them by hand |
|
||||
| <a id="gate-unexpected"></a>`GATE_UNEXPECTED` | any other throw | report it with the trace log line |
|
||||
|
||||
## Exit codes
|
||||
|
||||
`gstack-skill-gates report [--session <id>] [--expect armed] [--json]` lists each session's gate counts.
|
||||
|
||||
| Exit | Code | Meaning |
|
||||
|---|---|---|
|
||||
| 0 | | done |
|
||||
| 1 | `SKILL_GATES_USAGE` | a bad argument |
|
||||
| 2 | <a id="gate-not-armed"></a>`GATE_NOT_ARMED` | `--expect armed` and a session has no `fired` marker: the hooks never loaded (see [Headless runs](#headless-runs)) or the skill never ran |
|
||||
| 3 | <a id="gate-errored"></a>`GATE_ERRORED` | a session recorded a hook error, so some calls or stops went through unchecked |
|
||||
|
||||
## Headless smoke
|
||||
|
||||
`bun scripts/skill-gates-smoke.ts` runs the real Claude Code CLI against a local stand-in for the Anthropic Messages API that plays a scripted session: load /investigate, read the file, try an Edit, try `sed -i`, state the root cause, edit. No model is called and nothing is spent. It fetches the pinned CLI (`CLAUDE_CODE_VERSION` in the script, verified at 2.1.318) with npm into a temp directory at run time and never stores it; `--claude <path>` uses one you have. It checks the record, the trace log, the transcript attachments, that `systemMessage` markers never reach a request, and that the `sed -i` write was put back. Run it after a Claude Code upgrade.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Skill routing and skill contracts
|
||||
|
||||
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes.
|
||||
Two Claude Code hooks that help a model actually run gstack's skills (GST-1088). Each has one default, `SWITCH_DEFAULT` in `lib/skill-route.ts`; `gstack-config defaults` prints it. With both off nothing changes. The opt-in gates that hold one skill rule mid-run (the /investigate gate) are in [skill-gates.md](skill-gates.md).
|
||||
|
||||
- **Skill routing** (`skill_routing`, a UserPromptSubmit hook). When a prompt matches an installed skill's `triggers:`, the hook adds one line of context naming that skill, for example "this request fits /investigate. Run /investigate with the Skill tool before you start".
|
||||
- **Skill contracts** (`skill_contracts`, a Stop hook). A skill can declare the outputs that must exist before it is done. When the model tries to stop without them, the hook blocks once and names what is missing.
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /investigate gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/investigate-gate.ts with bun. Registered by
|
||||
# ./setup --investigate-gate; one basename per event so the KNOWN_HOOKS
|
||||
# identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/investigate-gate.ts" || true
|
||||
exit 0
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /investigate gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/investigate-gate.ts with bun. Registered by
|
||||
# ./setup --investigate-gate; one basename per event so the KNOWN_HOOKS
|
||||
# identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/investigate-gate.ts" || true
|
||||
exit 0
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /investigate gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/investigate-gate.ts with bun. Registered by
|
||||
# ./setup --investigate-gate; one basename per event so the KNOWN_HOOKS
|
||||
# identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/investigate-gate.ts" || true
|
||||
exit 0
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bash shim for one Claude Code hook event of the /investigate gate
|
||||
# (GST-1461): Claude Code runs hook commands through /bin/sh, so this wrapper
|
||||
# runs hosts/claude/hooks/investigate-gate.ts with bun. Registered by
|
||||
# ./setup --investigate-gate; one basename per event so the KNOWN_HOOKS
|
||||
# identity table keeps one event per row.
|
||||
#
|
||||
# FAIL-OPEN: every failure path (bun missing, script crash) still exits 0.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0
|
||||
bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/investigate-gate.ts" || true
|
||||
exit 0
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* investigate-gate — opt-in enforcement for /investigate (GST-1461). Off
|
||||
* unless investigate_gate is on (env GSTACK_INVESTIGATE_GATE, then config,
|
||||
* then SWITCH_DEFAULT). Registered per event through four shims:
|
||||
* investigate-gate-pre (PreToolUse Edit|Write|MultiEdit|NotebookEdit|Bash),
|
||||
* investigate-gate-post (PostToolUse Bash), investigate-gate-post-failure
|
||||
* (PostToolUseFailure Bash) and investigate-gate-stop (Stop).
|
||||
*
|
||||
* payload -> transcript -> the last /investigate load (none: nothing)
|
||||
* -> released for this run? nothing
|
||||
* -> first call of the run: pin the baseline, marker fired
|
||||
* -> tree check: a gated path changed -> put back, marker blocked via=bash
|
||||
* (deny at PreToolUse, block at PostToolUse/Stop)
|
||||
* -> PreToolUse edit of tracked gated source -> marker blocked via=edit, deny
|
||||
* -> Root cause line stated -> marker released, everything allowed after
|
||||
*
|
||||
* Every marker is counted in the session record and traced in
|
||||
* $GSTACK_STATE_ROOT/hooks/investigate-gate.log; blocks carry it as the first
|
||||
* line of their reason, the others ride systemMessage. Errors fail open
|
||||
* (lib/skill-gates.ts runGateHook). Docs: docs/skill-gates.md.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { EDIT_TOOLS, armBaseline, checkTree, isGatedTracked, repoRoot, rootCause, type Baseline } from '../../../lib/investigate-gate';
|
||||
import { StateError, UnknownShape, parseTranscript, readTranscriptFile, sameSkill, skillLoads, type Transcript } from '../../../lib/skill-contract';
|
||||
import { readSkillSwitch } from '../../../lib/skill-route';
|
||||
import {
|
||||
GateError, blockWith, denyTool, hookRecord, note, pruneSessionDirs, readGateRecord, recordPath, remainingMs, runGateHook, sessionDir, systemMessage,
|
||||
withSessionLock, writeGateRecord, type GatePayload, type HookOutput,
|
||||
} from '../../../lib/skill-gates';
|
||||
|
||||
const HOOK = 'investigate-gate';
|
||||
const SKILL = 'investigate';
|
||||
|
||||
const SYNC_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Claude Code can fire a tool hook before the turn's earlier blocks reach the
|
||||
* transcript file (the smoke saw a Root cause line land after the Edit's
|
||||
* PreToolUse). The call's own tool_use block is written after them, so wait,
|
||||
* briefly and within the deadline, until the file holds this call's id.
|
||||
*/
|
||||
function readTranscript(file: string, toolUseId: unknown): Transcript {
|
||||
try {
|
||||
let raw = readTranscriptFile(file);
|
||||
if (typeof toolUseId === 'string' && toolUseId) {
|
||||
const needle = JSON.stringify(toolUseId);
|
||||
const until = Date.now() + Math.min(SYNC_WAIT_MS, remainingMs() - 2500);
|
||||
while (!raw.includes(needle) && Date.now() < until) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
||||
raw = readTranscriptFile(file);
|
||||
}
|
||||
}
|
||||
return parseTranscript(raw);
|
||||
} catch (e) {
|
||||
if (e instanceof StateError || e instanceof UnknownShape) throw new GateError('GATE_TRANSCRIPT', e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function howToRelease(rejected?: { value: string; why: string }): string {
|
||||
return [
|
||||
...(rejected ? [`Your line "Root cause: ${rejected.value}" does not count: ${rejected.why}.`] : []),
|
||||
'State the root cause first, in your reply, as one line in this form:',
|
||||
'Root cause: <what is actually wrong, with file:line>',
|
||||
'It counts once you have read the code or run something. Until then you can read code, run commands, and write new untracked files or tests (a repro script, a failing test, temporary logging in a scratch file).',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function handle(p: GatePayload): HookOutput | undefined {
|
||||
const t = readTranscript(p.transcriptPath, p.raw.tool_use_id);
|
||||
const load = skillLoads(t).filter(l => sameSkill(l.skill, SKILL)).pop();
|
||||
if (!load) return undefined;
|
||||
const key = `${SKILL}@${load.line}`;
|
||||
const dir = sessionDir(p.sessionId);
|
||||
const gateDir = path.join(dir, HOOK);
|
||||
const blobs = path.join(gateDir, 'blobs');
|
||||
const basePath = path.join(gateDir, `baseline-${load.line}.json`);
|
||||
return withSessionLock(dir, () => {
|
||||
const file = recordPath(p.sessionId);
|
||||
const rec = readGateRecord(file, p.sessionId);
|
||||
rec.transcript_path = p.transcriptPath;
|
||||
const h = hookRecord(rec, HOOK);
|
||||
let run = h.runs[key];
|
||||
if (run?.released_at) return undefined;
|
||||
const sys: string[] = [];
|
||||
const save = () => writeGateRecord(file, rec);
|
||||
let base: Baseline | null = null;
|
||||
if (!run) {
|
||||
const root = repoRoot(p.cwd);
|
||||
const skip = (reason: string) => {
|
||||
if (h.skipped[reason]) return undefined;
|
||||
h.skipped[reason] = new Date().toISOString();
|
||||
save();
|
||||
return systemMessage([note(HOOK, 'skipped', p.sessionId, { skill: SKILL, reason })]);
|
||||
};
|
||||
if (!root) return skip('no-git');
|
||||
base = armBaseline(root, blobs);
|
||||
if (!base) return skip('no-head');
|
||||
fs.mkdirSync(gateDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(basePath, JSON.stringify(base), { mode: 0o600 });
|
||||
run = h.runs[key] = { skill: SKILL, load_line: load.line, fired_at: new Date().toISOString(), blocked: 0, observed: 0 };
|
||||
save();
|
||||
sys.push(note(HOOK, 'fired', p.sessionId, { skill: SKILL, on: p.event, root: path.basename(root) }));
|
||||
pruneSessionDirs(dir);
|
||||
} else {
|
||||
try { base = JSON.parse(fs.readFileSync(basePath, 'utf8')) as Baseline; } catch (e) { throw new GateError('GATE_STATE', `cannot read ${basePath} (${(e as Error).message})`); }
|
||||
}
|
||||
const check = checkTree(base!, blobs);
|
||||
fs.writeFileSync(basePath, JSON.stringify(check.baseline), { mode: 0o600 });
|
||||
if (check.observed.length) {
|
||||
run.observed += check.observed.length;
|
||||
save();
|
||||
note(HOOK, 'observed', p.sessionId, { skill: SKILL, files: check.observed.join(',') });
|
||||
}
|
||||
const block = (via: string, files: string[], body: string): HookOutput => {
|
||||
run!.blocked++;
|
||||
save();
|
||||
const marker = note(HOOK, 'blocked', p.sessionId, { skill: SKILL, via, files: files.join(','), n: run!.blocked });
|
||||
const reason = `${marker}\n${body}`;
|
||||
return p.event === 'PreToolUse' ? denyTool(reason, sys) : blockWith(reason, sys);
|
||||
};
|
||||
if (check.violations.length) {
|
||||
if (check.failed.length) {
|
||||
const why = check.failed.map(f => `${f.path}: ${f.why}`).join('; ');
|
||||
h.errors.push({ at: new Date().toISOString(), code: 'GATE_RESTORE_FAILED', why });
|
||||
sys.push(note(HOOK, 'error', p.sessionId, { code: 'GATE_RESTORE_FAILED', why }));
|
||||
}
|
||||
const failed = check.failed.map(f => f.path);
|
||||
const body = [
|
||||
`This session loaded /investigate and has not stated a root cause, so changes to tracked source are not allowed yet. A command changed ${check.violations.join(', ')}.`,
|
||||
...(check.restored.length ? [`The gate put these back to how they were: ${check.restored.join(', ')}.`] : []),
|
||||
...(check.saved.length ? [`The content it replaced is saved: ${check.saved.map(s => `${s.path} at ${s.file}`).join(', ')}.`] : []),
|
||||
...(check.reset ? [`It also moved the branch back to ${check.reset.slice(0, 12)}; the new commit stays in the reflog.`] : []),
|
||||
...(check.staged.length ? [`The commit's other changes are still staged: ${check.staged.join(', ')}.`] : []),
|
||||
...(check.kept.length ? [`The new commit rewrote history from before /investigate loaded, on a different branch, so the gate did not undo it: ${check.kept.join(', ')} stay committed.`] : []),
|
||||
...(failed.length ? [`These could not be put back and are in an unknown state: ${failed.join(', ')}. Run git diff HEAD -- ${failed.join(' ')} to see them.`] : []),
|
||||
howToRelease(),
|
||||
].join('\n');
|
||||
return block('bash', check.violations, body);
|
||||
}
|
||||
const cause = rootCause(t, load);
|
||||
if (cause.stated) {
|
||||
run.released_at = new Date().toISOString();
|
||||
run.cause = cause.cause;
|
||||
save();
|
||||
sys.push(note(HOOK, 'released', p.sessionId, { skill: SKILL, cause: cause.cause!, blocked: run.blocked }));
|
||||
return systemMessage(sys);
|
||||
}
|
||||
if (p.event === 'PreToolUse' && EDIT_TOOLS.test(String(p.raw.tool_name ?? ''))) {
|
||||
const input = (p.raw.tool_input ?? {}) as Record<string, unknown>;
|
||||
const target = String(input.file_path ?? input.notebook_path ?? '');
|
||||
if (target) {
|
||||
const abs = path.resolve(p.cwd, target);
|
||||
const rel = path.relative(base!.root, fs.existsSync(path.dirname(abs)) ? path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)) : abs).split(path.sep).join('/');
|
||||
if (!rel.startsWith('..') && !path.isAbsolute(rel) && isGatedTracked(base!.root, rel)) {
|
||||
return block('edit', [rel], `This session loaded /investigate and has not stated a root cause, so edits to tracked source (${rel}) are blocked.\n${howToRelease(cause.rejected)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return systemMessage(sys);
|
||||
});
|
||||
}
|
||||
|
||||
runGateHook(HOOK, () => readSkillSwitch('investigate_gate').on, handle).finally(() => process.exit(0));
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* investigate-gate — the tree check and release rule behind the /investigate
|
||||
* enforcement hook (GST-1461). Docs: docs/skill-gates.md.
|
||||
*
|
||||
* When the gate arms it pins a baseline: the repo root, HEAD, the refs and
|
||||
* stash list, and the content of every tracked path that differs from HEAD
|
||||
* (raw bytes of gated files copied into the session dir). Every later check
|
||||
* compares the tree with that one baseline, so a write made by a background
|
||||
* command or a parallel call is caught at the next hook call whichever call
|
||||
* made it. A gated path whose content moved away from the baseline is a fix
|
||||
* write and is put back, unless the new content is the path's blob in HEAD
|
||||
* (a revert), in a commit that existed before the baseline (checkout, bisect)
|
||||
* or in a stash entry that existed before (stash pop). A commit made after
|
||||
* the baseline that touches a gated path is undone with reset --soft.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isPlaceholder, labelValue, type SkillLoad, type Transcript } from './skill-contract';
|
||||
import { GateError, remainingMs } from './skill-gates';
|
||||
|
||||
const GIT_TIMEOUT_MS = 3000;
|
||||
const LOCKFILES = new Set(['bun.lock', 'bun.lockb', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock', 'go.sum', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Gemfile.lock', 'composer.lock', 'mix.lock', 'pubspec.lock', 'Podfile.lock', 'flake.lock']);
|
||||
const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
|
||||
/** lib/review-depth.ts's TEST_PATH (gstack-diff-scope's TEST_PATH_RE plus fixture directories), copied because that file's bytes are pinned to a no-loss replay. */
|
||||
const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$/, /(^|\/)fixtures?\//];
|
||||
const HEDGE = /^(?:unknown|unclear|not (?:sure|known|yet known|clear)|undetermined|investigating|still investigating|to be determined|pending|see below|see above|n\/?a|none|tbd\b.*)$/i;
|
||||
const EVIDENCE_TOOLS = /^(?:Read|Grep|Glob|Bash|LS|NotebookRead|WebFetch)$/;
|
||||
export const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/;
|
||||
|
||||
function git(root: string, args: string[], opts: { input?: string; allowFail?: boolean } = {}): { ok: boolean; out: Buffer } {
|
||||
const r = spawnSync('git', args, { cwd: root, input: opts.input, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, timeout: Math.min(GIT_TIMEOUT_MS, remainingMs()), maxBuffer: 64 * 1024 * 1024 });
|
||||
const ok = r.status === 0;
|
||||
if (!ok && !opts.allowFail) {
|
||||
const why = r.error ? r.error.message : String(r.stderr ?? '').trim().split('\n')[0];
|
||||
throw new GateError('GATE_GIT', `git ${args[0]} failed: ${why}`);
|
||||
}
|
||||
return { ok, out: (r.stdout as Buffer) ?? Buffer.alloc(0) };
|
||||
}
|
||||
const text = (b: Buffer) => b.toString('utf8');
|
||||
const nulList = (b: Buffer) => text(b).split('\0').filter(Boolean);
|
||||
|
||||
export function repoRoot(cwd: string): string | null {
|
||||
if (!fs.existsSync(cwd)) return null;
|
||||
const r = git(cwd, ['rev-parse', '--show-toplevel'], { allowFail: true });
|
||||
return r.ok ? fs.realpathSync(text(r.out).trim()) : null;
|
||||
}
|
||||
const headOf = (root: string) => { const r = git(root, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'], { allowFail: true }); return r.ok ? text(r.out).trim() : null; };
|
||||
const branchOf = (root: string) => text(git(root, ['symbolic-ref', '-q', 'HEAD'], { allowFail: true }).out).trim();
|
||||
const changedVsHead = (root: string) => nulList(git(root, ['diff', 'HEAD', '--name-only', '-z', '--no-renames']).out);
|
||||
|
||||
interface TreeEntry { mode: string; oid: string }
|
||||
function treeEntries(root: string, rev: string, paths: string[]): Map<string, TreeEntry> {
|
||||
const out = new Map<string, TreeEntry>();
|
||||
for (let i = 0; i < paths.length; i += 500) {
|
||||
for (const rec of nulList(git(root, ['ls-tree', '-z', rev, '--', ...paths.slice(i, i + 500)]).out)) {
|
||||
const m = /^(\d+) (\w+) ([0-9a-f]+)\t(.*)$/s.exec(rec);
|
||||
if (m && m[2] === 'blob') out.set(m[4], { mode: m[1], oid: m[3] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function generatedPaths(root: string, paths: string[]): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i < paths.length; i += 500) {
|
||||
const f = nulList(git(root, ['check-attr', '-z', 'linguist-generated', '--', ...paths.slice(i, i + 500)]).out);
|
||||
for (let j = 0; j + 2 < f.length + 1; j += 3) if (f[j + 2] === 'set' || f[j + 2] === 'true') out.add(f[j]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The content id of a worktree path: its git blob id (filters applied), symlink:<target>, dir, or null when absent. */
|
||||
function worktreeIds(root: string, paths: string[]): Map<string, string | null> {
|
||||
const ids = new Map<string, string | null>();
|
||||
const files: string[] = [];
|
||||
for (const p of paths) {
|
||||
let st: fs.Stats;
|
||||
try { st = fs.lstatSync(path.join(root, p)); } catch { ids.set(p, null); continue; }
|
||||
if (st.isSymbolicLink()) ids.set(p, `symlink:${fs.readlinkSync(path.join(root, p))}`);
|
||||
else if (st.isDirectory()) ids.set(p, 'dir');
|
||||
else if (p.includes('\n')) ids.set(p, 'unhashable');
|
||||
else files.push(p);
|
||||
}
|
||||
if (files.length) {
|
||||
const oids = text(git(root, ['hash-object', '--stdin-paths'], { input: `${files.join('\n')}\n` }).out).trim().split('\n');
|
||||
files.forEach((p, i) => ids.set(p, oids[i] ?? null));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export type ExcludedReason = 'test' | 'doc' | 'lockfile' | 'generated' | 'symlink';
|
||||
export function excludedReason(rel: string, mode?: string, generated = false): ExcludedReason | null {
|
||||
if (TEST_PATH.some(r => r.test(rel))) return 'test';
|
||||
if (DOC_PATH.test(rel)) return 'doc';
|
||||
if (LOCKFILES.has(path.posix.basename(rel))) return 'lockfile';
|
||||
if (generated) return 'generated';
|
||||
if (mode === '120000') return 'symlink';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a repo-relative path is tracked (in HEAD or the index) and gated. */
|
||||
export function isGatedTracked(root: string, rel: string): boolean {
|
||||
const ls = text(git(root, ['ls-files', '-s', '-z', '--', rel]).out).split('\0').filter(Boolean);
|
||||
const entry = ls.find(l => l.endsWith(`\t${rel}`));
|
||||
if (!entry) return false;
|
||||
return excludedReason(rel, entry.split(' ')[0], generatedPaths(root, [rel]).has(rel)) === null;
|
||||
}
|
||||
|
||||
interface Entry { id: string | null; excluded: boolean; mode: number }
|
||||
export interface Baseline { version: 1; root: string; head: string; branch?: string; taken_at: number; refs: string[]; stashes: string[]; entries: Record<string, Entry> }
|
||||
|
||||
function snapshot(root: string, blobs: string, head: string): Baseline {
|
||||
const changed = changedVsHead(root);
|
||||
const ids = worktreeIds(root, changed);
|
||||
const tree = changed.length ? treeEntries(root, head, changed) : new Map<string, TreeEntry>();
|
||||
const generated = changed.length ? generatedPaths(root, changed) : new Set<string>();
|
||||
const entries: Record<string, Entry> = {};
|
||||
for (const p of changed) {
|
||||
const id = ids.get(p) ?? null;
|
||||
const abs = path.join(root, p);
|
||||
const excluded = excludedReason(p, tree.get(p)?.mode, generated.has(p)) !== null;
|
||||
let mode = 0o644;
|
||||
if (id && /^[0-9a-f]+$/.test(id)) {
|
||||
mode = fs.statSync(abs).mode & 0o777;
|
||||
if (!excluded && !fs.existsSync(path.join(blobs, id))) {
|
||||
fs.mkdirSync(blobs, { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(abs, path.join(blobs, id));
|
||||
fs.chmodSync(path.join(blobs, id), 0o600);
|
||||
}
|
||||
}
|
||||
entries[p] = { id, excluded, mode };
|
||||
}
|
||||
const refs = nulList(git(root, ['for-each-ref', '--format=%(objectname)%00']).out).map(s => s.trim()).filter(Boolean);
|
||||
const stashes = text(git(root, ['log', '-g', '--format=%H', 'refs/stash'], { allowFail: true }).out).split('\n').filter(Boolean);
|
||||
return { version: 1, root, head, branch: branchOf(root), taken_at: Date.now(), refs: [...new Set([head, ...refs])], stashes, entries };
|
||||
}
|
||||
|
||||
export function armBaseline(root: string, blobs: string): Baseline | null {
|
||||
const head = headOf(root);
|
||||
return head ? snapshot(root, blobs, head) : null;
|
||||
}
|
||||
|
||||
function existedBefore(root: string, base: Baseline, commit: string): boolean {
|
||||
const unseen = git(root, ['rev-list', '-n', '1', '--stdin'], { input: `${commit}\n${base.refs.map(r => `^${r}`).join('\n')}\n`, allowFail: true });
|
||||
if (unseen.ok ? !text(unseen.out).trim() : base.refs.some(r => git(root, ['merge-base', '--is-ancestor', commit, r], { allowFail: true }).ok)) return true;
|
||||
const ct = Number(text(git(root, ['show', '-s', '--format=%ct', commit]).out).trim());
|
||||
return ct < Math.floor(base.taken_at / 1000);
|
||||
}
|
||||
|
||||
/** Whether a remote-tracking ref holds the commit (a fetched upstream commit a merge brings in). */
|
||||
const onRemote = (root: string, commit: string) => text(git(root, ['for-each-ref', '--count=1', '--contains', commit, 'refs/remotes'], { allowFail: true }).out).trim() !== '';
|
||||
|
||||
function inStash(root: string, base: Baseline, rel: string, id: string): boolean {
|
||||
return base.stashes.some(s => {
|
||||
const r = git(root, ['rev-parse', '-q', '--verify', `${s}:${rel}`], { allowFail: true });
|
||||
return r.ok && text(r.out).trim() === id;
|
||||
});
|
||||
}
|
||||
|
||||
/** Write bytes (or remove the path) without following a symlink or leaving root. */
|
||||
function safeRestore(root: string, rel: string, content: Buffer | null, mode: number, symlink?: string): void {
|
||||
const parts = rel.split('/');
|
||||
let dir = root;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
dir = path.join(dir, part);
|
||||
let st: fs.Stats | null = null;
|
||||
try { st = fs.lstatSync(dir); } catch { /* missing */ }
|
||||
if (!st) fs.mkdirSync(dir, { mode: 0o755 });
|
||||
else if (!st.isDirectory()) throw new Error(`${path.relative(root, dir)} is not a directory inside the repository`);
|
||||
}
|
||||
const abs = path.join(root, rel);
|
||||
let st: fs.Stats | null = null;
|
||||
try { st = fs.lstatSync(abs); } catch { /* absent */ }
|
||||
if (st?.isDirectory()) throw new Error(`${rel} is now a directory`);
|
||||
if (content === null && symlink === undefined) { if (st) fs.unlinkSync(abs); return; }
|
||||
const tmp = `${abs}.gstack-restore-${process.pid}`;
|
||||
fs.rmSync(tmp, { force: true });
|
||||
if (symlink !== undefined) fs.symlinkSync(symlink, tmp);
|
||||
else fs.writeFileSync(tmp, content!, { mode });
|
||||
fs.renameSync(tmp, abs);
|
||||
}
|
||||
|
||||
export interface CheckResult { baseline: Baseline; violations: string[]; observed: string[]; restored: string[]; failed: Array<{ path: string; why: string }>; reset: string | null; staged: string[]; kept: string[]; saved: Array<{ path: string; file: string }> }
|
||||
|
||||
/** Compare the tree with the baseline, put back fix writes, and return the next baseline. */
|
||||
export function checkTree(base: Baseline, blobs: string): CheckResult {
|
||||
const root = base.root;
|
||||
const head = headOf(root);
|
||||
if (!head) throw new GateError('GATE_GIT', 'HEAD no longer resolves');
|
||||
const result: CheckResult = { baseline: base, violations: [], observed: [], restored: [], failed: [], reset: null, staged: [], kept: [], saved: [] };
|
||||
let target = head;
|
||||
const merged: string[] = [];
|
||||
const upstream = (c: string) => existedBefore(root, base, c) || onRemote(root, c);
|
||||
if (head !== base.head) {
|
||||
while (!upstream(target)) {
|
||||
const parents = text(git(root, ['rev-list', '--parents', '-n', '1', target]).out).trim().split(' ').slice(1);
|
||||
if (!parents.length) throw new GateError('GATE_GIT', `no commit under ${head.slice(0, 12)} existed before the baseline`);
|
||||
merged.push(...parents.slice(1).filter(upstream));
|
||||
target = parents[0];
|
||||
}
|
||||
}
|
||||
let rewrote = false;
|
||||
if (target !== head && target !== base.head && git(root, ['merge-base', '--is-ancestor', target, base.head], { allowFail: true }).ok) {
|
||||
if (branchOf(root) === base.branch) target = base.head;
|
||||
else rewrote = true;
|
||||
}
|
||||
const newCommit = target !== head;
|
||||
const moved = target !== base.head;
|
||||
const committed = newCommit ? nulList(git(root, ['diff', '--name-only', '-z', '--no-renames', target, head]).out) : [];
|
||||
const cand = [...new Set([...changedVsHead(root), ...Object.keys(base.entries), ...committed])];
|
||||
if (!cand.length && head === base.head) return result;
|
||||
const ids = worktreeIds(root, cand);
|
||||
const baseTree = treeEntries(root, base.head, cand);
|
||||
const refTree = moved ? treeEntries(root, target, cand) : baseTree;
|
||||
const headTree = newCommit ? treeEntries(root, head, cand) : refTree;
|
||||
const mergedTrees = merged.map(m => treeEntries(root, m, cand));
|
||||
const generated = generatedPaths(root, cand);
|
||||
const fix = new Set<string>();
|
||||
for (const p of cand) {
|
||||
const baseEntry = base.entries[p];
|
||||
const want = baseEntry ? baseEntry.id : refTree.get(p)?.oid;
|
||||
if (want === undefined) continue;
|
||||
const known = (id: string | null) => id === want || (id !== null && (id === baseTree.get(p)?.oid || id === refTree.get(p)?.oid || mergedTrees.some(t => t.get(p)?.oid === id)));
|
||||
const curId = ids.get(p) ?? null;
|
||||
const committedFix = committed.includes(p) && !known(headTree.get(p)?.oid ?? null);
|
||||
if (!committedFix && (known(curId) || (curId !== null && inStash(root, base, p, curId)))) continue;
|
||||
if (excludedReason(p, baseTree.get(p)?.mode ?? refTree.get(p)?.mode ?? headTree.get(p)?.mode, generated.has(p)) !== null) { result.observed.push(p); continue; }
|
||||
fix.add(p);
|
||||
}
|
||||
result.violations = [...fix].sort();
|
||||
if (rewrote) result.kept = committed.filter(p => fix.has(p));
|
||||
else if (newCommit && committed.some(p => fix.has(p))) {
|
||||
git(root, ['reset', '-q', '--soft', target]);
|
||||
git(root, ['reset', '-q', target, '--', ...committed.filter(p => fix.has(p))], { allowFail: true });
|
||||
result.reset = target;
|
||||
result.staged = committed.filter(p => !fix.has(p));
|
||||
}
|
||||
for (const p of result.violations) {
|
||||
const baseEntry = base.entries[p];
|
||||
const curId = ids.get(p) ?? null;
|
||||
if (result.kept.includes(p) || curId === (baseEntry ? baseEntry.id : refTree.get(p)!.oid)) continue;
|
||||
try {
|
||||
if (curId && /^[0-9a-f]+$/.test(curId)) {
|
||||
const keep = path.join(blobs, curId);
|
||||
if (!fs.existsSync(keep)) {
|
||||
fs.mkdirSync(blobs, { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(path.join(root, p), keep);
|
||||
fs.chmodSync(keep, 0o600);
|
||||
}
|
||||
result.saved.push({ path: p, file: keep });
|
||||
}
|
||||
if (baseEntry) {
|
||||
if (baseEntry.id === null) safeRestore(root, p, null, 0);
|
||||
else if (baseEntry.id.startsWith('symlink:')) safeRestore(root, p, null, 0, baseEntry.id.slice(8));
|
||||
else safeRestore(root, p, fs.readFileSync(path.join(blobs, baseEntry.id)), baseEntry.mode);
|
||||
} else {
|
||||
safeRestore(root, p, git(root, ['cat-file', '--filters', `${target}:${p}`]).out, refTree.get(p)!.mode === '100755' ? 0o755 : 0o644);
|
||||
}
|
||||
result.restored.push(p);
|
||||
} catch (e) {
|
||||
result.failed.push({ path: p, why: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
result.baseline = snapshot(root, blobs, headOf(root) ?? base.head);
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface RootCause { stated: boolean; cause?: string; rejected?: { value: string; why: string } }
|
||||
|
||||
/** A \`Root cause: <value>\` line in assistant text after the load and after an evidence tool call, not a placeholder or hedge. */
|
||||
export function rootCause(t: Transcript, load: SkillLoad): RootCause {
|
||||
let evidence = false;
|
||||
let rejected: RootCause['rejected'];
|
||||
for (const e of t.events.slice(load.at + 1)) {
|
||||
if (e.kind === 'tool_use' && EVIDENCE_TOOLS.test(e.name)) evidence = true;
|
||||
if (e.kind !== 'text') continue;
|
||||
const value = labelValue(e.text, 'Root cause');
|
||||
if (value === null) continue;
|
||||
if (isPlaceholder(value) || HEDGE.test(value.replace(/[.!]+$/, '').trim())) { rejected = { value, why: 'it is a placeholder or a hedge, not a cause' }; continue; }
|
||||
if (!evidence) { rejected = { value, why: 'it came before you read code or ran anything' }; continue; }
|
||||
return { stated: true, cause: createHash('sha256').update(value).digest('hex').slice(0, 12) };
|
||||
}
|
||||
return { stated: false, ...(rejected ? { rejected } : {}) };
|
||||
}
|
||||
@@ -338,7 +338,7 @@ const bashCommand = (e: TranscriptEvent) => (isToolUse(e) && e.name === 'Bash' ?
|
||||
const agentText = (e: ToolUse) => [e.input.description, e.input.prompt, e.input.subagent_type, e.input.name].filter(v => typeof v === 'string').join('\n');
|
||||
const cut = (s: string, n = 80) => (s.length > n ? `${s.slice(0, n - 3)}...` : s).replace(/\s+/g, ' ');
|
||||
|
||||
function labelValue(text: string, label: string): string | null {
|
||||
export function labelValue(text: string, label: string): string | null {
|
||||
const re = new RegExp(`^[ \\t>*_|-]*(?:\\*\\*|__)?${escapeRe(label)}[ \\t]*(?:\\*\\*|__)?[ \\t]*:[ \\t]*(?:\\*\\*|__)?(.*)$`, 'im');
|
||||
const m = re.exec(text);
|
||||
return m ? m[1].replace(/(?:\*\*|__)\s*$/, '').replace(/^`+|`+$/g, '').trim() : null;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* skill-gates — what the opt-in enforcement hooks share (GST-1461): the
|
||||
* GSTACK_GATE marker line, the per-session record that counts markers, the
|
||||
* session lock, the hook output shapes and the fail-open runner.
|
||||
*
|
||||
* marker GSTACK_GATE <hook> <event> session=<sid> [k=v ...]
|
||||
* record $GSTACK_STATE_ROOT/skill-gates/<session>/record.json (0600, atomic)
|
||||
* trace $GSTACK_STATE_ROOT/hooks/<hook>.log (one line per marker)
|
||||
*
|
||||
* A hook error never blocks and never goes unrecorded: runGateHook catches
|
||||
* every throw and its own deadline, writes an \`error code=<CODE>\` marker to the
|
||||
* record, the trace, stderr and the transcript (systemMessage), and exits 0.
|
||||
* Docs: docs/skill-gates.md.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { atomicWriteSync } from './fs-atomic';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
import { appendHookTrace, readStdinBounded, type TraceHook } from './skill-route';
|
||||
|
||||
export type GateHook = 'investigate-gate';
|
||||
export type GateEvent = 'fired' | 'blocked' | 'released' | 'observed' | 'exhausted' | 'skipped' | 'error';
|
||||
export const GATE_DOC = 'docs/skill-gates.md';
|
||||
export const DEADLINE_MS = 5000;
|
||||
const STDIN_CAP_BYTES = 1024 * 1024;
|
||||
const STDIN_DEADLINE_MS = 500;
|
||||
const LOCK_WAIT_MS = 3000;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const SESSION_DIRS_KEPT_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let deadlineAt = Number.POSITIVE_INFINITY;
|
||||
let deadlineMs = DEADLINE_MS;
|
||||
/** Throw GATE_TIMEOUT once the hook's deadline has passed; returns the milliseconds left. Sync work (git calls) checks this, since the timer cannot fire during it. */
|
||||
export function remainingMs(): number {
|
||||
const left = deadlineAt - Date.now();
|
||||
if (left <= 0) throw new GateError('GATE_TIMEOUT', `timed out after ${deadlineMs} ms`);
|
||||
return left;
|
||||
}
|
||||
|
||||
/** A failure with a code from docs/skill-gates.md#error-codes. */
|
||||
export class GateError extends Error {
|
||||
constructor(readonly code: string, why: string) { super(why); }
|
||||
}
|
||||
|
||||
export const cleanSessionId = (sid: string) => sid.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'unknown';
|
||||
const markerValue = (v: string | number) => String(v).replace(/\s+/g, '_').slice(0, 240);
|
||||
|
||||
export function gateMarker(hook: GateHook, event: GateEvent, sessionId: string, fields: Record<string, string | number> = {}): string {
|
||||
return ['GSTACK_GATE', hook, event, `session=${cleanSessionId(sessionId)}`, ...Object.entries(fields).map(([k, v]) => `${k}=${markerValue(v)}`)].join(' ');
|
||||
}
|
||||
|
||||
export interface GateRun {
|
||||
skill: string;
|
||||
load_line: number;
|
||||
fired_at: string;
|
||||
released_at?: string;
|
||||
cause?: string;
|
||||
blocked: number;
|
||||
observed: number;
|
||||
exhausted_at?: string;
|
||||
}
|
||||
export interface GateHookRecord { runs: Record<string, GateRun>; errors: Array<{ at: string; code: string; why: string }>; skipped: Record<string, string> }
|
||||
export interface GateRecord { version: 1; session_id: string; transcript_path: string; hooks: Partial<Record<GateHook, GateHookRecord>> }
|
||||
|
||||
export function gatesRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateRoot(env), 'skill-gates');
|
||||
}
|
||||
export function sessionDir(sessionId: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(gatesRoot(env), cleanSessionId(sessionId));
|
||||
}
|
||||
export const recordPath = (sessionId: string, env: NodeJS.ProcessEnv = process.env) => path.join(sessionDir(sessionId, env), 'record.json');
|
||||
|
||||
export function readGateRecord(file: string, sessionId: string): GateRecord {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, session_id: sessionId, transcript_path: '', hooks: {} };
|
||||
throw new GateError('GATE_STATE', `cannot read ${file} (${(e as Error).message})`);
|
||||
}
|
||||
try {
|
||||
const rec = JSON.parse(text);
|
||||
if (rec?.version !== 1 || typeof rec.hooks !== 'object' || rec.hooks === null) throw new Error('not a version 1 record');
|
||||
return rec as GateRecord;
|
||||
} catch (e) {
|
||||
throw new GateError('GATE_STATE', `corrupt record ${file} (${(e as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGateRecord(file: string, rec: GateRecord): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
atomicWriteSync(file, `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw new GateError('GATE_STATE', `cannot write ${file} (${(e as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function hookRecord(rec: GateRecord, hook: GateHook): GateHookRecord {
|
||||
const h = rec.hooks[hook] ?? { runs: {}, errors: [], skipped: {} };
|
||||
h.skipped ??= {};
|
||||
rec.hooks[hook] = h;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Run fn holding the session's O_EXCL lock; a lock older than LOCK_STALE_MS (a killed hook) is taken over. */
|
||||
export function withSessionLock<T>(dir: string, fn: () => T): T {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const lock = path.join(dir, 'lock');
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
fs.closeSync(fs.openSync(lock, 'wx', 0o600));
|
||||
break;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw new GateError('GATE_STATE', `cannot take ${lock} (${(e as Error).message})`);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) { fs.rmSync(lock, { force: true }); continue; }
|
||||
} catch { continue; }
|
||||
if (Date.now() - start > LOCK_WAIT_MS) throw new GateError('GATE_LOCKED', `${lock} held for over ${LOCK_WAIT_MS} ms`);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.rmSync(lock, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete session dirs untouched for SESSION_DIRS_KEPT_MS (they hold copies of dirty source files). Best effort. */
|
||||
export function pruneSessionDirs(keep: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
try {
|
||||
const root = gatesRoot(env);
|
||||
for (const name of fs.readdirSync(root)) {
|
||||
const dir = path.join(root, name);
|
||||
if (dir === keep) continue;
|
||||
try {
|
||||
if (Date.now() - fs.statSync(dir).mtimeMs > SESSION_DIRS_KEPT_MS) fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { /* raced with another session */ }
|
||||
}
|
||||
} catch { /* no root yet */ }
|
||||
}
|
||||
|
||||
/** One marker: a trace line, and the marker text for the transcript. */
|
||||
export function note(hook: GateHook, event: GateEvent, sessionId: string, fields: Record<string, string | number> = {}): string {
|
||||
appendHookTrace(hook as TraceHook, sessionId, { event, ...fields }, event === 'error');
|
||||
return gateMarker(hook, event, sessionId, fields);
|
||||
}
|
||||
|
||||
export type HookOutput = Record<string, unknown>;
|
||||
export const systemMessage = (lines: string[]): HookOutput | undefined => (lines.length ? { systemMessage: lines.join('\n') } : undefined);
|
||||
export const denyTool = (reason: string, extra: string[] = []): HookOutput =>
|
||||
({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, ...(extra.length ? { systemMessage: extra.join('\n') } : {}) });
|
||||
export const blockWith = (reason: string, extra: string[] = []): HookOutput => ({ decision: 'block', reason, ...(extra.length ? { systemMessage: extra.join('\n') } : {}) });
|
||||
|
||||
export interface GatePayload { raw: Record<string, unknown>; sessionId: string; transcriptPath: string; event: string; cwd: string }
|
||||
|
||||
/**
|
||||
* The hook entry point: switch check, bounded stdin, payload shape, deadline,
|
||||
* then handler. Any GateError or throw becomes an error marker and exit 0.
|
||||
*/
|
||||
export async function runGateHook(hook: GateHook, isOn: () => boolean, handler: (p: GatePayload) => HookOutput | undefined): Promise<void> {
|
||||
let sessionId = 'unknown';
|
||||
let done = false;
|
||||
const fail = (code: string, why: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const marker = note(hook, 'error', sessionId, { code, why });
|
||||
try {
|
||||
if (sessionId !== 'unknown') {
|
||||
const file = recordPath(sessionId);
|
||||
withSessionLock(path.dirname(file), () => {
|
||||
const rec = readGateRecord(file, sessionId);
|
||||
hookRecord(rec, hook).errors.push({ at: new Date().toISOString(), code, why });
|
||||
writeGateRecord(file, rec);
|
||||
});
|
||||
}
|
||||
} catch { /* the trace and stderr still carry it */ }
|
||||
process.stderr.write(`${marker} (stop and edits allowed; see ${GATE_DOC}#${code.toLowerCase().replace(/_/g, '-')})\n`);
|
||||
process.stdout.write(JSON.stringify({ systemMessage: marker }));
|
||||
};
|
||||
deadlineMs = Number(process.env.GSTACK_SKILL_GATE_DEADLINE_MS) || DEADLINE_MS;
|
||||
deadlineAt = Date.now() + deadlineMs;
|
||||
const timer = setTimeout(() => { fail('GATE_TIMEOUT', `timed out after ${deadlineMs} ms`); process.exit(0); }, deadlineMs);
|
||||
try {
|
||||
if (!isOn()) return;
|
||||
const stdin = await readStdinBounded(STDIN_CAP_BYTES, STDIN_DEADLINE_MS);
|
||||
if (stdin.overCap) throw new GateError('GATE_BAD_PAYLOAD', `hook payload over ${STDIN_CAP_BYTES} bytes`);
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(stdin.text); } catch { throw new GateError('GATE_BAD_PAYLOAD', 'hook payload is not JSON'); }
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new GateError('GATE_BAD_PAYLOAD', 'hook payload is not a JSON object');
|
||||
const p = raw as Record<string, unknown>;
|
||||
if (typeof p.session_id !== 'string' || !p.session_id) throw new GateError('GATE_BAD_PAYLOAD', 'payload has no session_id');
|
||||
sessionId = p.session_id;
|
||||
if (typeof p.transcript_path !== 'string' || !p.transcript_path) throw new GateError('GATE_BAD_PAYLOAD', 'payload has no transcript_path');
|
||||
const out = handler({ raw: p, sessionId, transcriptPath: p.transcript_path, event: String(p.hook_event_name ?? ''), cwd: typeof p.cwd === 'string' && p.cwd ? p.cwd : process.cwd() });
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (out) await new Promise<void>(resolve => process.stdout.write(JSON.stringify(out), () => resolve()));
|
||||
} catch (e) {
|
||||
if (e instanceof GateError) fail(e.code, e.message);
|
||||
else fail('GATE_UNEXPECTED', `unexpected error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -28,16 +28,18 @@ const MAX_TRIGGER_CHARS = 200;
|
||||
const TRACE_MAX_BYTES = 1024 * 1024;
|
||||
const FAILURE_SESSIONS_KEPT = 64;
|
||||
|
||||
export type SwitchName = 'skill_routing' | 'skill_contracts';
|
||||
export type SwitchName = 'skill_routing' | 'skill_contracts' | 'investigate_gate';
|
||||
export const SWITCH_ENV: Record<SwitchName, string> = {
|
||||
skill_routing: 'GSTACK_SKILL_ROUTING',
|
||||
skill_contracts: 'GSTACK_SKILL_CONTRACTS',
|
||||
investigate_gate: 'GSTACK_INVESTIGATE_GATE',
|
||||
};
|
||||
|
||||
/** Each switch's one default. setup, gstack-config and gstack-skill-route read it through `gstack-skill-route --default <switch>`; the hooks read it here. */
|
||||
export const SWITCH_DEFAULT: Record<SwitchName, 'on' | 'off'> = {
|
||||
skill_routing: 'off',
|
||||
skill_contracts: 'off',
|
||||
investigate_gate: 'off',
|
||||
};
|
||||
|
||||
export function normalizeSwitch(raw: string | null | undefined): 'on' | 'off' | null {
|
||||
@@ -278,7 +280,7 @@ export function readStdinBounded(capBytes: number, ms: number, stream: NodeJS.Re
|
||||
});
|
||||
}
|
||||
|
||||
export type TraceHook = 'skill-route' | 'skill-contract';
|
||||
export type TraceHook = 'skill-route' | 'skill-contract' | 'investigate-gate';
|
||||
|
||||
export function traceLogPath(hook: TraceHook, env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateRoot(env), 'hooks', `${hook}.log`);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* skill-gates-smoke — the $0 headless smoke for the opt-in enforcement hooks
|
||||
* (GST-1461). It runs the real Claude Code CLI (\`claude -p\`, unattended, with
|
||||
* the hooks registered through \`gstack-skill-gates settings\`) against a local
|
||||
* stand-in for the Anthropic Messages API that plays a scripted session, so
|
||||
* no model is called and nothing is spent. Then it checks the GSTACK_GATE
|
||||
* markers in the session record, the hook log and the transcript JSONL.
|
||||
*
|
||||
* bun scripts/skill-gates-smoke.ts [--claude <path to claude>] [--keep]
|
||||
*
|
||||
* Without --claude it fetches the pinned CLI with npm into a temp dir at run
|
||||
* time (never vendored; Anthropic's commercial terms apply to the CLI). Exit
|
||||
* 0 when every check passes, 1 when one fails, 2 on a setup error.
|
||||
* Docs: docs/skill-gates.md#headless-smoke.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
export const CLAUDE_CODE_VERSION = '2.1.318';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const args = process.argv.slice(2);
|
||||
const keep = args.includes('--keep');
|
||||
const work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gates-smoke-')));
|
||||
const home = path.join(work, 'home');
|
||||
const repo = path.join(work, 'repo');
|
||||
const state = path.join(work, 'state');
|
||||
const SRC = 'export const total = (a: number, b: number) => a - b;\n';
|
||||
const FIXED = 'export const total = (a: number, b: number) => a + b;\n';
|
||||
|
||||
function run(cmd: string, argv: string[], cwd: string, timeout = 120_000): string {
|
||||
const r = spawnSync(cmd, argv, { cwd, encoding: 'utf8', timeout });
|
||||
if (r.status !== 0) { process.stderr.write(`SMOKE_SETUP: ${cmd} ${argv.join(' ')} failed: ${r.stderr || r.error}\n`); process.exit(2); }
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
function claudeBin(): string {
|
||||
const i = args.indexOf('--claude');
|
||||
if (i >= 0) return path.resolve(args[i + 1]);
|
||||
const dir = path.join(work, 'cli');
|
||||
fs.mkdirSync(dir);
|
||||
run('npm', ['install', '--silent', '--no-audit', '--no-fund', '--prefix', dir, `@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`], dir, 600_000);
|
||||
return path.join(dir, 'node_modules', '.bin', 'claude');
|
||||
}
|
||||
|
||||
const file = (rel: string) => path.join(repo, rel);
|
||||
/** The scripted session: one assistant turn per main-loop request, chosen by how many assistant turns the request already holds. */
|
||||
const SCRIPT: Array<Array<Record<string, unknown>>> = [
|
||||
[{ type: 'text', text: 'Loading /investigate.' }, { type: 'tool_use', id: 'toolu_s1', name: 'Skill', input: { skill: 'investigate' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_r1', name: 'Read', input: { file_path: file('src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e1', name: 'Edit', input: { file_path: file('src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'tool_use', id: 'toolu_b1', name: 'Bash', input: { command: "sed -i 's/a - b/a + b/' src/total.ts", description: 'Patch through the shell' } }],
|
||||
[{ type: 'text', text: 'Root cause: total() subtracts b instead of adding it (src/total.ts:1).' }, { type: 'tool_use', id: 'toolu_r2', name: 'Read', input: { file_path: file('src/total.ts') } }],
|
||||
[{ type: 'tool_use', id: 'toolu_e2', name: 'Edit', input: { file_path: file('src/total.ts'), old_string: 'a - b', new_string: 'a + b' } }],
|
||||
[{ type: 'text', text: 'Fixed total().' }],
|
||||
];
|
||||
|
||||
function sse(content: Array<Record<string, unknown>>, model: string): string {
|
||||
const ev = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
let out = ev('message_start', { type: 'message_start', message: { id: `msg_smoke_${Math.random().toString(36).slice(2)}`, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } } });
|
||||
content.forEach((b, i) => {
|
||||
const start = b.type === 'text' ? { type: 'text', text: '' } : { type: 'tool_use', id: b.id, name: b.name, input: {} };
|
||||
const delta = b.type === 'text' ? { type: 'text_delta', text: b.text } : { type: 'input_json_delta', partial_json: JSON.stringify(b.input) };
|
||||
out += ev('content_block_start', { type: 'content_block_start', index: i, content_block: start });
|
||||
out += ev('content_block_delta', { type: 'content_block_delta', index: i, delta });
|
||||
out += ev('content_block_stop', { type: 'content_block_stop', index: i });
|
||||
});
|
||||
out += ev('message_delta', { type: 'message_delta', delta: { stop_reason: content.some(b => b.type === 'tool_use') ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
|
||||
return out + ev('message_stop', { type: 'message_stop' });
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const claude = claudeBin();
|
||||
const version = run(claude, ['--version'], work).trim();
|
||||
fs.mkdirSync(path.join(home, '.claude', 'skills', 'investigate'), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), path.join(home, '.claude', 'skills', 'investigate', 'SKILL.md'));
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(file('src/total.ts'), SRC);
|
||||
run('git', ['init', '-q', '-b', 'main'], repo);
|
||||
run('git', ['add', '-A'], repo);
|
||||
run('git', ['-c', 'user.name=Smoke fixture', '-c', 'user.email=smoke@gstack.test', 'commit', '-q', '-m', 'seed'], repo);
|
||||
const settingsFile = path.join(work, 'settings.json');
|
||||
fs.writeFileSync(settingsFile, run(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-skill-gates'), 'settings', '--investigate'], work));
|
||||
|
||||
const requests: string[] = [];
|
||||
const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
|
||||
const raw = await req.text();
|
||||
requests.push(raw);
|
||||
if (!new URL(req.url).pathname.endsWith('/messages')) return Response.json({ input_tokens: 1 });
|
||||
const body = JSON.parse(raw || '{}');
|
||||
const main = Array.isArray(body.tools) && body.tools.length > 0;
|
||||
const content = main ? (SCRIPT[body.messages.filter((m: { role: string }) => m.role === 'assistant').length] ?? [{ type: 'text', text: 'Done.' }]) : [{ type: 'text', text: 'ok' }];
|
||||
if (!body.stream) return Response.json({ id: 'msg_smoke', type: 'message', role: 'assistant', model: body.model, content, stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } });
|
||||
return new Response(sse(content, body.model), { headers: { 'content-type': 'text/event-stream' } });
|
||||
} });
|
||||
|
||||
const env = {
|
||||
PATH: `${path.dirname(process.execPath)}:${process.env.PATH ?? ''}`, HOME: home, GSTACK_STATE_ROOT: state,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`, ANTHROPIC_API_KEY: 'sk-smoke-not-a-key',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_AUTOUPDATER: '1',
|
||||
};
|
||||
const proc = Bun.spawn([claude, '-p', 'total() returns the wrong value; investigate and fix it', '--settings', settingsFile, '--permission-mode', 'bypassPermissions', '--output-format', 'json', '--max-turns', '20'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
|
||||
const killer = setTimeout(() => proc.kill(), 180_000);
|
||||
const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
||||
clearTimeout(killer);
|
||||
server.stop(true);
|
||||
|
||||
const transcripts = fs.existsSync(path.join(home, '.claude', 'projects')) ? fs.readdirSync(path.join(home, '.claude', 'projects'), { recursive: true }).map(String).filter(f => f.endsWith('.jsonl')) : [];
|
||||
const transcript = transcripts.length ? fs.readFileSync(path.join(home, '.claude', 'projects', transcripts[0]), 'utf8') : '';
|
||||
const records = fs.existsSync(path.join(state, 'skill-gates')) ? fs.readdirSync(path.join(state, 'skill-gates')) : [];
|
||||
const record = records.length ? JSON.parse(fs.readFileSync(path.join(state, 'skill-gates', records[0], 'record.json'), 'utf8')) : null;
|
||||
const run0 = record ? Object.values(record.hooks['investigate-gate']?.runs ?? {})[0] as Record<string, unknown> | undefined : undefined;
|
||||
const log = fs.existsSync(path.join(state, 'hooks', 'investigate-gate.log')) ? fs.readFileSync(path.join(state, 'hooks', 'investigate-gate.log'), 'utf8') : '';
|
||||
const markerLines = [...new Set(transcript.match(/GSTACK_GATE investigate-gate [a-z]+ [^"\\]*/g) ?? [])];
|
||||
const later = requests.join('\n');
|
||||
|
||||
const checks: Array<[string, boolean]> = [
|
||||
['claude -p exited 0', code === 0],
|
||||
['record: one run, fired', !!run0 && typeof run0.fired_at === 'string'],
|
||||
['record: blocked twice (Edit, then Bash sed -i)', run0?.blocked === 2],
|
||||
['record: released after the Root cause line', typeof run0?.released_at === 'string'],
|
||||
['record: no hook errors', (record?.hooks['investigate-gate']?.errors ?? []).length === 0],
|
||||
['log: fired, blocked via=edit, blocked via=bash, released', /event=fired/.test(log) && /event=blocked .*via=edit/.test(log) && /event=blocked .*via=bash/.test(log) && /event=released/.test(log)],
|
||||
['transcript: deny reason carries the blocked via=edit marker (tool_result)', /"tool_result"[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=edit/.test(transcript)],
|
||||
['transcript: PostToolUse block carries the blocked via=bash marker (hook_blocking_error)', /hook_blocking_error[^\n]*GSTACK_GATE investigate-gate blocked [^"]*via=bash/.test(transcript)],
|
||||
['transcript: fired and released ride hook_system_message', /hook_system_message[^\n]*GSTACK_GATE investigate-gate fired/.test(transcript) && /hook_system_message[^\n]*GSTACK_GATE investigate-gate released/.test(transcript)],
|
||||
['requests: fired/released systemMessage markers never reach the model', !/investigate-gate (fired|released)/.test(later)],
|
||||
['requests: the model saw both block reasons', /investigate-gate blocked [^"]*via=edit/.test(later) && /investigate-gate blocked [^"]*via=bash/.test(later)],
|
||||
['repo: the sed -i write was put back, then the released Edit applied', fs.readFileSync(file('src/total.ts'), 'utf8') === FIXED],
|
||||
];
|
||||
process.stdout.write(`SMOKE: claude ${version} (fake Messages API, ${requests.length} requests, $0)\n`);
|
||||
for (const line of markerLines) process.stdout.write(` transcript marker: ${line}\n`);
|
||||
for (const [name, ok] of checks) process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
|
||||
const failed = checks.filter(([, ok]) => !ok).length;
|
||||
if (failed) process.stdout.write(`claude stdout: ${out.slice(0, 2000)}\nclaude stderr: ${err.slice(0, 2000)}\nwork dir kept: ${work}\n`);
|
||||
else if (!keep) fs.rmSync(work, { recursive: true, force: true });
|
||||
process.stdout.write(`SMOKE_RESULT: ${failed ? 'fail' : 'pass'} checks=${checks.length} failed=${failed}\n`);
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
main().then(code => process.exit(code), e => { process.stderr.write(`SMOKE_SETUP: ${e instanceof Error ? e.stack : String(e)}\n`); process.exit(2); });
|
||||
@@ -60,6 +60,10 @@ Claude Code hooks (docs/skill-routing-and-contracts.md):
|
||||
--skill-contracts / --no-skill-contracts
|
||||
Block once when a contract skill ends without its
|
||||
required outputs (Stop). Saved as skill_contracts.
|
||||
--investigate-gate / --no-investigate-gate
|
||||
After /investigate loads, block changes to tracked source
|
||||
(Edit, Write and Bash) until a root cause is stated
|
||||
(docs/skill-gates.md). Saved as investigate_gate.
|
||||
|
||||
Model selection:
|
||||
--model <id> Codex model profile override. Otherwise reads Codex config.
|
||||
@@ -193,6 +197,7 @@ PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = ex
|
||||
TIMELINE_STOP_HOOK_MODE="" # "" = resolve from env/config; "yes"/"no" = explicit (#2677)
|
||||
SKILL_ROUTING_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1088)
|
||||
SKILL_CONTRACTS_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1088)
|
||||
INVESTIGATE_GATE_MODE="" # "" = resolve from env/config; "on"/"off" = explicit (GST-1461)
|
||||
MODEL_OVERRIDE=""
|
||||
MODEL_OVERRIDE_SET=0
|
||||
CLAUDE_MODEL_ARG=""
|
||||
@@ -207,6 +212,7 @@ _setup_unknown_option() {
|
||||
for flag in --host --model --claude-model --loadout --global --local --prefix --no-prefix --team --no-team \
|
||||
--plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \
|
||||
--skill-routing --no-skill-routing --skill-contracts --no-skill-contracts \
|
||||
--investigate-gate --no-investigate-gate \
|
||||
--refresh-registered --status --no-browser --no-harness-auto --quiet --help; do
|
||||
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
|
||||
n = length(a); m = length(b)
|
||||
@@ -255,6 +261,9 @@ while [ $# -gt 0 ]; do
|
||||
--skill-contracts) SKILL_CONTRACTS_MODE="on"; shift ;;
|
||||
--no-skill-contracts) SKILL_CONTRACTS_MODE="off"; shift ;;
|
||||
--skill-contracts=*) SKILL_CONTRACTS_MODE="${1#--skill-contracts=}"; shift ;;
|
||||
--investigate-gate) INVESTIGATE_GATE_MODE="on"; shift ;;
|
||||
--no-investigate-gate) INVESTIGATE_GATE_MODE="off"; shift ;;
|
||||
--investigate-gate=*) INVESTIGATE_GATE_MODE="${1#--investigate-gate=}"; shift ;;
|
||||
--refresh-registered) REFRESH_REGISTERED=1; shift ;;
|
||||
--no-browser) export GSTACK_SKIP_PLAYWRIGHT=1 GSTACK_SKIP_FONTS=1; shift ;;
|
||||
--no-harness-auto) HARNESS_AUTO=0; shift ;;
|
||||
@@ -4895,7 +4904,9 @@ fi
|
||||
# takes effect on the next prompt without re-running setup.
|
||||
_skill_hook_gate() {
|
||||
local key="$1" mode="$2" env_name="$3" event="$4" cmd="$5" source="$6" timeout="$7" what="$8"
|
||||
local decision src out def unrecognized=0
|
||||
local decision src out def unrecognized=0 matcher="${9:-}"
|
||||
local -a regs=("$event" "$cmd" "$matcher")
|
||||
[ "$#" -gt 9 ] && regs+=("${@:10}")
|
||||
def="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SKILL_ROUTE_BIN" --default "$key" 2>/dev/null || true)"
|
||||
[ "$def" = "on" ] || def="off"
|
||||
if [ -n "$mode" ]; then
|
||||
@@ -4927,17 +4938,21 @@ _skill_hook_gate() {
|
||||
"$SETTINGS_HOOK" remove-source --source "$source" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
[ -n "$cmd" ] || return 0
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then cmd="bash $cmd"; fi
|
||||
if out=$("$SETTINGS_HOOK" ensure-event --event "$event" --command "$cmd" --source "$source" --timeout "$timeout" 2>&1); then
|
||||
case "$out" in
|
||||
*unchanged*) : ;;
|
||||
*re-pointed*) log " re-pointed $event hook to $cmd (previous registration held a stale path)" ;;
|
||||
*) log " registered $event hook: $what (on via $src; turn off: gstack-config set $key off, or ./setup --no-${key//_/-}; backup: settings.json.bak.<ts>)" ;;
|
||||
esac
|
||||
else
|
||||
log " warning: settings hook update failed: $(printf '%s\n' "$out" | head -1) — run $SETTINGS_HOOK manually"
|
||||
fi
|
||||
local i
|
||||
for ((i = 0; i < ${#regs[@]}; i += 3)); do
|
||||
event="${regs[i]}"; cmd="${regs[i+1]}"; matcher="${regs[i+2]}"
|
||||
[ -n "$cmd" ] || continue
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then cmd="bash $cmd"; fi
|
||||
if out=$("$SETTINGS_HOOK" ensure-event --event "$event" --command "$cmd" --source "$source" --timeout "$timeout" ${matcher:+--matcher "$matcher"} 2>&1); then
|
||||
case "$out" in
|
||||
*unchanged*) : ;;
|
||||
*re-pointed*) log " re-pointed $event hook to $cmd (previous registration held a stale path)" ;;
|
||||
*) log " registered $event hook: $what (on via $src; turn off: gstack-config set $key off, or ./setup --no-${key//_/-}; backup: settings.json.bak.<ts>)" ;;
|
||||
esac
|
||||
else
|
||||
log " warning: settings hook update failed: $(printf '%s\n' "$out" | head -1) — run $SETTINGS_HOOK manually"
|
||||
fi
|
||||
done
|
||||
}
|
||||
SKILL_ROUTE_BIN="$SOURCE_GSTACK_DIR/bin/gstack-skill-route"
|
||||
SKILL_ROUTE_HOOK="$(_hook_command_path hosts/claude/hooks/skill-route-hook || true)"
|
||||
@@ -4948,6 +4963,20 @@ _skill_hook_gate skill_routing "$SKILL_ROUTING_MODE" GSTACK_SKILL_ROUTING UserPr
|
||||
_skill_hook_gate skill_contracts "$SKILL_CONTRACTS_MODE" GSTACK_SKILL_CONTRACTS Stop \
|
||||
"$SKILL_CONTRACT_HOOK" gstack-skill-contract 10 \
|
||||
"a contract skill that ends without its required outputs is blocked once"
|
||||
# The /investigate gate (GST-1461, docs/skill-gates.md) registers one shim per
|
||||
# event; each basename has its own KNOWN_HOOKS row under one source tag.
|
||||
INVESTIGATE_GATE_PRE="$(_hook_command_path hosts/claude/hooks/investigate-gate-pre || true)"
|
||||
INVESTIGATE_GATE_POST="$(_hook_command_path hosts/claude/hooks/investigate-gate-post || true)"
|
||||
INVESTIGATE_GATE_POST_FAILURE="$(_hook_command_path hosts/claude/hooks/investigate-gate-post-failure || true)"
|
||||
INVESTIGATE_GATE_STOP="$(_hook_command_path hosts/claude/hooks/investigate-gate-stop || true)"
|
||||
INVESTIGATE_GATE_EDIT_MATCHER="Edit|Write|MultiEdit|NotebookEdit|Bash"
|
||||
_skill_hook_gate investigate_gate "$INVESTIGATE_GATE_MODE" GSTACK_INVESTIGATE_GATE PreToolUse \
|
||||
"$INVESTIGATE_GATE_PRE" gstack-investigate-gate 10 \
|
||||
"after /investigate loads, changes to tracked source wait for a stated root cause" \
|
||||
"$INVESTIGATE_GATE_EDIT_MATCHER" \
|
||||
PostToolUse "$INVESTIGATE_GATE_POST" Bash \
|
||||
PostToolUseFailure "$INVESTIGATE_GATE_POST_FAILURE" Bash \
|
||||
Stop "$INVESTIGATE_GATE_STOP" ""
|
||||
|
||||
# Also tear down plan-tune + timeline hooks on --no-team (matches the existing pattern).
|
||||
# Tag-only remove-source misses untagged entries (Claude Code strips
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* investigate-gate (GST-1461): with the investigate_gate switch on, a session
|
||||
* that loaded /investigate cannot change tracked non-test source until it has
|
||||
* stated a root cause as a `Root cause: <value>` line. Edit/Write are denied
|
||||
* at PreToolUse; Bash writes are caught by comparing the tracked tree before
|
||||
* (PreToolUse) and after (PostToolUse / PostToolUseFailure) the command, put
|
||||
* back, and reported. Every decision is a GSTACK_GATE marker in the hook log
|
||||
* and in the hook's output; every error fails open with an error marker.
|
||||
* Native hook payloads against a throwaway git repo.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { excludedReason } from '../lib/investigate-gate';
|
||||
import { ROOT, hookEnv, result, runHook, say, skillsDir, slash, tmpDir, tool, user } from './helpers/skill-contract-fixture';
|
||||
|
||||
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'investigate-gate.ts');
|
||||
const SRC = 'export const total = (a: number, b: number) => a - b;\n';
|
||||
const FIXED = 'export const total = (a: number, b: number) => a + b;\n';
|
||||
|
||||
let state = '', skills = '', repo = '', work = '';
|
||||
beforeEach(() => {
|
||||
state = tmpDir('gstack-igate-state-');
|
||||
work = tmpDir('gstack-igate-work-');
|
||||
repo = tmpDir('gstack-igate-repo-');
|
||||
skills = skillsDir(['investigate']);
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'core.autocrlf', 'false');
|
||||
fs.mkdirSync(path.join(repo, 'src'));
|
||||
fs.mkdirSync(path.join(repo, 'test'));
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), SRC);
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 1;\n');
|
||||
fs.writeFileSync(path.join(repo, 'test', 'total.test.ts'), 'expect(total(1, 2)).toBe(3);\n');
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n');
|
||||
git('add', '-A');
|
||||
commit('seed');
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of [state, skills, repo, work]) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
const commit = (msg: string) => git('-c', 'user.name=Gate fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', msg);
|
||||
const read = (rel: string) => fs.readFileSync(path.join(repo, rel), 'utf8');
|
||||
const sh = (command: string) => spawnSync('bash', ['-c', command], { cwd: repo, encoding: 'utf8', timeout: 10_000 });
|
||||
|
||||
const LOADED = [user('total() is wrong, fix it'), tool('s1', 'Skill', { skill: 'investigate' }), result('s1', 'Launching skill: investigate')];
|
||||
const LOOKED = [tool('r1', 'Read', { file_path: 'src/total.ts' }), result('r1', SRC)];
|
||||
const ROOT_CAUSE = say('Root cause: total() subtracts b instead of adding it (src/total.ts:1).');
|
||||
const RELEASED = [...LOADED, ...LOOKED, ROOT_CAUSE];
|
||||
const record = () => JSON.parse(fs.readFileSync(path.join(state, 'skill-gates', 'sess-1', 'record.json'), 'utf8'));
|
||||
|
||||
function transcript(lines: string[]): string {
|
||||
const file = path.join(work, 'transcript.jsonl');
|
||||
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
||||
return file;
|
||||
}
|
||||
function fire(event: string, lines: string[], extra: Record<string, unknown> = {}, env: Record<string, string> = {}) {
|
||||
const payload = { session_id: 'sess-1', transcript_path: transcript(lines), cwd: repo, hook_event_name: event, ...extra };
|
||||
return runHook(HOOK, JSON.stringify(payload), hookEnv(state, skills, { GSTACK_INVESTIGATE_GATE: 'on', ...env }));
|
||||
}
|
||||
/** PreToolUse as Claude Code sends it: the call's own tool_use block is already in the transcript. */
|
||||
const pre = (lines: string[], name: string, input: Record<string, unknown>, id = 'tu1', env: Record<string, string> = {}) =>
|
||||
fire('PreToolUse', [...lines, tool(id, name, input)], { tool_name: name, tool_input: input, tool_use_id: id }, env);
|
||||
const post = (lines: string[], command: string, id = 'tu1', event = 'PostToolUse') =>
|
||||
fire(event, [...lines, tool(id, 'Bash', { command })], { tool_name: 'Bash', tool_input: { command }, tool_use_id: id, tool_response: { stdout: '', stderr: '', interrupted: false } });
|
||||
const log = () => {
|
||||
const f = path.join(state, 'hooks', 'investigate-gate.log');
|
||||
return fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
|
||||
};
|
||||
const json = (stdout: string) => (stdout.trim() ? JSON.parse(stdout) : null);
|
||||
|
||||
/** Run a Bash command the way Claude Code does: PreToolUse, the command, then PostToolUse (or PostToolUseFailure on a non-zero exit). */
|
||||
function bash(lines: string[], command: string, id = 'tu1') {
|
||||
const before = pre(lines, 'Bash', { command }, id);
|
||||
const ran = sh(command);
|
||||
const after = post(lines, command, id, ran.status === 0 ? 'PostToolUse' : 'PostToolUseFailure');
|
||||
return { before, ran, after };
|
||||
}
|
||||
|
||||
describe('Edit and Write before a root cause', () => {
|
||||
test('an Edit to tracked source is denied with the blocked marker, and fired is logged once', () => {
|
||||
const r = pre(LOADED, 'Edit', { file_path: path.join(repo, 'src', 'total.ts'), old_string: 'a - b', new_string: 'a + b' });
|
||||
expect(r.code).toBe(0);
|
||||
const out = json(r.stdout).hookSpecificOutput;
|
||||
expect(out.hookEventName).toBe('PreToolUse');
|
||||
expect(out.permissionDecision).toBe('deny');
|
||||
expect(out.permissionDecisionReason).toMatch(/^GSTACK_GATE investigate-gate blocked session=sess-1 skill=investigate via=edit files=src\/total\.ts/);
|
||||
expect(out.permissionDecisionReason).toContain('Root cause:');
|
||||
expect(out.permissionDecisionReason).not.toMatch(/investigate_gate|gstack-config/);
|
||||
pre(LOADED, 'Write', { file_path: path.join(repo, 'src', 'other.ts'), content: 'x' }, 'tu2');
|
||||
expect(log().match(/ event=fired /g)?.length).toBe(1);
|
||||
expect(log().match(/ event=blocked /g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
test('MultiEdit and NotebookEdit are denied too; relative paths resolve against cwd', () => {
|
||||
expect(json(pre(LOADED, 'MultiEdit', { file_path: 'src/total.ts', edits: [] }).stdout).hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
expect(json(pre(LOADED, 'NotebookEdit', { notebook_path: 'src/total.ts', new_source: '' }, 'tu2').stdout).hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
});
|
||||
|
||||
test('test files, new untracked files, docs and paths outside the repo are allowed', () => {
|
||||
for (const [i, file] of [path.join(repo, 'test', 'total.test.ts'), path.join(repo, 'src', 'new.ts'), path.join(repo, 'README.md'), path.join(work, 'notes.ts')].entries()) {
|
||||
const r = pre(LOADED, 'Write', { file_path: file, content: 'x' }, `tu${i}`);
|
||||
expect(r.code).toBe(0);
|
||||
expect(json(r.stdout)?.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
||||
}
|
||||
expect(log()).not.toContain('event=blocked');
|
||||
});
|
||||
|
||||
test('a placeholder, a hedge, a cause before any evidence, or a cause inside tool output does not release', () => {
|
||||
const cases: string[][] = [
|
||||
[...LOADED, ...LOOKED, say('Root cause: [what was actually wrong]')],
|
||||
[...LOADED, ...LOOKED, say('Root cause: unknown')],
|
||||
[...LOADED, ...LOOKED, say('**Root cause:** still investigating.')],
|
||||
[...LOADED, ROOT_CAUSE],
|
||||
[...LOADED, tool('b1', 'Bash', { command: 'echo' }), result('b1', 'Root cause: total subtracts')],
|
||||
];
|
||||
for (const [i, lines] of cases.entries()) {
|
||||
const out = json(pre(lines, 'Edit', { file_path: 'src/total.ts' }, `tu${i}`).stdout).hookSpecificOutput;
|
||||
expect(out.permissionDecision).toBe('deny');
|
||||
}
|
||||
expect(json(pre(cases[1], 'Edit', { file_path: 'src/total.ts' }, 'tu9').stdout).hookSpecificOutput.permissionDecisionReason).toContain('"Root cause: unknown" does not count');
|
||||
});
|
||||
|
||||
test('after a Root cause line the edit is allowed, released is logged once and carried into the transcript', () => {
|
||||
const r = pre(RELEASED, 'Edit', { file_path: 'src/total.ts' });
|
||||
const out = json(r.stdout);
|
||||
expect(out.hookSpecificOutput).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE investigate-gate fired session=sess-1 skill=investigate/m);
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE investigate-gate released session=sess-1 skill=investigate cause=[0-9a-f]{12} blocked=0$/m);
|
||||
expect(pre(RELEASED, 'Edit', { file_path: 'src/total.ts' }, 'tu2').stdout).toBe('');
|
||||
expect(log().match(/ event=released /g)?.length).toBe(1);
|
||||
const run = Object.values(record().hooks['investigate-gate'].runs)[0] as any;
|
||||
expect(run).toMatchObject({ skill: 'investigate', blocked: 0 });
|
||||
expect(run.released_at).toBeString();
|
||||
});
|
||||
|
||||
test('a Root cause line that reaches the transcript after PreToolUse fires still releases (the hook waits for its own tool_use)', () => {
|
||||
const file = transcript([...LOADED, ...LOOKED]);
|
||||
const late = [ROOT_CAUSE, tool('tu1', 'Edit', { file_path: 'src/total.ts' })].join('\n');
|
||||
const writer = spawnSync('bash', ['-c', `(sleep 0.4; printf '%s\\n' "$1" >> "$2") >/dev/null 2>&1 &`, 'w', late, file], { timeout: 5000 });
|
||||
expect(writer.status).toBe(0);
|
||||
const payload = { session_id: 'sess-1', transcript_path: file, cwd: repo, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: 'src/total.ts' }, tool_use_id: 'tu1' };
|
||||
const r = runHook(HOOK, JSON.stringify(payload), hookEnv(state, skills, { GSTACK_INVESTIGATE_GATE: 'on' }));
|
||||
const out = json(r.stdout);
|
||||
expect(out.hookSpecificOutput).toBeUndefined();
|
||||
expect(out.systemMessage).toContain('investigate-gate released');
|
||||
});
|
||||
|
||||
test('a fresh /investigate load re-arms the gate', () => {
|
||||
const lines = [...RELEASED, slash('investigate', 'another bug')];
|
||||
expect(json(pre(lines, 'Edit', { file_path: 'src/total.ts' }).stdout).hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
});
|
||||
|
||||
test('no /investigate load, or the switch off: no output and no log', () => {
|
||||
expect(pre([user('fix it')], 'Edit', { file_path: 'src/total.ts' }).stdout).toBe('');
|
||||
expect(pre(LOADED, 'Edit', { file_path: 'src/total.ts' }, 'tu2', { GSTACK_INVESTIGATE_GATE: 'off' }).stdout).toBe('');
|
||||
expect(log()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bash writes are caught by the tree check, put back and reported', () => {
|
||||
const WRITES: Record<string, string> = {
|
||||
'sed -i': "sed -i 's/a - b/a + b/' src/total.ts",
|
||||
'redirect': `printf '%s' '${FIXED}' > src/total.ts`,
|
||||
'tee': `printf '%s' '${FIXED}' | tee src/total.ts >/dev/null`,
|
||||
'python': "python3 -c \"import pathlib; p = pathlib.Path('src/total.ts'); p.write_text(p.read_text().replace('a - b', 'a + b'))\"",
|
||||
'perl -pi': "perl -pi -e 's/a - b/a + b/' src/total.ts",
|
||||
'node': "node -e \"const fs = require('fs'); fs.writeFileSync('src/total.ts', fs.readFileSync('src/total.ts', 'utf8').replace('a - b', 'a + b'))\"",
|
||||
'git apply': "printf '%s\\n' 'diff --git a/src/total.ts b/src/total.ts' '--- a/src/total.ts' '+++ b/src/total.ts' '@@ -1 +1 @@' '-export const total = (a: number, b: number) => a - b;' '+export const total = (a: number, b: number) => a + b;' | git apply",
|
||||
'heredoc into cat': `cat > src/total.ts <<'EOF'\n${FIXED}EOF`,
|
||||
'rm': 'rm src/other.ts',
|
||||
};
|
||||
for (const [name, command] of Object.entries(WRITES)) {
|
||||
test(`${name}: blocked, restored, marker in log and output`, () => {
|
||||
const { before, ran, after } = bash(LOADED, command);
|
||||
expect(json(before.stdout)?.hookSpecificOutput).toBeUndefined();
|
||||
expect(ran.status).toBe(0);
|
||||
const out = json(after.stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason).toMatch(/^GSTACK_GATE investigate-gate blocked session=sess-1 skill=investigate via=bash files=src\/(total|other)\.ts/);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(read('src/other.ts')).toBe('export const other = 1;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
expect(log()).toContain('event=blocked');
|
||||
});
|
||||
}
|
||||
|
||||
test('a command that writes and then fails is caught at PostToolUseFailure', () => {
|
||||
const { ran, after } = bash(LOADED, `${WRITES['sed -i']} && false`);
|
||||
expect(ran.status).not.toBe(0);
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
});
|
||||
|
||||
test('write then commit in one command is caught and the worktree put back', () => {
|
||||
const head = git('rev-parse', 'HEAD');
|
||||
const { after } = bash(LOADED, `${WRITES['sed -i']} && git -c user.name=x -c user.email=x@x.test commit -qam fix`);
|
||||
const out = json(after.stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason).toContain('moved the branch back');
|
||||
expect(git('rev-parse', 'HEAD')).toBe(head);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('a file dirty at the start is put back to its dirty bytes, not HEAD', () => {
|
||||
const dirty = '// local note\n' + SRC;
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), dirty);
|
||||
const { after } = bash(LOADED, WRITES['sed -i']);
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(read('src/total.ts')).toBe(dirty);
|
||||
});
|
||||
|
||||
test('reads, test writes, new files, reverts and checkouts of existing commits pass', () => {
|
||||
fs.writeFileSync(path.join(repo, 'src', 'total.ts'), FIXED);
|
||||
git('stash', '-q');
|
||||
git('checkout', '-q', '-b', 'other');
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 2;\n');
|
||||
git('add', '-A');
|
||||
commit('other');
|
||||
git('checkout', '-q', 'main');
|
||||
git('stash', 'pop', '-q');
|
||||
const commands = [
|
||||
'cat src/total.ts && git log --oneline | head -1',
|
||||
"sed -i 's/3/4/' test/total.test.ts",
|
||||
"printf 'x' > src/repro.ts",
|
||||
'git checkout -- src/total.ts',
|
||||
'git checkout -q other && git checkout -q main',
|
||||
'git stash -q && git stash pop -q || true',
|
||||
];
|
||||
for (const [i, c] of commands.entries()) {
|
||||
const { after } = bash(LOADED, c, `tu${i}`);
|
||||
expect(json(after.stdout)?.decision).toBeUndefined();
|
||||
}
|
||||
expect(log()).not.toContain('event=blocked');
|
||||
}, 30_000);
|
||||
|
||||
test('the bytes a restore replaces are saved in the session directory and named in the block', () => {
|
||||
const out = json(bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts").after.stdout);
|
||||
const saved = /The content it replaced is saved: src\/total\.ts at (\S+)\.$/m.exec(out.reason)?.[1];
|
||||
expect(saved).toBeDefined();
|
||||
expect(fs.readFileSync(saved!, 'utf8')).toBe(FIXED);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
});
|
||||
|
||||
test('a fix committed on another branch moves that branch back to its own tip, not to the baseline HEAD', () => {
|
||||
git('checkout', '-q', '-b', 'side');
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 2;\n');
|
||||
git('add', '-A');
|
||||
commit('side');
|
||||
const side = git('rev-parse', 'HEAD');
|
||||
git('checkout', '-q', 'main');
|
||||
const { after } = bash(LOADED, "git checkout -q side && sed -i 's/a - b/a + b/' src/total.ts && git -c user.name=x -c user.email=x@x.test commit -qam fix");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(git('rev-parse', 'side')).toBe(side);
|
||||
expect(git('rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('side');
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(read('src/other.ts')).toBe('export const other = 2;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('amending the baseline HEAD with a fix moves the branch back to the baseline commit, not its parent', () => {
|
||||
fs.writeFileSync(path.join(repo, 'src', 'other.ts'), 'export const other = 2;\n');
|
||||
git('add', '-A');
|
||||
commit('second');
|
||||
const head = git('rev-parse', 'HEAD');
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts && git -c user.name=x -c user.email=x@x.test commit -q --amend -a --no-edit");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(git('rev-parse', 'HEAD')).toBe(head);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
expect(read('src/other.ts')).toBe('export const other = 2;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('a fix commit in a repo with hundreds of refs on distinct commits is still undone inside the deadline', () => {
|
||||
const head = git('rev-parse', 'HEAD');
|
||||
const stream = Array.from({ length: 300 }, (_, i) => `commit refs/tags/t${i}\ncommitter x <x@x.test> 1700000000 +0000\ndata ${String(i).length}\n${i}\nfrom ${head.trim()}\n`).join('');
|
||||
expect(spawnSync('git', ['fast-import', '--quiet'], { cwd: repo, input: stream, timeout: 10_000 }).status).toBe(0);
|
||||
const { after } = bash(LOADED, "sed -i 's/a - b/a + b/' src/total.ts && git -c user.name=x -c user.email=x@x.test commit -qam fix");
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(git('rev-parse', 'HEAD')).toBe(head);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
}, 30_000);
|
||||
|
||||
test('pulling or merging upstream commits fetched after the baseline is not undone', () => {
|
||||
const origin = path.join(work, 'origin.git');
|
||||
const up = path.join(work, 'up');
|
||||
git('clone', '-q', '--bare', repo, origin);
|
||||
git('remote', 'add', 'origin', origin);
|
||||
git('fetch', '-q', 'origin');
|
||||
bash(LOADED, 'ls', 'tu0');
|
||||
git('clone', '-q', origin, up);
|
||||
fs.writeFileSync(path.join(up, 'src', 'total.ts'), FIXED);
|
||||
fs.writeFileSync(path.join(up, 'test', 'total.test.ts'), 'expect(total(2, 2)).toBe(4);\n');
|
||||
for (const args of [['-c', 'user.name=x', '-c', 'user.email=x@x.test', 'commit', '-qam', 'upstream'], ['push', '-q', 'origin', 'main']]) {
|
||||
expect(spawnSync('git', args, { cwd: up, timeout: 10_000 }).status).toBe(0);
|
||||
}
|
||||
const pulled = bash(LOADED, 'git pull -q --ff-only origin main', 'tu1');
|
||||
expect(json(pulled.after.stdout)?.decision).toBeUndefined();
|
||||
expect(read('src/total.ts')).toBe(FIXED);
|
||||
fs.writeFileSync(path.join(up, 'src', 'other.ts'), 'export const other = 3;\n');
|
||||
for (const args of [['-c', 'user.name=x', '-c', 'user.email=x@x.test', 'commit', '-qam', 'upstream 2'], ['push', '-q', 'origin', 'main']]) {
|
||||
expect(spawnSync('git', args, { cwd: up, timeout: 10_000 }).status).toBe(0);
|
||||
}
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# fixture, local\n');
|
||||
git('add', '-A');
|
||||
commit('local');
|
||||
const merged = bash(LOADED, 'git fetch -q origin && git -c user.name=x -c user.email=x@x.test merge -q --no-edit origin/main', 'tu2');
|
||||
expect(json(merged.after.stdout)?.decision).toBeUndefined();
|
||||
expect(git('rev-list', '--parents', '-n', '1', 'HEAD').trim().split(' ')).toHaveLength(3);
|
||||
expect(read('src/other.ts')).toBe('export const other = 3;\n');
|
||||
expect(git('status', '--porcelain')).toBe('');
|
||||
});
|
||||
|
||||
test('a background write after the command returned is caught at the next call, and at Stop', () => {
|
||||
bash(LOADED, 'ls', 'tu1');
|
||||
sh(WRITES['sed -i']);
|
||||
const out = json(pre(LOADED, 'Bash', { command: 'ls' }, 'tu2').stdout).hookSpecificOutput;
|
||||
expect(out.permissionDecision).toBe('deny');
|
||||
expect(out.permissionDecisionReason).toMatch(/^GSTACK_GATE investigate-gate blocked .*via=bash files=src\/total\.ts n=1/);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
sh(WRITES['perl -pi']);
|
||||
const stop = json(fire('Stop', LOADED, { stop_hook_active: false }).stdout);
|
||||
expect(stop.decision).toBe('block');
|
||||
expect(stop.reason).toMatch(/^GSTACK_GATE investigate-gate blocked .*n=2/);
|
||||
expect(read('src/total.ts')).toBe(SRC);
|
||||
});
|
||||
|
||||
test('lockfile and doc churn is observed, not reverted', () => {
|
||||
fs.writeFileSync(path.join(repo, 'bun.lock'), 'v1\n');
|
||||
git('add', 'bun.lock');
|
||||
commit('lock');
|
||||
const { after } = bash(LOADED, "printf 'v2\\n' > bun.lock && printf 'more\\n' >> README.md");
|
||||
expect(json(after.stdout)?.decision).toBeUndefined();
|
||||
expect(read('bun.lock')).toBe('v2\n');
|
||||
expect(log()).toContain('event=observed');
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a gated file swapped for a symlink is put back as a file and the outside target is untouched', () => {
|
||||
const outside = path.join(work, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'keep me\n');
|
||||
const { after } = bash(LOADED, `rm src/other.ts && ln -s ${outside} src/other.ts`);
|
||||
expect(json(after.stdout).decision).toBe('block');
|
||||
expect(fs.lstatSync(path.join(repo, 'src', 'other.ts')).isSymbolicLink()).toBe(false);
|
||||
expect(read('src/other.ts')).toBe('export const other = 1;\n');
|
||||
expect(fs.readFileSync(outside, 'utf8')).toBe('keep me\n');
|
||||
});
|
||||
|
||||
test('a path that cannot be put back gives GATE_RESTORE_FAILED and says the state is unknown', () => {
|
||||
const { after } = bash(LOADED, 'rm src/other.ts && mkdir src/other.ts && touch src/other.ts/x');
|
||||
const out = json(after.stdout);
|
||||
expect(out.decision).toBe('block');
|
||||
expect(out.reason).toContain('could not be put back and are in an unknown state: src/other.ts');
|
||||
expect(out.systemMessage).toMatch(/GSTACK_GATE investigate-gate error session=sess-1 code=GATE_RESTORE_FAILED/);
|
||||
expect(record().hooks['investigate-gate'].errors[0].code).toBe('GATE_RESTORE_FAILED');
|
||||
});
|
||||
|
||||
test('after the root cause, Bash writes go through untouched', () => {
|
||||
const { after } = bash(RELEASED, WRITES['sed -i']);
|
||||
expect(json(after.stdout)?.decision).toBeUndefined();
|
||||
expect(read('src/total.ts')).toBe(FIXED);
|
||||
});
|
||||
|
||||
test('outside a git repository the gate logs skipped once and stays out of the way', () => {
|
||||
fs.rmSync(path.join(repo, '.git'), { recursive: true, force: true });
|
||||
const { after } = bash(LOADED, WRITES['sed -i']);
|
||||
expect(after.stdout).toBe('');
|
||||
bash(LOADED, 'ls', 'tu2');
|
||||
expect(log().match(/ event=skipped skill=investigate reason=no-git/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors fail open with an error marker', () => {
|
||||
test('a payload that is not JSON: exit 0, no decision, error marker on stderr and in the log', () => {
|
||||
const r = runHook(HOOK, 'not json', hookEnv(state, skills, { GSTACK_INVESTIGATE_GATE: 'on' }));
|
||||
expect(r.code).toBe(0);
|
||||
expect(json(r.stdout).hookSpecificOutput).toBeUndefined();
|
||||
expect(r.stderr).toMatch(/^GSTACK_GATE investigate-gate error session=unknown code=GATE_BAD_PAYLOAD why=/);
|
||||
expect(json(r.stdout).systemMessage).toMatch(/^GSTACK_GATE investigate-gate error session=unknown code=GATE_BAD_PAYLOAD/);
|
||||
expect(log()).toContain('event=error');
|
||||
});
|
||||
|
||||
test('an unreadable transcript: the error marker rides systemMessage and nothing is denied', () => {
|
||||
const r = runHook(HOOK, JSON.stringify({ session_id: 'sess-1', transcript_path: path.join(work, 'missing.jsonl'), cwd: repo, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: 'src/total.ts' }, tool_use_id: 'tu1' }),
|
||||
hookEnv(state, skills, { GSTACK_INVESTIGATE_GATE: 'on' }));
|
||||
expect(r.code).toBe(0);
|
||||
const out = json(r.stdout);
|
||||
expect(out.hookSpecificOutput).toBeUndefined();
|
||||
expect(out.systemMessage).toMatch(/^GSTACK_GATE investigate-gate error session=sess-1 code=GATE_TRANSCRIPT why=/);
|
||||
expect(record().hooks['investigate-gate'].errors[0].code).toBe('GATE_TRANSCRIPT');
|
||||
});
|
||||
|
||||
test('a hook past its deadline fails open with an error marker', () => {
|
||||
const r = pre(LOADED, 'Edit', { file_path: 'src/total.ts' }, 'tu1', { GSTACK_SKILL_GATE_DEADLINE_MS: '1' });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).not.toContain('"deny"');
|
||||
expect(log()).toMatch(/event=error code=GATE_TIMEOUT why="timed out after 1 ms"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which tracked paths the gate leaves alone', () => {
|
||||
test('tests, fixtures, docs, lockfiles, generated files and symlinks are excluded; source is gated', () => {
|
||||
expect(excludedReason('test/total.test.ts')).toBe('test');
|
||||
expect(excludedReason('src/total.spec.ts')).toBe('test');
|
||||
expect(excludedReason('pkg/fixtures/case.json')).toBe('test');
|
||||
expect(excludedReason('docs/guide.md')).toBe('doc');
|
||||
expect(excludedReason('web/bun.lock')).toBe('lockfile');
|
||||
expect(excludedReason('src/schema.gen.ts', '100644', true)).toBe('generated');
|
||||
expect(excludedReason('src/link.ts', '120000')).toBe('symlink');
|
||||
expect(excludedReason('src/total.ts', '100644')).toBeNull();
|
||||
expect(excludedReason('src/contest.ts')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ function fixtureTree(root: string) {
|
||||
fs.copyFileSync(path.join(ROOT, hook), path.join(root, hook));
|
||||
fs.chmodSync(path.join(root, hook), 0o755);
|
||||
if (fs.existsSync(path.join(ROOT, `${hook}.ts`))) fs.writeFileSync(path.join(root, `${hook}.ts`), 'export const ok = 1;\n');
|
||||
for (const m of fs.readFileSync(path.join(ROOT, hook), 'utf8').matchAll(/\$HERE\/([\w.-]+\.ts)\b/g)) fs.writeFileSync(path.join(root, path.dirname(hook), m[1]!), 'export const ok = 1;\n');
|
||||
}
|
||||
for (const dir of SKILLS_WITH_HOOKS) {
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
|
||||
@@ -32,6 +32,8 @@ const settings = () => (fs.existsSync(settingsFile()) ? JSON.parse(fs.readFileSy
|
||||
const GATES = {
|
||||
skill_routing: 'GSTACK_SKILL_ROUTING UserPromptSubmit "$2/hosts/claude/hooks/skill-route-hook" gstack-skill-route 5 "routing hint"',
|
||||
skill_contracts: 'GSTACK_SKILL_CONTRACTS Stop "$2/hosts/claude/hooks/skill-contract-stop-hook" gstack-skill-contract 10 "contract check"',
|
||||
investigate_gate: 'GSTACK_INVESTIGATE_GATE PreToolUse "$2/hosts/claude/hooks/investigate-gate-pre" gstack-investigate-gate 10 "investigate gate" "Edit|Write|MultiEdit|NotebookEdit|Bash"'
|
||||
+ ' PostToolUse "$2/hosts/claude/hooks/investigate-gate-post" Bash PostToolUseFailure "$2/hosts/claude/hooks/investigate-gate-post-failure" Bash Stop "$2/hosts/claude/hooks/investigate-gate-stop" ""',
|
||||
};
|
||||
|
||||
/** Run one gate once from the checkout at root: mode is the flag value ("" = none). */
|
||||
@@ -189,13 +191,13 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
|
||||
expect(config()).toBe('');
|
||||
}
|
||||
|
||||
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 39 and this pin)', () => {
|
||||
expect(original.split('\n')[38]).toBe(" skill_routing: 'off',");
|
||||
test('pin: skill_routing defaults to off today (a flip PR changes lib/skill-route.ts line 40 and this pin)', () => {
|
||||
expect(original.split('\n')[39]).toBe(" skill_routing: 'off',");
|
||||
expect(SWITCH_DEFAULT.skill_routing).toBe('off');
|
||||
});
|
||||
|
||||
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 40 and this pin)', () => {
|
||||
expect(original.split('\n')[39]).toBe(" skill_contracts: 'off',");
|
||||
test('pin: skill_contracts defaults to off today (a flip PR changes lib/skill-route.ts line 41 and this pin)', () => {
|
||||
expect(original.split('\n')[40]).toBe(" skill_contracts: 'off',");
|
||||
expect(SWITCH_DEFAULT.skill_contracts).toBe('off');
|
||||
});
|
||||
|
||||
@@ -231,3 +233,48 @@ describe('SWITCH_DEFAULT is the one default (a flip PR changes one line)', () =>
|
||||
expect(routes(routeAt(ROOT, { GSTACK_SKILL_ROUTING: 'onn' }))).toBe(SWITCH_DEFAULT.skill_routing === 'on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup: the investigate_gate switch (GST-1461) registers one shim per event', () => {
|
||||
const EVENTS: Array<[string, string, string | undefined]> = [
|
||||
['PreToolUse', 'investigate-gate-pre', 'Edit|Write|MultiEdit|NotebookEdit|Bash'],
|
||||
['PostToolUse', 'investigate-gate-post', 'Bash'],
|
||||
['PostToolUseFailure', 'investigate-gate-post-failure', 'Bash'],
|
||||
['Stop', 'investigate-gate-stop', undefined],
|
||||
];
|
||||
const ours = (event: string) => registered(event).filter(e => e.hooks.some(h => h.command.includes('investigate-gate')));
|
||||
|
||||
test('flags parse, appear in --help, and each shim has a KNOWN_HOOKS row with its event and matcher', () => {
|
||||
for (const flag of ['--investigate-gate)', '--no-investigate-gate)', '--investigate-gate=*)']) expect(setupSrc).toContain(` ${flag}`);
|
||||
expect(setupSrc).toContain('--investigate-gate / --no-investigate-gate');
|
||||
for (const [event, shim, matcher] of EVENTS) {
|
||||
expect(setupSrc).toContain(`_hook_command_path hosts/claude/hooks/${shim} ||`);
|
||||
const row = settingsHookSrc.slice(settingsHookSrc.indexOf(`"${shim}":`), settingsHookSrc.indexOf('}', settingsHookSrc.indexOf(`"${shim}":`)));
|
||||
expect(row).toContain('source: "gstack-investigate-gate"');
|
||||
expect(row).toContain(`event: "${event}"`);
|
||||
expect(row).toContain(`matcher: "${matcher ?? ''}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('pin: investigate_gate defaults to off, so with no flag, env or config nothing registers', () => {
|
||||
expect(SWITCH_DEFAULT.investigate_gate).toBe('off');
|
||||
expect(gate('', {}, 0, ROOT, 'investigate_gate')).toEqual({ code: 0, out: '' });
|
||||
for (const [event] of EVENTS) expect(ours(event)).toEqual([]);
|
||||
});
|
||||
|
||||
test('flag on: persists and registers all four events with their matchers; a re-run is quiet; off removes all four', () => {
|
||||
const first = gate('on', {}, 0, ROOT, 'investigate_gate');
|
||||
expect(first.code).toBe(0);
|
||||
expect(first.out).toContain('registered PreToolUse hook: investigate gate (on via flag; turn off: gstack-config set investigate_gate off, or ./setup --no-investigate-gate');
|
||||
expect(config()).toMatch(/^investigate_gate: on$/m);
|
||||
for (const [event, shim, matcher] of EVENTS) {
|
||||
const entries = ours(event) as Array<{ matcher?: string; hooks: Array<{ command: string; timeout?: number }> }>;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].matcher ?? undefined).toBe(matcher);
|
||||
expect(entries[0].hooks[0].command).toEndWith(`hosts/claude/hooks/${shim}`);
|
||||
expect(entries[0].hooks[0].timeout).toBe(10);
|
||||
}
|
||||
expect(gate('', {}, 0, ROOT, 'investigate_gate')).toEqual({ code: 0, out: '' });
|
||||
gate('off', {}, 0, ROOT, 'investigate_gate');
|
||||
for (const [event] of EVENTS) expect(ours(event)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The enforcement hooks are opt-in and leave the advisory skills alone
|
||||
* (GST-1461): turning a gate on changes no byte of /investigate's or
|
||||
* /plan-ceo-review's SKILL.md. The generator renders the committed bytes with
|
||||
* the switch on, the skill sources never name the gate, and an armed gate run
|
||||
* writes nothing under the installed skills dir.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { ROOT, hookEnv, result, runHook, tmpDir, tool, user } from './helpers/skill-contract-fixture';
|
||||
|
||||
const ADVISORY = ['investigate', 'plan-ceo-review'];
|
||||
const GATE_NAMES = /investigate[-_]gate|GSTACK_GATE|skill-gates/;
|
||||
|
||||
function filesUnder(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { recursive: true }).map(String).filter(f => fs.statSync(path.join(dir, f)).isFile()).map(f => path.join(dir, f));
|
||||
}
|
||||
const digest = (dir: string) => Object.fromEntries(filesUnder(dir).map(f => [path.relative(dir, f), createHash('sha256').update(fs.readFileSync(f)).digest('hex')]));
|
||||
|
||||
let state = '', home = '';
|
||||
beforeEach(() => { state = tmpDir('gstack-advisory-state-'); home = tmpDir('gstack-advisory-home-'); });
|
||||
afterEach(() => { for (const d of [state, home]) fs.rmSync(d, { recursive: true, force: true }); });
|
||||
|
||||
describe('advisory skill text is byte-identical with the gate on', () => {
|
||||
test('gen:skill-docs --dry-run with investigate_gate on (env and config) finds both skills fresh', () => {
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'investigate_gate: on\n');
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), '--dry-run'], {
|
||||
cwd: ROOT, env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_INVESTIGATE_GATE: 'on' }, encoding: 'utf8', timeout: 120_000,
|
||||
});
|
||||
for (const skill of ADVISORY) expect(r.stdout).toContain(`FRESH: ${skill}/SKILL.md`);
|
||||
expect(r.stdout).not.toMatch(/STALE: (investigate|plan-ceo-review)\//);
|
||||
});
|
||||
|
||||
test('the advisory skill sources never name the gate: the hooks attach from settings, not from the skill', () => {
|
||||
for (const skill of ADVISORY) {
|
||||
for (const f of filesUnder(path.join(ROOT, skill))) expect({ f, hit: GATE_NAMES.test(fs.readFileSync(f, 'utf8')) }).toEqual({ f, hit: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('an armed gate run that blocks an edit writes nothing under the installed skills dir', () => {
|
||||
const skills = path.join(home, '.claude', 'skills');
|
||||
for (const skill of ADVISORY) {
|
||||
fs.mkdirSync(path.join(skills, skill), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, skill, 'SKILL.md'), path.join(skills, skill, 'SKILL.md'));
|
||||
}
|
||||
const before = digest(skills);
|
||||
const repo = path.join(home, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
fs.writeFileSync(path.join(repo, 'app.ts'), 'export const x = 1;\n');
|
||||
for (const args of [['init', '-q'], ['config', 'core.autocrlf', 'false'], ['add', '-A'], ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'seed']]) {
|
||||
expect(spawnSync('git', args, { cwd: repo, timeout: 10_000 }).status).toBe(0);
|
||||
}
|
||||
const target = path.join(repo, 'app.ts');
|
||||
const transcript = path.join(home, 't.jsonl');
|
||||
fs.writeFileSync(transcript, `${[user('fix it'), tool('s1', 'Skill', { skill: 'investigate' }), result('s1', 'Launching skill: investigate'), tool('e1', 'Edit', { file_path: target })].join('\n')}\n`);
|
||||
const r = runHook(path.join(ROOT, 'hosts', 'claude', 'hooks', 'investigate-gate.ts'),
|
||||
JSON.stringify({ session_id: 'sess-adv', transcript_path: transcript, cwd: repo, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: target }, tool_use_id: 'e1' }),
|
||||
hookEnv(state, skills, { HOME: home, GSTACK_INVESTIGATE_GATE: 'on' }));
|
||||
expect(JSON.parse(r.stdout).hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
expect(digest(skills)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* gstack-skill-gates (GST-1461): settings prints a --settings JSON that
|
||||
* registers the /investigate gate headless; report counts markers from the
|
||||
* session records and exits GATE_NOT_ARMED (2) or GATE_ERRORED (3).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { gateMarker } from '../lib/skill-gates';
|
||||
import { ROOT, tmpDir } from './helpers/skill-contract-fixture';
|
||||
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-skill-gates');
|
||||
let state = '';
|
||||
beforeEach(() => { state = tmpDir('gstack-skill-gates-'); });
|
||||
afterEach(() => { fs.rmSync(state, { recursive: true, force: true }); });
|
||||
const cli = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { env: { PATH: process.env.PATH ?? '', HOME: state, GSTACK_STATE_ROOT: state }, encoding: 'utf8', timeout: 20_000 });
|
||||
function seed(session: string, run: Record<string, unknown> | null, errors: Array<{ code: string }> = []) {
|
||||
const dir = path.join(state, 'skill-gates', session);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const runs = run ? { 'investigate@3': { skill: 'investigate', load_line: 3, fired_at: '2026-11-05T00:00:00Z', blocked: 0, observed: 0, ...run } } : {};
|
||||
fs.writeFileSync(path.join(dir, 'record.json'), JSON.stringify({ version: 1, session_id: session, transcript_path: '/t', hooks: { 'investigate-gate': { runs, errors: errors.map(e => ({ at: 'x', why: 'y', ...e })), skipped: {} } } }));
|
||||
}
|
||||
|
||||
test('the marker line has one fixed shape', () => {
|
||||
expect(gateMarker('investigate-gate', 'blocked', 'sess 1/../x', { skill: 'investigate', via: 'bash', files: 'a b.ts', n: 2 }))
|
||||
.toBe('GSTACK_GATE investigate-gate blocked session=sess1x skill=investigate via=bash files=a_b.ts n=2');
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
test('--investigate registers four events on this checkout\'s shims and turns the switch on', () => {
|
||||
const r = cli('settings', '--investigate');
|
||||
expect(r.status).toBe(0);
|
||||
const s = JSON.parse(r.stdout);
|
||||
expect(s.env).toEqual({ GSTACK_INVESTIGATE_GATE: 'on' });
|
||||
expect(Object.keys(s.hooks).sort()).toEqual(['PostToolUse', 'PostToolUseFailure', 'PreToolUse', 'Stop']);
|
||||
expect(s.hooks.PreToolUse[0].matcher).toBe('Edit|Write|MultiEdit|NotebookEdit|Bash');
|
||||
for (const entries of Object.values(s.hooks) as Array<Array<{ hooks: Array<{ command: string }> }>>) {
|
||||
const cmd = entries[0].hooks[0].command;
|
||||
expect(cmd.startsWith(path.join(ROOT, 'hosts', 'claude', 'hooks', 'investigate-gate-'))).toBe(true);
|
||||
expect(fs.statSync(cmd).mode & 0o111).not.toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('no hook named is a usage error', () => {
|
||||
const r = cli('settings');
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('SKILL_GATES_USAGE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('report', () => {
|
||||
test('counts fired, blocked and released per session', () => {
|
||||
seed('s1', { blocked: 2, released_at: '2026-11-05T00:01:00Z' });
|
||||
const r = cli('report', '--json');
|
||||
expect(r.status).toBe(0);
|
||||
expect(JSON.parse(r.stdout).rows).toEqual([{ session: 's1', hook: 'investigate-gate', runs: 1, fired: 1, blocked: 2, released: 1, observed: 0, errors: 0, error_codes: [], skipped: [] }]);
|
||||
});
|
||||
|
||||
test('--expect armed exits 2 GATE_NOT_ARMED for a session with no record or no fired run', () => {
|
||||
expect(cli('report', '--session', 'missing', '--expect', 'armed').status).toBe(2);
|
||||
seed('s2', null);
|
||||
const r = cli('report', '--session', 's2', '--expect', 'armed');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('GATE_NOT_ARMED');
|
||||
seed('s3', {});
|
||||
expect(cli('report', '--session', 's3', '--expect', 'armed').status).toBe(0);
|
||||
});
|
||||
|
||||
test('a recorded hook error exits 3 GATE_ERRORED with its code listed', () => {
|
||||
seed('s4', {}, [{ code: 'GATE_RESTORE_FAILED' }]);
|
||||
const r = cli('report');
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stdout).toContain('errors=1 (GATE_RESTORE_FAILED)');
|
||||
expect(r.stderr).toContain('GATE_ERRORED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headless smoke', () => {
|
||||
test('docs name the Claude Code version the smoke script pins', () => {
|
||||
const pinned = fs.readFileSync(path.join(ROOT, 'scripts', 'skill-gates-smoke.ts'), 'utf8').match(/^export const CLAUDE_CODE_VERSION = '([^']+)';$/m)?.[1];
|
||||
expect(pinned).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'docs', 'skill-gates.md'), 'utf8')).toContain(`verified at ${pinned})`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user