@devrik-tools/claude-gates 0.1.0 → 0.1.1
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
@@ -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'))));
|