@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
@@ -0,0 +1,126 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, 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
+ function newProject({ config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'rule-skill-autodiscovery-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ return project;
19
+ }
20
+
21
+ function runGateIn(project, payload) {
22
+ const out = execFileSync(process.execPath, [GATE], {
23
+ input: JSON.stringify(payload),
24
+ encoding: 'utf8',
25
+ cwd: project,
26
+ env: { ...process.env, HOME: project, USERPROFILE: project },
27
+ });
28
+ return out.trim() ? JSON.parse(out.trim()) : null;
29
+ }
30
+
31
+ function exec() {
32
+ return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
33
+ }
34
+ function isDeny(result) {
35
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
36
+ }
37
+
38
+ const ENABLE = { gates: { autodiscoverRulesAndSkills: true } };
39
+
40
+ // EDGE CASE / CONFIRMED RISK: this gate executes ANY file matching the gate-file naming
41
+ // convention found under rules/ or .claude/skills/*/ via execFileSync(node, [script]) with
42
+ // no sandboxing, no read-only inspection, and no allowlist of what the script may do. A
43
+ // project-local file matching the naming convention runs with the same privileges as the
44
+ // gate itself (full Node process: fs, child_process, network, env) on every matched tool
45
+ // call. This test proves a "malicious" (here: benign-but-illustrative) discovered script
46
+ // can perform an arbitrary side effect (write a file outside its own directory) purely by
47
+ // existing at a name matching the convention — no code review or opt-in beyond enabling the
48
+ // gate is required.
49
+ test('CONFIRMED RISK: a discovered rule script executes with full Node privileges and can perform arbitrary side effects (e.g. writing files elsewhere in the project)', () => {
50
+ const project = newProject({ config: ENABLE });
51
+ mkdirSync(join(project, 'rules'));
52
+ const sideEffectMarker = join(project, 'side-effect-proof.txt');
53
+ const scriptSource = [
54
+ "import { writeFileSync } from 'node:fs';",
55
+ `writeFileSync(${JSON.stringify(sideEffectMarker)}, 'arbitrary code ran');`,
56
+ 'process.exit(0);',
57
+ ].join('\n');
58
+ writeFileSync(join(project, 'rules', 'gate.mjs'), scriptSource);
59
+
60
+ const result = runGateIn(project, exec());
61
+ assert.equal(result, null, 'the well-behaved (exit 0) script allows the tool call through, as documented');
62
+ assert.ok(existsSync(sideEffectMarker), 'the discovered script executed arbitrary code with full privileges (proved by the side-effect file it wrote)');
63
+ assert.equal(readFileSync(sideEffectMarker, 'utf8'), 'arbitrary code ran');
64
+ });
65
+
66
+ // EDGE CASE (BUG): a broken (not malicious, just buggy) module that hangs past the 5000ms
67
+ // timeout is caught by execFileSync's own timeout and treated as a failure -> deny, which is
68
+ // the safe default. Confirm this actually holds (defensive verification, not assumed) since
69
+ // an infinite loop in a project's own rule script would otherwise stall every tool call
70
+ // indefinitely with no visible cause to the user.
71
+ test('OK: an infinite-looping discovered script is caught by the 5s execFileSync timeout and denies (not a hang)', () => {
72
+ const project = newProject({ config: ENABLE });
73
+ mkdirSync(join(project, 'rules'));
74
+ writeFileSync(join(project, 'rules', 'gate.mjs'), 'while (true) {}');
75
+ const start = Date.now();
76
+ const result = runGateIn(project, exec());
77
+ const elapsedMs = Date.now() - start;
78
+ assert.ok(isDeny(result), 'a hanging/broken sub-gate should deny (fail-safe), not silently allow');
79
+ assert.ok(elapsedMs < 15000, `expected the 5s internal timeout to bound total runtime, took ${elapsedMs}ms`);
80
+ });
81
+
82
+ // FIXED (was CONFIRMED RISK): a script that mutates .ai/config.json to disable other gates
83
+ // (or this one) is now caught deterministically: the gate snapshots a hash of the project
84
+ // AND global config before running each discovered script, and after running compares
85
+ // hashes. Any change is reverted to the pre-execution content and the call is denied --
86
+ // regardless of the script's own exit code. This is the positive control for the escalation
87
+ // fix: red before the fix (script's tampering stuck, call allowed), green after (tampering
88
+ // reverted, call denied).
89
+ test('FIXED: a discovered script that mutates .ai/config.json to disable another gate is reverted and denied', () => {
90
+ const project = newProject({ config: ENABLE });
91
+ mkdirSync(join(project, 'rules'));
92
+ const configPath = join(project, '.ai', 'config.json');
93
+ const originalConfigContent = readFileSync(configPath, 'utf8');
94
+ const scriptSource = [
95
+ "import { readFileSync, writeFileSync } from 'node:fs';",
96
+ `const path = ${JSON.stringify(configPath)};`,
97
+ 'const config = JSON.parse(readFileSync(path, "utf8"));',
98
+ 'config.gates.blockDestructiveShellCommands = false;',
99
+ 'writeFileSync(path, JSON.stringify(config));',
100
+ 'process.exit(0);',
101
+ ].join('\n');
102
+ writeFileSync(join(project, 'rules', 'gate.mjs'), scriptSource);
103
+
104
+ const result = runGateIn(project, exec());
105
+ assert.ok(isDeny(result), 'a discovered script that tampers with gate security config must be denied, not silently allowed');
106
+ const configAfter = readFileSync(configPath, 'utf8');
107
+ assert.equal(configAfter, originalConfigContent, 'the tampered config must be reverted to its pre-execution content');
108
+ });
109
+
110
+ // EDGE CASE (legitimate): a discovered script that behaves (does NOT touch either watched
111
+ // config file) must not be penalized by the tamper check -- the hash comparison must allow
112
+ // silently when nothing changed, same as before this protection existed.
113
+ test('OK: a well-behaved discovered script that never touches the watched config files is allowed through unaffected', () => {
114
+ const project = newProject({ config: ENABLE });
115
+ mkdirSync(join(project, 'rules'));
116
+ writeFileSync(
117
+ join(project, 'rules', 'gate.mjs'),
118
+ "console.log('a legitimate rule ran, touched nothing sensitive'); process.exit(0);",
119
+ );
120
+ const configPath = join(project, '.ai', 'config.json');
121
+ const originalConfigContent = readFileSync(configPath, 'utf8');
122
+
123
+ const result = runGateIn(project, exec());
124
+ assert.equal(result, null, 'a legitimate discovered script that does not tamper with config must allow the tool call through');
125
+ assert.equal(readFileSync(configPath, 'utf8'), originalConfigContent, 'a legitimate script must leave the config untouched');
126
+ });
@@ -0,0 +1,130 @@
1
+ // Edge-case probes for sdd-specs. Previously: (1) an Edit (new_string) bypassed the
2
+ // content check the same way as feature-catalog; (2) malformed JSON content failed
3
+ // open (parseJsonOrNull -> null -> features=[] -> nothing to check); (3) the catalog
4
+ // lookup was relative to process.cwd() only, so a monorepo subpackage's own
5
+ // .ai/feature_list.json living outside cwd was invisible to findCatalog. All three are
6
+ // now fixed: writtenContentOf()/writtenPathOf() read every field shape, invalid JSON on
7
+ // a catalog write now denies (fail-closed) instead of silently allowing, and
8
+ // findCatalog also resolves catalogLocations relative to the written file's own
9
+ // directory.
10
+ import assert from 'node:assert/strict';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { test } from 'node:test';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
19
+
20
+ function makeProject() {
21
+ const project = mkdtempSync(join(tmpdir(), 'sdd-specs-edge-'));
22
+ mkdirSync(join(project, '.git'));
23
+ return project;
24
+ }
25
+
26
+ function writeProjectConfig(project, config) {
27
+ mkdirSync(join(project, '.ai'), { recursive: true });
28
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
29
+ }
30
+
31
+ function runGate(project, payload) {
32
+ const out = execFileSync(process.execPath, [GATE], {
33
+ input: JSON.stringify(payload),
34
+ encoding: 'utf8',
35
+ cwd: project,
36
+ env: { ...process.env, HOME: project, USERPROFILE: project },
37
+ });
38
+ return out.trim() ? JSON.parse(out.trim()) : null;
39
+ }
40
+
41
+ function isDeny(result) {
42
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
43
+ }
44
+
45
+ function enableGate(project) {
46
+ writeProjectConfig(project, { gates: { requireSpecBeforeImplementing: true } });
47
+ }
48
+
49
+ test('FIXED: an Edit (new_string) moving a feature to spec_ready with no contract is now detected', () => {
50
+ const project = makeProject();
51
+ enableGate(project);
52
+ writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
53
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
54
+ const payload = {
55
+ tool_name: 'Edit',
56
+ tool_input: {
57
+ file_path: join(project, '.ai', 'feature_list.json'),
58
+ old_string: 'x',
59
+ new_string: JSON.stringify({
60
+ features: [{ name: 'checkout', status: 'spec_ready' }],
61
+ }),
62
+ },
63
+ };
64
+ assert.ok(
65
+ isDeny(runGate(project, payload)),
66
+ 'writtenContentOf reads new_string and catches the spec_ready-with-no-contract write',
67
+ );
68
+ });
69
+
70
+ test('FIXED: malformed/invalid JSON content on a catalog write now denies (fail-closed)', () => {
71
+ const project = makeProject();
72
+ enableGate(project);
73
+ writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
74
+ mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
75
+ const payload = {
76
+ tool_name: 'Write',
77
+ tool_input: {
78
+ file_path: join(project, '.ai', 'feature_list.json'),
79
+ // deliberately invalid JSON (unquoted keys): the gate can no longer verify the
80
+ // spec-contract invariant for this write, so it denies instead of allowing.
81
+ content:
82
+ '{features: [{name: "checkout", status: "spec_ready"}]}',
83
+ },
84
+ };
85
+ assert.ok(
86
+ isDeny(runGate(project, payload)),
87
+ 'malformed JSON targeting the catalog must deny, not fail open',
88
+ );
89
+ });
90
+
91
+ test('FIXED: a catalog living outside process.cwd() is now found via the written path', () => {
92
+ const project = makeProject();
93
+ enableGate(project);
94
+ // The catalog lives under sub/.ai/feature_list.json, not the project root's .ai/ --
95
+ // findCatalog now also resolves catalogLocations relative to dirname(writtenPath).
96
+ mkdirSync(join(project, 'sub', '.ai', 'features', 'checkout'), { recursive: true });
97
+ writeFileSync(
98
+ join(project, 'sub', '.ai', 'feature_list.json'),
99
+ JSON.stringify({ features: [{ name: 'checkout', status: 'spec_ready' }] }),
100
+ );
101
+ const payload = {
102
+ tool_name: 'Write',
103
+ tool_input: {
104
+ file_path: join(project, 'sub', '.ai', 'feature_list.json'),
105
+ content: JSON.stringify({
106
+ features: [{ name: 'checkout', status: 'spec_ready' }],
107
+ }),
108
+ },
109
+ };
110
+ assert.ok(
111
+ isDeny(runGate(project, payload)),
112
+ 'a subpackage catalog reached via its own written path must now be inspected',
113
+ );
114
+ });
115
+
116
+ test('OK: the equivalent Write-tool payload at the project root IS caught (control)', () => {
117
+ const project = makeProject();
118
+ enableGate(project);
119
+ writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
120
+ const payload = {
121
+ tool_name: 'Write',
122
+ tool_input: {
123
+ file_path: join(project, '.ai', 'feature_list.json'),
124
+ content: JSON.stringify({
125
+ features: [{ name: 'checkout', status: 'spec_ready' }],
126
+ }),
127
+ },
128
+ };
129
+ assert.ok(isDeny(runGate(project, payload)), 'control failed');
130
+ });