@devrik-tools/claude-gates 0.1.2 → 0.2.0

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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,147 +1,144 @@
1
- // protected-paths — denies writes and mutating shell commands that target a path
2
- // containing one of the project's protected fragments (.env, lockfiles, the harness
3
- // itself). Migrated from ~/.claude/hooks/guard-protected-paths.mjs: that guard protected
4
- // hardcoded "code directories" (src, tests) to force delegation through a Leader role.
5
- // This gate keeps only the path-protection mechanism — sensitive files no write should
6
- // ever touch — and drops the Leader-role/delegation policy, which is a different concern.
7
- //
8
- // ── What a project can configure (params) ───────────────────────────────────────────
9
- // protectedPaths path fragments (case-insensitive) that no write or mutating
10
- // command may target. Replaces the built-in list wholesale.
11
- // mutatingCommands shell command names treated as mutating, so a merely-reading
12
- // command that mentions a protected path (echo, cat, grep) is
13
- // never denied. Replaces the built-in list wholesale.
14
- // The defaults live here, in the source, so a project reads them and knows exactly what
15
- // its override replaces.
16
-
17
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
18
-
19
- const GATE_ID = 'protected-paths';
20
- const CONFIG_KEY = 'blockWritesToProtectedPaths';
21
-
22
- const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
23
- const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
24
-
25
- const DEFAULT_PROTECTED_PATHS = [
26
- '.env',
27
- 'package-lock.json',
28
- 'yarn.lock',
29
- 'pnpm-lock.yaml',
30
- 'hooks/',
31
- ];
32
-
33
- const DEFAULT_MUTATING_COMMANDS = [
34
- 'touch',
35
- 'rm',
36
- 'cp',
37
- 'mv',
38
- 'sed\\s+-i',
39
- 'tee',
40
- 'install',
41
- 'chmod',
42
- 'chown',
43
- 'truncate',
44
- ];
45
-
46
- function escapeRegExp(fragment) {
47
- return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
- }
49
-
50
- /** The path a write tool targets, across the field names different tools use. */
51
- function writeTargetFrom(toolInput) {
52
- return String(
53
- toolInput.TargetFile ??
54
- toolInput.target_file ??
55
- toolInput.file_path ??
56
- toolInput.path ??
57
- '',
58
- );
59
- }
60
-
61
- function isProtectedPath(path, protectedPaths) {
62
- const normalized = path.toLowerCase();
63
- return protectedPaths.some((fragment) =>
64
- normalized.includes(fragment.toLowerCase()),
65
- );
66
- }
67
-
68
- function buildCommandPatterns(protectedPaths, mutatingCommands) {
69
- const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
70
- const mutatingAlternation = mutatingCommands.join('|');
71
- const wordBoundary = String.raw`\b`;
72
- const redirectPrefix = String.raw`>>?\s*\S*`;
73
- return {
74
- mutatingCommand: new RegExp(
75
- `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
76
- 'i',
77
- ),
78
- protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
79
- redirectToProtected: new RegExp(
80
- `${redirectPrefix}(${pathAlternation})`,
81
- 'i',
82
- ),
83
- };
84
- }
85
-
86
- /** The write-tool branch: deny when the target path matches a protected fragment. */
87
- function checkWrite(toolInput, protectedPaths) {
88
- const target = writeTargetFrom(toolInput);
89
- if (target && isProtectedPath(target, protectedPaths)) {
90
- deny(
91
- GATE_ID,
92
- `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
93
- );
94
- }
95
- }
96
-
97
- /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
98
- function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
99
- const command = String(toolInput.CommandLine ?? toolInput.command ?? '');
100
- if (!command.trim()) return;
101
-
102
- const { mutatingCommand, protectedTarget, redirectToProtected } =
103
- buildCommandPatterns(protectedPaths, mutatingCommands);
104
-
105
- const targetsProtected =
106
- redirectToProtected.test(command) ||
107
- (mutatingCommand.test(command) && protectedTarget.test(command));
108
-
109
- if (targetsProtected) {
110
- deny(
111
- GATE_ID,
112
- `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
113
- 'Reading (echo/cat/grep) is fine; modifying it is not.',
114
- );
115
- }
116
- }
117
-
118
- runGate(
119
- {
120
- id: GATE_ID,
121
- configKey: CONFIG_KEY,
122
- enabledByDefault: true,
123
- defaultParams: {
124
- protectedPaths: DEFAULT_PROTECTED_PATHS,
125
- mutatingCommands: DEFAULT_MUTATING_COMMANDS,
126
- },
127
- },
128
- ({ toolName, toolInput, parameters }) => {
129
- const isWrite = WRITE_TOOLS.has(toolName);
130
- const isShell = SHELL_TOOLS.has(toolName);
131
- if (!isWrite && !isShell) return;
132
-
133
- const protectedPaths = parameters.protectedPaths ?? [];
134
- if (protectedPaths.length === 0) return;
135
-
136
- if (isWrite) {
137
- checkWrite(toolInput, protectedPaths);
138
- return;
139
- }
140
-
141
- checkShellCommand(
142
- toolInput,
143
- protectedPaths,
144
- parameters.mutatingCommands ?? [],
145
- );
146
- },
147
- );
1
+ // protected-paths — denies writes and mutating shell commands that target a path
2
+ // containing one of the project's protected fragments (.env, lockfiles, the harness
3
+ // itself). Migrated from ~/.claude/hooks/guard-protected-paths.mjs: that guard protected
4
+ // hardcoded "code directories" (src, tests) to force delegation through a Leader role.
5
+ // This gate keeps only the path-protection mechanism — sensitive files no write should
6
+ // ever touch — and drops the Leader-role/delegation policy, which is a different concern.
7
+ //
8
+ // ── What a project can configure (params) ───────────────────────────────────────────
9
+ // protectedPaths path fragments (case-insensitive) that no write or mutating
10
+ // command may target. Replaces the built-in list wholesale.
11
+ // mutatingCommands shell command names treated as mutating, so a merely-reading
12
+ // command that mentions a protected path (echo, cat, grep) is
13
+ // never denied. Replaces the built-in list wholesale.
14
+ // The defaults live here, in the source, so a project reads them and knows exactly what
15
+ // its override replaces.
16
+
17
+ import { runGate, deny, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
18
+
19
+ const GATE_ID = 'protected-paths';
20
+ const CONFIG_KEY = 'blockWritesToProtectedPaths';
21
+
22
+ const DEFAULT_PROTECTED_PATHS = [
23
+ '.env',
24
+ 'package-lock.json',
25
+ 'yarn.lock',
26
+ 'pnpm-lock.yaml',
27
+ 'hooks/',
28
+ ];
29
+
30
+ const DEFAULT_MUTATING_COMMANDS = [
31
+ 'touch',
32
+ 'rm',
33
+ 'cp',
34
+ 'mv',
35
+ 'sed\\s+-i',
36
+ 'tee',
37
+ 'install',
38
+ 'chmod',
39
+ 'chown',
40
+ 'truncate',
41
+ ];
42
+
43
+ function escapeRegExp(fragment) {
44
+ return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45
+ }
46
+
47
+ // Windows delivers absolute paths with backslashes ('C:\repo\hooks\gates\x.mjs'), but a
48
+ // protected fragment like 'hooks/' is written with a forward slash. Normalizing the
49
+ // separator before comparing means the fragment matches regardless of which OS produced
50
+ // the path, without weakening the existing case-insensitive comparison.
51
+ function toForwardSlashes(path) {
52
+ return path.replace(/\\/g, '/');
53
+ }
54
+
55
+ function isProtectedPath(path, protectedPaths) {
56
+ const normalized = toForwardSlashes(path).toLowerCase();
57
+ return protectedPaths.some((fragment) =>
58
+ normalized.includes(fragment.toLowerCase()),
59
+ );
60
+ }
61
+
62
+ function buildCommandPatterns(protectedPaths, mutatingCommands) {
63
+ const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
64
+ const mutatingAlternation = mutatingCommands.join('|');
65
+ const wordBoundary = String.raw`\b`;
66
+ const redirectPrefix = String.raw`>>?\s*\S*`;
67
+ return {
68
+ mutatingCommand: new RegExp(
69
+ `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
70
+ 'i',
71
+ ),
72
+ protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
73
+ redirectToProtected: new RegExp(
74
+ `${redirectPrefix}(${pathAlternation})`,
75
+ 'i',
76
+ ),
77
+ };
78
+ }
79
+
80
+ /** The write-tool branch: deny when the target path matches a protected fragment. */
81
+ function checkWrite(toolInput, protectedPaths) {
82
+ const target = writtenPathOf(toolInput);
83
+ if (target && isProtectedPath(target, protectedPaths)) {
84
+ deny(
85
+ GATE_ID,
86
+ `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
87
+ );
88
+ }
89
+ }
90
+
91
+ /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
92
+ function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
93
+ const rawCommand = String(toolInput.CommandLine ?? toolInput.command ?? '');
94
+ if (!rawCommand.trim()) return;
95
+ // Same separator normalization as the write branch: a command embedding a Windows
96
+ // path ('rm C:\repo\hooks\gates\evil.mjs') must still match a 'hooks/' fragment.
97
+ const command = toForwardSlashes(rawCommand);
98
+
99
+ const { mutatingCommand, protectedTarget, redirectToProtected } =
100
+ buildCommandPatterns(protectedPaths, mutatingCommands);
101
+
102
+ const targetsProtected =
103
+ redirectToProtected.test(command) ||
104
+ (mutatingCommand.test(command) && protectedTarget.test(command));
105
+
106
+ if (targetsProtected) {
107
+ deny(
108
+ GATE_ID,
109
+ `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
110
+ 'Reading (echo/cat/grep) is fine; modifying it is not.',
111
+ );
112
+ }
113
+ }
114
+
115
+ runGate(
116
+ {
117
+ id: GATE_ID,
118
+ configKey: CONFIG_KEY,
119
+ enabledByDefault: true,
120
+ defaultParams: {
121
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
122
+ mutatingCommands: DEFAULT_MUTATING_COMMANDS,
123
+ },
124
+ },
125
+ ({ toolName, toolInput, parameters }) => {
126
+ const isWrite = toolInGroups(toolName, ['write']);
127
+ const isShell = toolInGroups(toolName, ['shell']);
128
+ if (!isWrite && !isShell) return;
129
+
130
+ const protectedPaths = parameters.protectedPaths ?? [];
131
+ if (protectedPaths.length === 0) return;
132
+
133
+ if (isWrite) {
134
+ checkWrite(toolInput, protectedPaths);
135
+ return;
136
+ }
137
+
138
+ checkShellCommand(
139
+ toolInput,
140
+ protectedPaths,
141
+ parameters.mutatingCommands ?? [],
142
+ );
143
+ },
144
+ );
@@ -0,0 +1,126 @@
1
+ // Edge-case audit for protected-paths: bypasses and false positives.
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
+
12
+ function runGate(payload, { config } = {}) {
13
+ const project = mkdtempSync(join(tmpdir(), 'protected-paths-edge-'));
14
+ mkdirSync(join(project, '.git'));
15
+ if (config) {
16
+ mkdirSync(join(project, '.ai'));
17
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
18
+ }
19
+ const out = execFileSync(process.execPath, [GATE], {
20
+ input: JSON.stringify(payload),
21
+ encoding: 'utf8',
22
+ cwd: project,
23
+ env: { ...process.env, HOME: project, USERPROFILE: project },
24
+ });
25
+ return out.trim() ? JSON.parse(out.trim()) : null;
26
+ }
27
+
28
+ function write(filePath) {
29
+ return { tool_name: 'Write', tool_input: { file_path: filePath } };
30
+ }
31
+ function edit(filePath) {
32
+ return { tool_name: 'Edit', tool_input: { file_path: filePath } };
33
+ }
34
+ function bash(command) {
35
+ return { tool_name: 'Bash', tool_input: { command } };
36
+ }
37
+ function mcpTool(name, input) {
38
+ return { tool_name: name, tool_input: input };
39
+ }
40
+ function isDeny(result) {
41
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
42
+ }
43
+
44
+ // ── FIXED: toolInGroups classifies an MCP tool by its action segment, so an MCP write
45
+ // tool is now recognized regardless of its exact name.
46
+ test('FIXED: mcp filesystem write_file targeting .env is now recognized as a write tool', () => {
47
+ const result = runGate(
48
+ mcpTool('mcp__filesystem__write_file', { path: '/repo/.env', content: 'X=1' }),
49
+ );
50
+ assert.ok(isDeny(result));
51
+ });
52
+
53
+ test('FIXED: mcp filesystem edit_file targeting package-lock.json is now recognized', () => {
54
+ const result = runGate(
55
+ mcpTool('mcp__filesystem__edit_file', {
56
+ file_path: '/repo/package-lock.json',
57
+ diff: '...',
58
+ }),
59
+ );
60
+ assert.ok(isDeny(result));
61
+ });
62
+
63
+ // ── FIXED: an mcp shell-equivalent tool with a mutating command against .env is now
64
+ // recognized too, via toolInGroups' shell signal.
65
+ test('FIXED: mcp shell-equivalent tool removing .env is now recognized as a shell tool', () => {
66
+ const result = runGate(mcpTool('mcp__shell__run', { command: 'rm .env' }));
67
+ assert.ok(isDeny(result));
68
+ });
69
+
70
+ // ── BUG candidate: chained command where the mutating verb and the protected path are
71
+ // split across a chain, e.g. `echo hi && rm .env` — mutatingCommand.test and
72
+ // protectedTarget.test both run against the FULL command string independently (not
73
+ // requiring adjacency), so this should still be caught. Confirm.
74
+ test('OK: mutating command chained after a harmless one is still caught', () => {
75
+ assert.ok(isDeny(runGate(bash('echo hi && rm .env'))));
76
+ });
77
+
78
+ // ── BUG candidate: redirect-to-protected pattern `>>?\s*\S*(protected)` requires the
79
+ // protected fragment to appear immediately after the redirect target token (no space
80
+ // before it beyond \s*, and \S* is greedy so it consumes up to whitespace). A redirect
81
+ // with a relative path prefix like `echo x > ./.env` should still match since '.env' is
82
+ // a substring inside './.env'. Confirm.
83
+ test('OK: redirect to a protected path via a relative ./ prefix is still caught', () => {
84
+ assert.ok(isDeny(runGate(bash('echo "X=1" > ./.env'))));
85
+ });
86
+
87
+ // ── BUG: the redirect pattern anchors on `>>?\s*\S*(protected)` — but it does not
88
+ // require a mutating command AT ALL; a bare redirect from an allowed "reading" command
89
+ // like `cat foo > .env` is denied correctly. But what about protected-path matches
90
+ // inside an UNQUOTED argument to a non-mutating, non-redirecting command that merely
91
+ // happens to reference the filename without any real risk, e.g. `node --check .env`?
92
+ // mutatingCommand list doesn't include 'node', and there's no redirect, so
93
+ // targetsProtected is false — correctly allowed. Confirm to make sure the "read/lint a
94
+ // protected path" case is not a false positive.
95
+ test('OK: a non-mutating command that only references a protected filename is allowed', () => {
96
+ assert.equal(runGate(bash('node --check .env')), null);
97
+ });
98
+
99
+ // ── BUG: case sensitivity / path separator on Windows. protectedTarget building lower-
100
+ // cases the path fragment comparison for isProtectedPath (writes) via .toLowerCase(), but
101
+ // for the SHELL branch, buildCommandPatterns compiles patterns with the 'i' flag, so case
102
+ // should be handled there too. Confirm mixed-case doesn't bypass either branch.
103
+ test('OK: mixed-case protected path does not bypass either the write or shell branch', () => {
104
+ assert.ok(isDeny(runGate(write('/repo/.ENV'))));
105
+ assert.ok(isDeny(runGate(bash('rm .ENV'))));
106
+ });
107
+
108
+ // ── FIXED: the write and shell branches normalize backslashes to forward slashes before
109
+ // comparing, so a Windows-style absolute path under hooks\ now matches the 'hooks/'
110
+ // protected fragment.
111
+ test('FIXED: windows backslash path under hooks\\ now matches the "hooks/" protected fragment', () => {
112
+ const result = runGate(write('C:\\repo\\hooks\\gates\\evil.mjs'));
113
+ assert.ok(isDeny(result));
114
+ });
115
+
116
+ // ── BUG: sed -i is in mutatingCommands as the two-token regex source `sed\\s+-i` joined
117
+ // into one big alternation with \b...\b around the WHOLE alternation, not each term. This
118
+ // means the outer \b applies to the first alternative's boundary only for single-word
119
+ // terms; for the multi-word 'sed\\s+-i' term, \b anchors immediately before 's' and after
120
+ // the LAST alternative in the group, not after 'sed\s+-i' locally — but since it's inside
121
+ // one non-capturing pattern (`\b(a|b|sed\s+-i|c)\b`), the trailing \b applies right after
122
+ // whatever alternative matched, i.e. after "-i", which IS a word boundary. Confirm sed -i
123
+ // against a protected path is still caught (not a bug) before moving on.
124
+ test('OK: sed -i against a protected path is still caught', () => {
125
+ assert.ok(isDeny(runGate(bash('sed -i "s/a/b/" .env'))));
126
+ });
@@ -1,64 +1,89 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
4
-
5
- const GATE_ID = 'recurrence-lock';
6
- const CONFIG_KEY = 'blockRegisteredRecurrences';
7
-
8
- // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
9
- // from a full tracking module (../scripts/memory/recurrences.mjs) with
10
- // close/decide/pending subcommands. That infrastructure belongs to another
11
- // project and does not exist here. This gate only reads a plain state file
12
- // at .ai/reincidencias.json if present, with the shape:
13
- // { classes: [{ class, occurrences: [...], status }] }
14
- // If neither the module nor the file exists, it allows silently there is
15
- // nothing to enforce without a recurrence record.
16
- function loadOpenRecurrences(projectRoot, thresholdAppearances) {
17
- const filePath = join(projectRoot, '.ai', 'reincidencias.json');
18
- if (!existsSync(filePath)) return [];
19
-
20
- let parsed;
21
- try {
22
- parsed = JSON.parse(readFileSync(filePath, 'utf8'));
23
- } catch {
24
- return [];
25
- }
26
-
27
- const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
28
- return classes.filter((entry) => {
29
- const occurrenceCount = Array.isArray(entry?.occurrences)
30
- ? entry.occurrences.length
31
- : 0;
32
- const status = String(entry?.status ?? '').toLowerCase();
33
- const isClosed = status === 'closed' || status === 'cerrada';
34
- return occurrenceCount >= thresholdAppearances && !isClosed;
35
- });
36
- }
37
-
38
- runGate(
39
- {
40
- id: GATE_ID,
41
- configKey: CONFIG_KEY,
42
- enabledByDefault: true,
43
- defaultParams: {
44
- thresholdAppearances: 2,
45
- },
46
- },
47
- ({ toolName, parameters }) => {
48
- const isExecution = TOOL_GROUPS.execution.includes(toolName);
49
- const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
50
- if (!isExecution && !isDelegation) return;
51
-
52
- const openRecurrences = loadOpenRecurrences(
53
- process.cwd(),
54
- parameters.thresholdAppearances,
55
- );
56
- if (openRecurrences.length === 0) return;
57
-
58
- const names = openRecurrences.map((entry) => entry.class).join(', ');
59
- deny(
60
- GATE_ID,
61
- `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
62
- );
63
- },
64
- );
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
4
+
5
+ const GATE_ID = 'recurrence-lock';
6
+ const CONFIG_KEY = 'blockRegisteredRecurrences';
7
+
8
+ /**
9
+ * De-duplicates occurrences before counting them against the threshold. An occurrence is
10
+ * identified by its `id`/`hash` field when present (the intended identity for a logged
11
+ * occurrence); a bare-scalar entry (number/string, as in the plain fixture shape used by
12
+ * tests) is deduplicated by its own value instead, since it carries no other identity.
13
+ * Without this, the same occurrence logged twice (a race or a bug in the writer) inflates
14
+ * the count and trips the lock as if two distinct occurrences had happened.
15
+ */
16
+ function dedupOccurrences(occurrences) {
17
+ const seen = new Set();
18
+ const deduped = [];
19
+ for (const occurrence of occurrences) {
20
+ const identity =
21
+ occurrence && typeof occurrence === 'object'
22
+ ? String(occurrence.id ?? occurrence.hash ?? JSON.stringify(occurrence))
23
+ : String(occurrence);
24
+ if (seen.has(identity)) continue;
25
+ seen.add(identity);
26
+ deduped.push(occurrence);
27
+ }
28
+ return deduped;
29
+ }
30
+
31
+ // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
32
+ // from a full tracking module (../scripts/memory/recurrences.mjs) with
33
+ // close/decide/pending subcommands. That infrastructure belongs to another
34
+ // project and does not exist here. This gate only reads a plain state file
35
+ // at .ai/reincidencias.json if present, with the shape:
36
+ // { classes: [{ class, occurrences: [...], status }] }
37
+ // If neither the module nor the file exists, it allows silently — there is
38
+ // nothing to enforce without a recurrence record.
39
+ function loadOpenRecurrences(projectRoot, thresholdAppearances) {
40
+ const filePath = join(projectRoot, '.ai', 'reincidencias.json');
41
+ if (!existsSync(filePath)) return [];
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(readFileSync(filePath, 'utf8'));
46
+ } catch {
47
+ return [];
48
+ }
49
+
50
+ const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
51
+ return classes.filter((entry) => {
52
+ const occurrences = Array.isArray(entry?.occurrences)
53
+ ? dedupOccurrences(entry.occurrences)
54
+ : [];
55
+ const occurrenceCount = occurrences.length;
56
+ // Trim before lowercasing so a padded status ("Closed ", "cerrada\n") is recognized —
57
+ // a status compared without trimming left a validly-closed (but padded) recurrence
58
+ // stuck as still-open, blocking unrelated work indefinitely.
59
+ const status = String(entry?.status ?? '').trim().toLowerCase();
60
+ const isClosed = status === 'closed' || status === 'cerrada';
61
+ return occurrenceCount >= thresholdAppearances && !isClosed;
62
+ });
63
+ }
64
+
65
+ runGate(
66
+ {
67
+ id: GATE_ID,
68
+ configKey: CONFIG_KEY,
69
+ enabledByDefault: true,
70
+ defaultParams: {
71
+ thresholdAppearances: 2,
72
+ },
73
+ },
74
+ ({ toolName, parameters }) => {
75
+ if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
76
+
77
+ const openRecurrences = loadOpenRecurrences(
78
+ process.cwd(),
79
+ parameters.thresholdAppearances,
80
+ );
81
+ if (openRecurrences.length === 0) return;
82
+
83
+ const names = openRecurrences.map((entry) => entry.class).join(', ');
84
+ deny(
85
+ GATE_ID,
86
+ `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
87
+ );
88
+ },
89
+ );