@devrik-tools/claude-gates 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +45 -0
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +58 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +37 -2
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +13 -0
- package/plugins/gates/hooks/hooks.json +20 -0
- package/registry.json +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// autonomous-mode — when the project turns on autonomous mode, the assistant must stop
|
|
2
|
+
// asking and decide. This gate denies the AskUserQuestion tool while the mode is on, so a
|
|
3
|
+
// question popup cannot interrupt an unattended run. The assistant is expected to take the
|
|
4
|
+
// best, aligned decision and state the reversible assumption instead of asking.
|
|
5
|
+
//
|
|
6
|
+
// justification: no existing tool covers this. It is the deterministic half of "run without
|
|
7
|
+
// asking": a gate can block the question tool; the prose reminder (via the session-start
|
|
8
|
+
// hook and this gate's message) covers the rest, which a hook cannot force.
|
|
9
|
+
//
|
|
10
|
+
// ── When it acts ────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Only when autonomousMode is enabled in .ai/config.json (project) or the global config.
|
|
12
|
+
// Off by default. The user is also reminded at session start that the mode is on (see the
|
|
13
|
+
// session hook), in case they forgot to turn it off.
|
|
14
|
+
//
|
|
15
|
+
// ── What it does NOT do ─────────────────────────────────────────────────────────────
|
|
16
|
+
// It blocks the AskUserQuestion TOOL. It cannot stop the assistant from asking in plain
|
|
17
|
+
// prose (no hook sees chat text) — the injected reminder pushes against that, but the only
|
|
18
|
+
// deterministic lever is the question tool, and this gate pulls it.
|
|
19
|
+
|
|
20
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
21
|
+
|
|
22
|
+
const GATE_ID = 'autonomous-mode';
|
|
23
|
+
const CONFIG_KEY = 'autonomousMode';
|
|
24
|
+
|
|
25
|
+
const QUESTION_TOOLS = new Set(TOOL_GROUPS.question);
|
|
26
|
+
|
|
27
|
+
const DENY_MESSAGE =
|
|
28
|
+
'Autonomous mode is ON for this project: do not ask the user. Take the best decision ' +
|
|
29
|
+
'that is aligned with the goal and the project rules, state the reversible assumption you ' +
|
|
30
|
+
'made, and proceed. Only a genuinely irreversible or dangerous choice (deleting data, ' +
|
|
31
|
+
'money, production) would justify stopping — and then say so in prose, do not use the ' +
|
|
32
|
+
'question popup. To let questions through again, set "autonomousMode": false in ' +
|
|
33
|
+
'.ai/config.json.';
|
|
34
|
+
|
|
35
|
+
runGate(
|
|
36
|
+
{
|
|
37
|
+
id: GATE_ID,
|
|
38
|
+
configKey: CONFIG_KEY,
|
|
39
|
+
enabledByDefault: false,
|
|
40
|
+
},
|
|
41
|
+
({ toolName }) => {
|
|
42
|
+
if (!QUESTION_TOOLS.has(toolName)) return;
|
|
43
|
+
deny(GATE_ID, DENY_MESSAGE);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
// Runs the gate in a temp project with HOME isolated (so the real global config is not
|
|
12
|
+
// read). `autonomous` writes the flag on. Returns the parsed deny output, or null.
|
|
13
|
+
function runGate(payload, { autonomous } = {}) {
|
|
14
|
+
const project = mkdtempSync(join(tmpdir(), 'autonomous-mode-'));
|
|
15
|
+
mkdirSync(join(project, '.git'));
|
|
16
|
+
mkdirSync(join(project, '.ai'));
|
|
17
|
+
writeFileSync(
|
|
18
|
+
join(project, '.ai', 'config.json'),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
gates: { autonomousMode: { enabled: Boolean(autonomous) } },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
+
input: JSON.stringify(payload),
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
cwd: project,
|
|
27
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
+
});
|
|
29
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ask() {
|
|
33
|
+
return {
|
|
34
|
+
tool_name: 'AskUserQuestion',
|
|
35
|
+
tool_input: { questions: [{ question: 'A or B?' }] },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function isDeny(result) {
|
|
39
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('autonomous mode ON: AskUserQuestion is denied', () => {
|
|
43
|
+
assert.ok(isDeny(runGate(ask(), { autonomous: true })));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('autonomous mode OFF (default): questions pass', () => {
|
|
47
|
+
assert.equal(runGate(ask(), { autonomous: false }), null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('even ON, a non-question tool is never blocked', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
runGate(
|
|
53
|
+
{ tool_name: 'Write', tool_input: { file_path: 'x.js', content: '' } },
|
|
54
|
+
{ autonomous: true },
|
|
55
|
+
),
|
|
56
|
+
null,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -125,6 +125,34 @@ function compile(source) {
|
|
|
125
125
|
return new RegExp(source, 'i');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// git's GLOBAL options sit between `git` and the subcommand: `git -C <path> reset --hard`,
|
|
129
|
+
// `git -c k=v push`, `git --git-dir=… clean -f`. A pattern that matches `git reset --hard`
|
|
130
|
+
// contiguously is evaded by any of them. Stripping these options first — turning
|
|
131
|
+
// `git -C /repo reset --hard` back into `git reset --hard` — closes that bypass for every
|
|
132
|
+
// git rule at once, instead of teaching each pattern about every global option.
|
|
133
|
+
//
|
|
134
|
+
// A single global option, matched one at a time and stripped repeatedly (below), so the
|
|
135
|
+
// pattern stays simple: an option taking a value (`-C /path`, `--git-dir=…`) or a flag
|
|
136
|
+
// (`--no-pager`). The leading `git ` is kept; only the option after it is removed.
|
|
137
|
+
const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
|
|
138
|
+
const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
|
|
139
|
+
const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
|
|
140
|
+
String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
|
|
141
|
+
'i',
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
function normalizeGitOptions(command) {
|
|
145
|
+
// Strip one leading global option at a time and re-run, so a stacked
|
|
146
|
+
// `git -c a=b -C /x reset` is fully reduced to `git reset` before the deny patterns run.
|
|
147
|
+
let previous;
|
|
148
|
+
let normalized = command;
|
|
149
|
+
do {
|
|
150
|
+
previous = normalized;
|
|
151
|
+
normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
|
|
152
|
+
} while (normalized !== previous);
|
|
153
|
+
return normalized;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
function stripQuoted(text) {
|
|
129
157
|
return text
|
|
130
158
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
@@ -171,6 +199,10 @@ function commandTextFrom(toolName, toolInput) {
|
|
|
171
199
|
|
|
172
200
|
/** Static deny rules + the runtime rm -rf rule, both read from config params. */
|
|
173
201
|
function checkDestructive(command, parameters) {
|
|
202
|
+
// Strip git's global options so `git -C /repo reset --hard` cannot slip past a pattern
|
|
203
|
+
// written for `git reset --hard`. Non-git commands are unaffected.
|
|
204
|
+
const normalized = normalizeGitOptions(command);
|
|
205
|
+
|
|
174
206
|
// denyPatterns may be a flat list of sources or [source, reason] pairs; normalize.
|
|
175
207
|
const denyPairs = (parameters.denyPatterns ?? []).map((entry) =>
|
|
176
208
|
Array.isArray(entry)
|
|
@@ -178,7 +210,7 @@ function checkDestructive(command, parameters) {
|
|
|
178
210
|
: [entry, 'Destructive command is not allowed.'],
|
|
179
211
|
);
|
|
180
212
|
for (const [source, reason] of denyPairs) {
|
|
181
|
-
if (compile(source).test(
|
|
213
|
+
if (compile(source).test(normalized)) deny(GATE_ID, reason);
|
|
182
214
|
}
|
|
183
215
|
|
|
184
216
|
// rm -rf over a protected area: areas read from config at runtime, so editing
|
|
@@ -200,10 +232,13 @@ function checkDestructive(command, parameters) {
|
|
|
200
232
|
|
|
201
233
|
/** Remote-publish rules: a real command is checked literally; a delegation prompt by intent. */
|
|
202
234
|
function checkRemotePublish(command, isShell) {
|
|
235
|
+
// For a real shell command, normalize git's global options first (same bypass as above).
|
|
236
|
+
// For a delegation prompt (free text), the intent check runs on the raw text.
|
|
237
|
+
const shellCommand = normalizeGitOptions(command);
|
|
203
238
|
for (const [source, reason] of REMOTE_PUBLISH_RULES) {
|
|
204
239
|
const pattern = compile(source);
|
|
205
240
|
if (isShell) {
|
|
206
|
-
if (pattern.test(
|
|
241
|
+
if (pattern.test(shellCommand)) deny(GATE_ID, reason);
|
|
207
242
|
} else if (hasRealPublishIntent(command, pattern)) {
|
|
208
243
|
deny(GATE_ID, reason);
|
|
209
244
|
}
|
|
@@ -51,6 +51,19 @@ test('allows an innocuous command', () => {
|
|
|
51
51
|
assert.equal(runGate(bash('rm -rf ./build/cache')), null);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("git's global options (-C, -c, --git-dir) cannot smuggle a destructive subcommand past", () => {
|
|
55
|
+
// Regression: `git -C /path reset --hard` used to slip past the `git reset --hard` pattern
|
|
56
|
+
// because the option sat between `git` and the subcommand. Normalization strips it first.
|
|
57
|
+
assert.ok(isDeny(runGate(bash('git -C /home/code/orca reset --hard HEAD'))));
|
|
58
|
+
assert.ok(isDeny(runGate(bash('git -c core.editor=vim reset --hard'))));
|
|
59
|
+
assert.ok(isDeny(runGate(bash('git --git-dir=/x reset --hard'))));
|
|
60
|
+
assert.ok(isDeny(runGate(bash('git -c a=b -C /x clean -fd'))));
|
|
61
|
+
assert.ok(isDeny(runGate(bash('git -C /repo push origin main'))));
|
|
62
|
+
// A global option on a harmless subcommand is still allowed.
|
|
63
|
+
assert.equal(runGate(bash('git -C /repo status')), null);
|
|
64
|
+
assert.equal(runGate(bash('git reset --soft HEAD')), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
54
67
|
test('denies a plain git push (remote publish needs fresh authorization)', () => {
|
|
55
68
|
assert.ok(isDeny(runGate(bash('git push origin main'))));
|
|
56
69
|
assert.ok(isDeny(runGate(bash('gh pr merge 12'))));
|
|
@@ -260,6 +260,26 @@
|
|
|
260
260
|
"timeout": 10
|
|
261
261
|
}
|
|
262
262
|
]
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command",
|
|
266
|
+
"hooks": [
|
|
267
|
+
{
|
|
268
|
+
"type": "command",
|
|
269
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/forge-flow/index.mjs\"",
|
|
270
|
+
"timeout": 10
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"matcher": "AskUserQuestion",
|
|
276
|
+
"hooks": [
|
|
277
|
+
{
|
|
278
|
+
"type": "command",
|
|
279
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/autonomous-mode/index.mjs\"",
|
|
280
|
+
"timeout": 10
|
|
281
|
+
}
|
|
282
|
+
]
|
|
263
283
|
}
|
|
264
284
|
]
|
|
265
285
|
}
|
package/registry.json
CHANGED
|
@@ -630,6 +630,24 @@
|
|
|
630
630
|
}
|
|
631
631
|
]
|
|
632
632
|
},
|
|
633
|
+
{
|
|
634
|
+
"id": "autonomy",
|
|
635
|
+
"name": "Autonomy",
|
|
636
|
+
"description": "Autonomous mode: block the question popup so an unattended run decides instead of asking.",
|
|
637
|
+
"gates": [
|
|
638
|
+
{
|
|
639
|
+
"id": "autonomous-mode",
|
|
640
|
+
"configKey": "autonomousMode",
|
|
641
|
+
"default": false,
|
|
642
|
+
"event": "PreToolUse",
|
|
643
|
+
"tools": [
|
|
644
|
+
"question"
|
|
645
|
+
],
|
|
646
|
+
"description": "When autonomous mode is on, denies AskUserQuestion so the assistant decides instead of asking.",
|
|
647
|
+
"script": "gates/autonomous-mode/index.mjs"
|
|
648
|
+
}
|
|
649
|
+
]
|
|
650
|
+
},
|
|
633
651
|
{
|
|
634
652
|
"id": "session",
|
|
635
653
|
"name": "Session start",
|