diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 9a4bb0314..f244fa667 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -106,6 +106,38 @@ near-identical blocks cannot accumulate in the context window and amplify model repetition loops (#2142). Retrying the same file or command after presenting facts never re-triggers the gate. +#### Graduated controls + +`ECC_GATEGUARD=off` disables the whole gate. The variables below narrow it +instead, so the load-bearing destructive-Bash checks keep running: + +| Variable | Default | Effect | +|---|---|---| +| `GATEGUARD_BASH_ROUTINE_DISABLED` | unset (gate on) | Disables the **routine-Bash** gate only. The destructive-Bash gate (`rm -rf`, `git reset --hard`, `drop table`, `dd if=`, …) is unaffected. | +| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit target skips first-touch fact-forcing. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. | +| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full four-fact block before later ones condense to a single line. `0` condenses from the very first denial. | +| `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. | +| `GATEGUARD_DISABLED` | unset | `1` disables the gate entirely — equivalent to `ECC_GATEGUARD=off`. | +| `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. | + +`GATEGUARD_BASH_ROUTINE_DISABLED` accepts `1`, `true`, `on`, `enabled`, +`enable`, or `yes` (case- and whitespace-insensitive); any other value +leaves the gate on. `GATEGUARD_DISABLED` recognises `1` only. + +`GATEGUARD_EXEMPT_GLOBS` patterns are matched against the normalized +(forward-slash, lowercased) file path: `*` matches within a path segment, +`**` across segments, `?` a single character. Matching is fail-open — a +malformed pattern is dropped rather than raising. + +```json +{ + "env": { + "GATEGUARD_BASH_ROUTINE_DISABLED": "1", + "GATEGUARD_EXEMPT_GLOBS": "**/tests/**,**/*.test.*,**/docs/**,**/dist/**" + } +} +``` + ### Option B: Full package with config ```bash diff --git a/tests/ci/gateguard-env-documented.test.js b/tests/ci/gateguard-env-documented.test.js new file mode 100644 index 000000000..4f6b4744b --- /dev/null +++ b/tests/ci/gateguard-env-documented.test.js @@ -0,0 +1,88 @@ +/** + * Surface test for #2573: every GATEGUARD_* environment variable the hook + * reads must be documented in the GateGuard skill doc. + * + * `GATEGUARD_BASH_ROUTINE_DISABLED` shipped with no documentation at all and + * `GATEGUARD_EXEMPT_GLOBS` was mentioned only in a release note, so operators + * had no discoverable way to narrow the gate short of disabling it outright. + * This pins the surface: adding a knob to the hook without documenting it + * fails here. + * + * Run with: node tests/ci/gateguard-env-documented.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..', '..'); +const hookPath = path.join(repoRoot, 'scripts', 'hooks', 'gateguard-fact-force.js'); +const skillPath = path.join(repoRoot, 'skills', 'gateguard', 'SKILL.md'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function readGateguardEnvNames(source) { + // process.env.GATEGUARD_X and process.env['GATEGUARD_X'] + const names = new Set(); + const dotted = /process\.env\.(GATEGUARD_[A-Z0-9_]+)/g; + const bracketed = /process\.env\[\s*['"](GATEGUARD_[A-Z0-9_]+)['"]\s*\]/g; + let m; + while ((m = dotted.exec(source)) !== null) names.add(m[1]); + while ((m = bracketed.exec(source)) !== null) names.add(m[1]); + return names; +} + +console.log('\nGateGuard env-var documentation surface\n'); + +if (test('hook and skill doc both exist', () => { + assert.ok(fs.existsSync(hookPath), `missing ${hookPath}`); + assert.ok(fs.existsSync(skillPath), `missing ${skillPath}`); +})) passed++; else failed++; + +const hookSource = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : ''; +const skillDoc = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : ''; +const envNames = readGateguardEnvNames(hookSource); + +if (test('hook reads at least one GATEGUARD_* variable', () => { + assert.ok(envNames.size > 0, 'no GATEGUARD_* env reads found - has the hook moved?'); +})) passed++; else failed++; + +if (test('every GATEGUARD_* variable the hook reads is documented', () => { + const undocumented = [...envNames].filter(name => !skillDoc.includes(name)).sort(); + assert.deepStrictEqual( + undocumented, + [], + `undocumented in skills/gateguard/SKILL.md: ${undocumented.join(', ')}` + ); +})) passed++; else failed++; + +if (test('the documented knobs are the ones the hook actually reads', () => { + // Guards the reverse drift: a doc naming a knob the hook no longer reads. + const documented = [...new Set( + (skillDoc.match(/GATEGUARD_[A-Z0-9_]+/g) || []) + )]; + const stale = documented.filter(name => !hookSource.includes(name)).sort(); + assert.deepStrictEqual(stale, [], `documented but unread by the hook: ${stale.join(', ')}`); +})) passed++; else failed++; + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}\n`); + +if (failed > 0) { + process.exit(1); +}