@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,15 +1,29 @@
1
+ // rule-skill-autodiscovery — runs the project's own sub-gates (rules/<gate file> and
2
+ // .claude/skills/*/<gate file>) on every execution/delegation call, feeding each the hook
3
+ // payload on stdin. A sub-gate denies by printing `{"permissionDecision":"deny",...}` or by
4
+ // exiting 2; a sub-gate that crashes or times out only WARNS, so one broken script cannot
5
+ // block all work. Deliberate restrictions, because a discovered script is arbitrary code:
6
+ // - it runs only when THIS project's .ai/config.json enables the gate (a global setting
7
+ // must never grant code execution to a freshly cloned repo);
8
+ // - it gets a minimal environment (PATH/HOME/USERPROFILE/TEMP/TMP/SystemRoot/CLAUDE_*);
9
+ // - all scripts share one 8 s budget;
10
+ // - the gate config files are hashed before and after each script; any change is
11
+ // reverted and denied, so a script cannot disable the gates that govern it.
12
+
1
13
  import { execFileSync } from 'node:child_process';
2
14
  import { createHash } from 'node:crypto';
3
15
  import {
4
16
  existsSync,
5
17
  readdirSync,
6
18
  readFileSync,
7
- writeFileSync,
19
+ statSync,
8
20
  unlinkSync,
21
+ writeFileSync,
9
22
  } from 'node:fs';
10
23
  import { homedir } from 'node:os';
11
24
  import { join, relative } from 'node:path';
12
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
25
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
26
+ import { runGate, deny, warn, toolInGroups } from '../../lib/hook-io.mjs';
13
27
 
14
28
  const GATE_ID = 'rule-skill-autodiscovery';
15
29
  const CONFIG_KEY = 'autodiscoverRulesAndSkills';
@@ -21,72 +35,92 @@ const DEFAULT_GATE_FILE_NAMES = [
21
35
  'check.mjs',
22
36
  'verify.mjs',
23
37
  ];
38
+ const TOTAL_BUDGET_MS = 8000;
39
+ const KIB = 1024;
40
+ const MAX_OUTPUT_BYTES = KIB * KIB;
41
+ const DENY_EXIT_CODE = 2;
42
+ const ALLOWED_ENVIRONMENT = new Set([
43
+ 'PATH',
44
+ 'HOME',
45
+ 'USERPROFILE',
46
+ 'TEMP',
47
+ 'TMP',
48
+ 'SYSTEMROOT',
49
+ ]);
50
+ const ENVIRONMENT_PREFIX = 'CLAUDE_';
51
+ const PROJECT_CONFIG_RELATIVE_PATH = join('.ai', 'config.json');
52
+ const GLOBAL_CONFIG_PATH = join(
53
+ homedir(),
54
+ '.claude',
55
+ 'claude-gates',
56
+ 'config.json',
57
+ );
24
58
 
25
- function isGateFile(fileName, gateFileNames) {
26
- return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
59
+ function projectEnablesGate(root) {
60
+ const entry = readJsonOrNull(join(root, PROJECT_CONFIG_RELATIVE_PATH))
61
+ ?.gates?.[CONFIG_KEY];
62
+ return entry === true || entry?.enabled === true;
27
63
  }
28
64
 
29
- function findScriptsIn(scriptsDirectory, gateFileNames) {
30
- if (!existsSync(scriptsDirectory)) return [];
31
- let entries;
65
+ function isDirectory(path) {
32
66
  try {
33
- entries = readdirSync(scriptsDirectory, { withFileTypes: true });
67
+ return statSync(path).isDirectory();
34
68
  } catch {
35
- return [];
69
+ return false;
36
70
  }
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
71
  }
43
72
 
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
- }
73
+ function isFile(path) {
74
+ try {
75
+ return statSync(path).isFile();
76
+ } catch {
77
+ return false;
64
78
  }
65
-
66
- return scripts;
67
79
  }
68
80
 
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');
81
+ function findScriptsIn(directory, gateFileNames) {
82
+ let names;
83
+ try {
84
+ names = readdirSync(directory);
85
+ } catch {
86
+ return [];
87
+ }
88
+ return names
89
+ .filter((name) => /\.(mjs|js)$/.test(name))
90
+ .filter(
91
+ (name) => gateFileNames.includes(name) || name.endsWith('.gate.mjs'),
92
+ )
93
+ .map((name) => join(directory, name))
94
+ .filter(isFile);
95
+ }
80
96
 
81
- function globalConfigPath() {
82
- return join(homedir(), '.claude', 'claude-gates', 'config.json');
97
+ function discoverScripts(root, rulesDirectoryName, gateFileNames) {
98
+ const scripts = findScriptsIn(join(root, rulesDirectoryName), gateFileNames);
99
+ const skillsRoot = join(root, '.claude', 'skills');
100
+ let skillNames;
101
+ try {
102
+ skillNames = readdirSync(skillsRoot);
103
+ } catch {
104
+ skillNames = [];
105
+ }
106
+ for (const name of skillNames) {
107
+ const skillDirectory = join(skillsRoot, name);
108
+ if (isDirectory(skillDirectory))
109
+ scripts.push(...findScriptsIn(skillDirectory, gateFileNames));
110
+ }
111
+ return scripts;
83
112
  }
84
113
 
85
- function securityConfigPaths(projectRoot) {
86
- return [join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH), globalConfigPath()];
114
+ function restrictedEnvironment() {
115
+ const environment = {};
116
+ for (const [key, value] of Object.entries(process.env)) {
117
+ const upper = key.toUpperCase();
118
+ if (ALLOWED_ENVIRONMENT.has(upper) || upper.startsWith(ENVIRONMENT_PREFIX))
119
+ environment[key] = value;
120
+ }
121
+ return environment;
87
122
  }
88
123
 
89
- /** Reads raw bytes (or null if absent) and their hash, per watched config path. */
90
124
  function snapshotConfigs(paths) {
91
125
  return paths.map((path) => {
92
126
  let content = null;
@@ -102,7 +136,6 @@ function snapshotConfigs(paths) {
102
136
  });
103
137
  }
104
138
 
105
- /** Restores every watched config file to its pre-execution content, best-effort. */
106
139
  function revertConfigs(snapshots) {
107
140
  for (const snapshot of snapshots) {
108
141
  try {
@@ -112,18 +145,114 @@ function revertConfigs(snapshots) {
112
145
  writeFileSync(snapshot.path, snapshot.content);
113
146
  }
114
147
  } catch {
115
- // Best-effort revert: if this fails there is nothing more this gate can do
116
- // beyond having already denied the call.
148
+ // Best-effort revert: the call is denied regardless.
117
149
  }
118
150
  }
119
151
  }
120
152
 
121
- /** Whether any watched config file's content changed since `before`. */
122
153
  function configsTampered(before) {
123
154
  const after = snapshotConfigs(before.map((snapshot) => snapshot.path));
124
155
  return before.some((snapshot, index) => snapshot.hash !== after[index].hash);
125
156
  }
126
157
 
158
+ function runScript(script, root, rawPayload, timeoutMs) {
159
+ try {
160
+ const stdout = execFileSync(process.execPath, [script], {
161
+ cwd: root,
162
+ input: rawPayload,
163
+ env: restrictedEnvironment(),
164
+ encoding: 'utf8',
165
+ timeout: timeoutMs,
166
+ maxBuffer: MAX_OUTPUT_BYTES,
167
+ });
168
+ return { status: 0, stdout, stderr: '' };
169
+ } catch (error) {
170
+ return {
171
+ status: error?.status ?? null,
172
+ stdout: String(error?.stdout ?? ''),
173
+ stderr: String(error?.stderr ?? ''),
174
+ message:
175
+ error?.code === 'ETIMEDOUT'
176
+ ? 'timed out'
177
+ : String(error?.message ?? error),
178
+ };
179
+ }
180
+ }
181
+
182
+ function parseJsonOrNull(text) {
183
+ try {
184
+ return JSON.parse(text);
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ const NO_REASON = 'no reason given';
191
+
192
+ function jsonDecisionOf(parsed) {
193
+ const specific = parsed?.hookSpecificOutput ?? {};
194
+ return {
195
+ decision:
196
+ specific.permissionDecision ??
197
+ parsed?.permissionDecision ??
198
+ parsed?.decision,
199
+ reason:
200
+ specific.permissionDecisionReason ??
201
+ parsed?.permissionDecisionReason ??
202
+ parsed?.reason ??
203
+ NO_REASON,
204
+ };
205
+ }
206
+
207
+ function denialReasonOf(result) {
208
+ const output = result.stdout.trim();
209
+ const { decision, reason } = jsonDecisionOf(parseJsonOrNull(output));
210
+ if (decision === 'deny' || decision === 'block') return reason;
211
+ if (result.status === DENY_EXIT_CODE)
212
+ return result.stderr.trim() || output || NO_REASON;
213
+ return null;
214
+ }
215
+
216
+ function failureOf(result, scriptLabel) {
217
+ if (result.status === 0 || result.status === DENY_EXIT_CODE) return null;
218
+ const detail = result.stderr.trim() || result.message || 'unknown error';
219
+ return `'${scriptLabel}' failed (exit ${result.status ?? 'none'}): ${detail}`;
220
+ }
221
+
222
+ function runDiscoveredScripts(scripts, root, rawPayload) {
223
+ const watched = [
224
+ join(root, PROJECT_CONFIG_RELATIVE_PATH),
225
+ GLOBAL_CONFIG_PATH,
226
+ ];
227
+ const started = Date.now();
228
+ const warnings = [];
229
+ for (const [index, script] of scripts.entries()) {
230
+ const remaining = TOTAL_BUDGET_MS - (Date.now() - started);
231
+ if (remaining <= 0) {
232
+ warnings.push(
233
+ `${scripts.length - index} script(s) skipped: the ${TOTAL_BUDGET_MS} ms budget for all sub-gates was exhausted`,
234
+ );
235
+ break;
236
+ }
237
+ const label = relative(root, script);
238
+ const before = snapshotConfigs(watched);
239
+ const result = runScript(script, root, rawPayload, remaining);
240
+ if (configsTampered(before)) {
241
+ revertConfigs(before);
242
+ deny(
243
+ CONFIG_KEY,
244
+ `Discovered rule/skill script ${label} attempted to modify gate security configuration (.ai/config.json or the global config). The change was reverted and the action is blocked.`,
245
+ );
246
+ }
247
+ const reason = denialReasonOf(result);
248
+ if (reason !== null)
249
+ deny(CONFIG_KEY, `Sub-gate '${label}' denied: ${reason}`);
250
+ const failure = failureOf(result, label);
251
+ if (failure) warnings.push(failure);
252
+ }
253
+ return warnings;
254
+ }
255
+
127
256
  runGate(
128
257
  {
129
258
  id: GATE_ID,
@@ -134,54 +263,24 @@ runGate(
134
263
  gateFileNames: DEFAULT_GATE_FILE_NAMES,
135
264
  },
136
265
  },
137
- ({ toolName, parameters }) => {
266
+ ({ toolName, parameters, cwd, rawPayload }) => {
138
267
  if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
268
+ const root = projectRootOf(cwd) ?? cwd;
269
+ if (!projectEnablesGate(root)) return;
139
270
 
140
- const projectRoot = process.cwd();
141
271
  const scripts = discoverScripts(
142
- projectRoot,
272
+ root,
143
273
  parameters.rulesDir,
144
274
  parameters.gateFileNames,
145
275
  );
146
276
  if (scripts.length === 0) return;
147
277
 
148
- const watchedConfigPaths = securityConfigPaths(projectRoot);
149
-
150
- for (const script of scripts) {
151
- const before = snapshotConfigs(watchedConfigPaths);
152
-
153
- let failure = null;
154
- try {
155
- execFileSync(process.execPath, [script], {
156
- stdio: ['ignore', 'ignore', 'pipe'],
157
- timeout: 5000,
158
- });
159
- } catch (error) {
160
- failure = error;
161
- }
162
-
163
- if (configsTampered(before)) {
164
- revertConfigs(before);
165
- deny(
166
- CONFIG_KEY,
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 (failure) {
173
- const stderr = String(failure.stderr ?? '').trim();
174
- const detail = stderr || failure.message || String(failure);
175
- deny(
176
- CONFIG_KEY,
177
- `Sub-gate '${relative(projectRoot, script)}' failed (exit ` +
178
- `${failure.status ?? 'unknown'}): ${detail}\n` +
179
- 'No filesystem exploration is needed — fix that script (open it at the path ' +
180
- 'above and address the error shown), or remove it from ' +
181
- `${parameters.rulesDir}/ if it should not run as a gate.`,
182
- );
183
- return;
184
- }
185
- }
278
+ const warnings = runDiscoveredScripts(scripts, root, rawPayload);
279
+ if (warnings.length === 0) return;
280
+ warn(
281
+ CONFIG_KEY,
282
+ `Sub-gate(s) could not be evaluated, so they did not judge this call: ${warnings.join('; ')}. ` +
283
+ `Fix the script (open it at the path above and address the error shown) or remove it from ${parameters.rulesDir}/ or the skill directory if it should not run as a gate.`,
284
+ );
186
285
  },
187
286
  );