@devrik-tools/claude-gates 0.3.1 → 0.6.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -4
  3. package/README.md +75 -43
  4. package/cli/config.mjs +126 -124
  5. package/cli/index.mjs +154 -104
  6. package/cli/init.mjs +303 -276
  7. package/cli/install.mjs +281 -175
  8. package/cli/materialize.mjs +103 -102
  9. package/cli/registry.mjs +139 -136
  10. package/cli/smoke-fixtures.json +441 -0
  11. package/cli/smoke.mjs +129 -0
  12. package/cli/task.mjs +140 -140
  13. package/package.json +1 -1
  14. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  15. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  16. package/plugins/gates/hooks/doctor.mjs +207 -207
  17. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  19. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  21. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  22. package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
  23. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  24. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  25. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  26. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  27. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  28. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  29. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  30. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  31. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  32. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  33. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  34. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  35. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  36. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  37. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  38. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  39. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  40. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  41. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  42. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  43. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  44. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  45. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  46. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  47. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  48. package/plugins/gates/hooks/hooks.json +51 -0
  49. package/plugins/gates/hooks/lib/config.mjs +179 -172
  50. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  51. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  52. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  53. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  54. package/plugins/tasks/hooks/hooks.json +26 -26
  55. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  56. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  57. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  58. package/registry.json +171 -1
@@ -1,184 +1,181 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import {
4
- existsSync,
5
- readdirSync,
6
- readFileSync,
7
- writeFileSync,
8
- unlinkSync,
9
- } from 'node:fs';
10
- import { homedir } from 'node:os';
11
- import { join, relative } from 'node:path';
12
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
13
-
14
- const GATE_ID = 'rule-skill-autodiscovery';
15
- const CONFIG_KEY = 'autodiscoverRulesAndSkills';
16
-
17
- const DEFAULT_RULES_DIR = 'rules';
18
- const DEFAULT_GATE_FILE_NAMES = [
19
- 'gate.mjs',
20
- 'rules.mjs',
21
- 'check.mjs',
22
- 'verify.mjs',
23
- ];
24
-
25
- function isGateFile(fileName, gateFileNames) {
26
- return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
27
- }
28
-
29
- function findScriptsIn(scriptsDirectory, gateFileNames) {
30
- if (!existsSync(scriptsDirectory)) return [];
31
- let entries;
32
- try {
33
- entries = readdirSync(scriptsDirectory, { withFileTypes: true });
34
- } catch {
35
- return [];
36
- }
37
- return entries
38
- .filter((entry) => entry.isFile())
39
- .filter((entry) => /\.(mjs|js)$/.test(entry.name))
40
- .filter((entry) => isGateFile(entry.name, gateFileNames))
41
- .map((entry) => join(scriptsDirectory, entry.name));
42
- }
43
-
44
- function discoverScripts(projectRoot, rulesDirectoryName, gateFileNames) {
45
- const scripts = [
46
- ...findScriptsIn(join(projectRoot, rulesDirectoryName), gateFileNames),
47
- ];
48
-
49
- const skillsRoot = join(projectRoot, '.claude', 'skills');
50
- if (existsSync(skillsRoot)) {
51
- let skillDirectories;
52
- try {
53
- skillDirectories = readdirSync(skillsRoot, {
54
- withFileTypes: true,
55
- }).filter((entry) => entry.isDirectory());
56
- } catch {
57
- skillDirectories = [];
58
- }
59
- for (const skillDirectory of skillDirectories) {
60
- scripts.push(
61
- ...findScriptsIn(join(skillsRoot, skillDirectory.name), gateFileNames),
62
- );
63
- }
64
- }
65
-
66
- return scripts;
67
- }
68
-
69
- // ── Security-surface protection ─────────────────────────────────────────────────────
70
- // A discovered script runs with full Node privileges (no sandbox is realistically
71
- // enforceable via execFileSync alone). The one privilege it must NEVER have is the
72
- // power to mutate the gate configuration that governs whether it (or any other gate)
73
- // runs at all — otherwise a discovered script can disable e.g.
74
- // `blockDestructiveShellCommands` in the same tool call it was meant to be gated by,
75
- // before any later gate could react. This is enforced deterministically: snapshot a
76
- // hash of both config files (project + global) before executing, and after every
77
- // script runs, compare. Any change is reverted immediately and the call is denied —
78
- // regardless of whether the script itself exited 0.
79
- const PROJECT_CONFIG_RELATIVE_PATH = join('.ai', 'config.json');
80
-
81
- function globalConfigPath() {
82
- return join(homedir(), '.claude', 'claude-gates', 'config.json');
83
- }
84
-
85
- function securityConfigPaths(projectRoot) {
86
- return [
87
- join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH),
88
- globalConfigPath(),
89
- ];
90
- }
91
-
92
- /** Reads raw bytes (or null if absent) and their hash, per watched config path. */
93
- function snapshotConfigs(paths) {
94
- return paths.map((path) => {
95
- let content = null;
96
- try {
97
- if (existsSync(path)) content = readFileSync(path);
98
- } catch {
99
- content = null;
100
- }
101
- const hash = content
102
- ? createHash('sha256').update(content).digest('hex')
103
- : null;
104
- return { path, content, hash };
105
- });
106
- }
107
-
108
- /** Restores every watched config file to its pre-execution content, best-effort. */
109
- function revertConfigs(snapshots) {
110
- for (const snapshot of snapshots) {
111
- try {
112
- if (snapshot.content === null) {
113
- if (existsSync(snapshot.path)) unlinkSync(snapshot.path);
114
- } else {
115
- writeFileSync(snapshot.path, snapshot.content);
116
- }
117
- } catch {
118
- // Best-effort revert: if this fails there is nothing more this gate can do
119
- // beyond having already denied the call.
120
- }
121
- }
122
- }
123
-
124
- /** Whether any watched config file's content changed since `before`. */
125
- function configsTampered(before, projectRoot) {
126
- const after = snapshotConfigs(before.map((snapshot) => snapshot.path));
127
- return before.some((snapshot, index) => snapshot.hash !== after[index].hash);
128
- }
129
-
130
- runGate(
131
- {
132
- id: GATE_ID,
133
- configKey: CONFIG_KEY,
134
- enabledByDefault: false,
135
- defaultParams: {
136
- rulesDir: DEFAULT_RULES_DIR,
137
- gateFileNames: DEFAULT_GATE_FILE_NAMES,
138
- },
139
- },
140
- ({ toolName, parameters }) => {
141
- if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
142
-
143
- const projectRoot = process.cwd();
144
- const scripts = discoverScripts(
145
- projectRoot,
146
- parameters.rulesDir,
147
- parameters.gateFileNames,
148
- );
149
- if (scripts.length === 0) return;
150
-
151
- const watchedConfigPaths = securityConfigPaths(projectRoot);
152
-
153
- for (const script of scripts) {
154
- const before = snapshotConfigs(watchedConfigPaths);
155
-
156
- let failed = false;
157
- try {
158
- execFileSync(process.execPath, [script], {
159
- stdio: 'ignore',
160
- timeout: 5000,
161
- });
162
- } catch {
163
- failed = true;
164
- }
165
-
166
- if (configsTampered(before, projectRoot)) {
167
- revertConfigs(before);
168
- deny(
169
- GATE_ID,
170
- `Discovered rule/skill script ${relative(projectRoot, script)} attempted to modify gate security configuration (.ai/config.json or the global config). The change was reverted and the action is blocked.`,
171
- );
172
- return;
173
- }
174
-
175
- if (failed) {
176
- deny(
177
- GATE_ID,
178
- `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
179
- );
180
- return;
181
- }
182
- }
183
- },
184
- );
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ existsSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ unlinkSync,
9
+ } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join, relative } from 'node:path';
12
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
13
+
14
+ const GATE_ID = 'rule-skill-autodiscovery';
15
+ const CONFIG_KEY = 'autodiscoverRulesAndSkills';
16
+
17
+ const DEFAULT_RULES_DIR = 'rules';
18
+ const DEFAULT_GATE_FILE_NAMES = [
19
+ 'gate.mjs',
20
+ 'rules.mjs',
21
+ 'check.mjs',
22
+ 'verify.mjs',
23
+ ];
24
+
25
+ function isGateFile(fileName, gateFileNames) {
26
+ return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
27
+ }
28
+
29
+ function findScriptsIn(scriptsDirectory, gateFileNames) {
30
+ if (!existsSync(scriptsDirectory)) return [];
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(scriptsDirectory, { withFileTypes: true });
34
+ } catch {
35
+ return [];
36
+ }
37
+ return entries
38
+ .filter((entry) => entry.isFile())
39
+ .filter((entry) => /\.(mjs|js)$/.test(entry.name))
40
+ .filter((entry) => isGateFile(entry.name, gateFileNames))
41
+ .map((entry) => join(scriptsDirectory, entry.name));
42
+ }
43
+
44
+ function discoverScripts(projectRoot, rulesDirectoryName, gateFileNames) {
45
+ const scripts = [
46
+ ...findScriptsIn(join(projectRoot, rulesDirectoryName), gateFileNames),
47
+ ];
48
+
49
+ const skillsRoot = join(projectRoot, '.claude', 'skills');
50
+ if (existsSync(skillsRoot)) {
51
+ let skillDirectories;
52
+ try {
53
+ skillDirectories = readdirSync(skillsRoot, {
54
+ withFileTypes: true,
55
+ }).filter((entry) => entry.isDirectory());
56
+ } catch {
57
+ skillDirectories = [];
58
+ }
59
+ for (const skillDirectory of skillDirectories) {
60
+ scripts.push(
61
+ ...findScriptsIn(join(skillsRoot, skillDirectory.name), gateFileNames),
62
+ );
63
+ }
64
+ }
65
+
66
+ return scripts;
67
+ }
68
+
69
+ // ── Security-surface protection ─────────────────────────────────────────────────────
70
+ // A discovered script runs with full Node privileges (no sandbox is realistically
71
+ // enforceable via execFileSync alone). The one privilege it must NEVER have is the
72
+ // power to mutate the gate configuration that governs whether it (or any other gate)
73
+ // runs at all — otherwise a discovered script can disable e.g.
74
+ // `blockDestructiveShellCommands` in the same tool call it was meant to be gated by,
75
+ // before any later gate could react. This is enforced deterministically: snapshot a
76
+ // hash of both config files (project + global) before executing, and after every
77
+ // script runs, compare. Any change is reverted immediately and the call is denied —
78
+ // regardless of whether the script itself exited 0.
79
+ const PROJECT_CONFIG_RELATIVE_PATH = join('.ai', 'config.json');
80
+
81
+ function globalConfigPath() {
82
+ return join(homedir(), '.claude', 'claude-gates', 'config.json');
83
+ }
84
+
85
+ function securityConfigPaths(projectRoot) {
86
+ return [join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH), globalConfigPath()];
87
+ }
88
+
89
+ /** Reads raw bytes (or null if absent) and their hash, per watched config path. */
90
+ function snapshotConfigs(paths) {
91
+ return paths.map((path) => {
92
+ let content = null;
93
+ try {
94
+ if (existsSync(path)) content = readFileSync(path);
95
+ } catch {
96
+ content = null;
97
+ }
98
+ const hash = content
99
+ ? createHash('sha256').update(content).digest('hex')
100
+ : null;
101
+ return { path, content, hash };
102
+ });
103
+ }
104
+
105
+ /** Restores every watched config file to its pre-execution content, best-effort. */
106
+ function revertConfigs(snapshots) {
107
+ for (const snapshot of snapshots) {
108
+ try {
109
+ if (snapshot.content === null) {
110
+ if (existsSync(snapshot.path)) unlinkSync(snapshot.path);
111
+ } else {
112
+ writeFileSync(snapshot.path, snapshot.content);
113
+ }
114
+ } catch {
115
+ // Best-effort revert: if this fails there is nothing more this gate can do
116
+ // beyond having already denied the call.
117
+ }
118
+ }
119
+ }
120
+
121
+ /** Whether any watched config file's content changed since `before`. */
122
+ function configsTampered(before) {
123
+ const after = snapshotConfigs(before.map((snapshot) => snapshot.path));
124
+ return before.some((snapshot, index) => snapshot.hash !== after[index].hash);
125
+ }
126
+
127
+ runGate(
128
+ {
129
+ id: GATE_ID,
130
+ configKey: CONFIG_KEY,
131
+ enabledByDefault: false,
132
+ defaultParams: {
133
+ rulesDir: DEFAULT_RULES_DIR,
134
+ gateFileNames: DEFAULT_GATE_FILE_NAMES,
135
+ },
136
+ },
137
+ ({ toolName, parameters }) => {
138
+ if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
139
+
140
+ const projectRoot = process.cwd();
141
+ const scripts = discoverScripts(
142
+ projectRoot,
143
+ parameters.rulesDir,
144
+ parameters.gateFileNames,
145
+ );
146
+ if (scripts.length === 0) return;
147
+
148
+ const watchedConfigPaths = securityConfigPaths(projectRoot);
149
+
150
+ for (const script of scripts) {
151
+ const before = snapshotConfigs(watchedConfigPaths);
152
+
153
+ let failed = false;
154
+ try {
155
+ execFileSync(process.execPath, [script], {
156
+ stdio: 'ignore',
157
+ timeout: 5000,
158
+ });
159
+ } catch {
160
+ failed = true;
161
+ }
162
+
163
+ if (configsTampered(before)) {
164
+ revertConfigs(before);
165
+ deny(
166
+ GATE_ID,
167
+ `Discovered rule/skill script ${relative(projectRoot, script)} attempted to modify gate security configuration (.ai/config.json or the global config). The change was reverted and the action is blocked.`,
168
+ );
169
+ return;
170
+ }
171
+
172
+ if (failed) {
173
+ deny(
174
+ GATE_ID,
175
+ `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
176
+ );
177
+ return;
178
+ }
179
+ }
180
+ },
181
+ );