@devrik-tools/claude-gates 0.4.0 → 0.7.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -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
+ );