@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.0",
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(command)) deny(GATE_ID, reason);
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(command)) deny(GATE_ID, reason);
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'))));