@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,37 +1,17 @@
1
- // autonomous-mode (Stop half) — closes the hole its PreToolUse half cannot reach. The
2
- // PreToolUse gate (index.mjs) blocks the AskUserQuestion TOOL, but nothing stops the
3
- // assistant from ending its turn with a question in plain PROSE and when it does, the
4
- // session just sits there waiting for a human who is supposed to be away. No hook can read
5
- // chat text to detect that a turn ended on a question. So this Stop hook takes the only
6
- // deterministic lever available: when autonomous mode is ON and the turn is ending, it
7
- // re-injects an instruction to NOT wait — decide, delegate, and keep going on everything
8
- // that does not strictly require the user's own explicit decision.
9
- //
10
- // ── What the re-injected message says (per the user's own framing) ───────────────────
11
- // In autonomous mode work is delegated to the assistant. It must only leave pending the ONE
12
- // thing that genuinely needs the user's exclusive, explicit decision (irreversible/dangerous
13
- // — deleting data, money, production). Everything else: proceed. If there are OTHER tasks it
14
- // can advance, advance them; isolate only the truly blocking one and state it in prose, then
15
- // stop. It must not end the turn on a prose question just to wait.
16
- //
17
- // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
18
- // Claude Code re-invokes Stop hooks with stop_hook_active=true after a block. Blocking again
19
- // would loop forever. So when stop_hook_active === true this hook unconditionally allows the
20
- // stop: the reminder is injected exactly once per cycle. After that one nudge, if the
21
- // assistant still ends the turn, it genuinely had nothing to proceed on (or a real blocker),
22
- // and the session must be allowed to end — a frozen session is worse than one missed nudge.
23
- //
24
- // ── Fail-safe (mandatory for a Stop hook) ────────────────────────────────────────────
25
- // Everything is wrapped so that no config, no project, a corrupt payload, or any unexpected
26
- // error allows the stop. A broken Stop hook must never hang a session.
1
+ // autonomous-mode (Stop half) — the PreToolUse gate blocks the question TOOL, but nothing
2
+ // stops the assistant from ending its turn on a question in plain prose, leaving the
3
+ // session waiting for a human who is away. No hook can read chat text, so this Stop hook
4
+ // re-injects, once per cycle, the instruction to decide and keep going. The loop guard
5
+ // (stop_hook_active) and fail-open shape come from runStopHook: after one nudge, a real
6
+ // end of turn goes through.
27
7
 
28
- import { isGateEnabled } from '../../lib/config.mjs';
29
- import { readHookPayload } from '../../lib/hook-io.mjs';
8
+ import { block, runStopHook } from '../../lib/hook-io.mjs';
30
9
 
10
+ const GATE_ID = 'autonomous-mode';
31
11
  const CONFIG_KEY = 'autonomousMode';
32
12
 
33
13
  const CONTINUE_MESSAGE =
34
- '[autonomous] Autonomous mode is ON — do not end the turn to wait for the user. Work here ' +
14
+ 'Autonomous mode is ON — do not end the turn to wait for the user. Work here ' +
35
15
  'is delegated to you. Leave PENDING only the one thing that genuinely needs the user’s own ' +
36
16
  'explicit, exclusive decision (a truly irreversible or dangerous choice: deleting data, ' +
37
17
  'money, production). For everything else: decide the best goal-aligned option, state the ' +
@@ -41,38 +21,7 @@ const CONTINUE_MESSAGE =
41
21
  'If you have truly nothing left to do and nothing needs the user, ending is fine — this ' +
42
22
  'reminder fires once, so a real end will go through on the next stop.';
43
23
 
44
- function block(reason) {
45
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
46
- process.exit(0);
47
- }
48
-
49
- function allow() {
50
- process.exit(0);
51
- }
52
-
53
- function main() {
54
- try {
55
- const rawPayload = readHookPayload();
56
- if (rawPayload === null) return allow();
57
-
58
- let payload;
59
- try {
60
- payload = JSON.parse(rawPayload);
61
- } catch {
62
- return allow(); // unparseable Stop payload: fail-safe, never hang the session
63
- }
64
-
65
- // Loop-guard: this hook already nudged once this cycle. Never block again.
66
- if (payload?.stop_hook_active === true) return allow();
67
-
68
- // Only act when the project turned autonomous mode ON. Off by default (registry).
69
- if (!isGateEnabled(CONFIG_KEY, false, process.cwd())) return allow();
70
-
71
- return block(CONTINUE_MESSAGE);
72
- } catch {
73
- // A broken Stop hook must never hang a session.
74
- return allow();
75
- }
76
- }
77
-
78
- main();
24
+ runStopHook(
25
+ { id: GATE_ID, configKey: CONFIG_KEY, enabledByDefault: false },
26
+ () => block(CONFIG_KEY, CONTINUE_MESSAGE),
27
+ );
@@ -1,144 +1,70 @@
1
- // bash-commands — denies destructive shell commands (rm -rf over protected areas, git
2
- // reset --hard, force push, kill-by-name). Runs on a real Bash/run_command call and on a
3
- // delegation prompt (a subagent can be told "run git reset --hard" in prose).
4
- //
5
- // Remote-publish blocking (git push / gh pr merge / gh release create) used to live here as
6
- // a hardcoded, non-configurable block. It was split out into the block-remote-publish gate
7
- // so it carries its own enabled flag: a project can now allow the agent to push by disabling
8
- // that gate WITHOUT also disabling the destructive-command protections below.
9
- //
10
- // ── What a project can configure (params) ───────────────────────────────────────────
11
- // denyPatterns regex sources (matched case-insensitively) of destructive
12
- // commands to deny. Replaces the built-in list below wholesale.
13
- // rmRfProtectedAreas targets rm -rf may not hit; woven into the rm -rf pattern.
14
- // embeddedInterpreterEnabled block `node -e`/`python -c` that does raw file ops. Off
15
- // by default: this harness legitimately uses inline interpreters.
16
- // The defaults live here, in the source, so a project reads them and knows exactly what
17
- // its override replaces.
18
-
1
+ // bash-commands — denies destructive shell commands (recursive force delete over a protected
2
+ // area, git reset --hard, force push, git clean -f, kill-by-name) on a real shell call and on
3
+ // a delegation prompt that ORDERS one. Deliberate limits: a delete target built from a
4
+ // variable (`rm -rf $DIR`) is not resolved, and a prompt that only mentions a command
5
+ // (negated, audited, reported) is not an order. Remote publishing lives in
6
+ // block-remote-publish so it carries its own flag.
7
+
8
+ import { hasRealCommandIntent } from '../../lib/delegation.mjs';
9
+ import { normalizeGitCommand } from '../../lib/git.mjs';
19
10
  import {
20
- runGate,
11
+ compileRegex,
12
+ delegationPromptOf,
21
13
  deny,
14
+ escapeRegExp,
15
+ normalizeRulePairs,
16
+ runGate,
17
+ shellCommandOf,
22
18
  toolInGroups,
23
- delegationPromptOf,
24
19
  } from '../../lib/hook-io.mjs';
25
20
 
26
21
  const GATE_ID = 'bash-commands';
27
22
  const CONFIG_KEY = 'blockDestructiveShellCommands';
28
23
 
29
- // Areas rm -rf must never target. A project overrides this list in config; the rm -rf
30
- // deny pattern is rebuilt from it at runtime (see rmRfSourceFrom), so an edit takes effect.
31
24
  const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
25
+ const DEFAULT_DENY_REASON = 'Destructive command is not allowed.';
26
+
27
+ const KILL_BY_NAME_REMEDY =
28
+ 'Killing processes by NAME reaches everything with that name, not just yours. ' +
29
+ 'Keep the PID you started and kill that; if lost, find it by its full command line ' +
30
+ 'and confirm it is yours before touching it.';
31
+
32
+ // A short-flag cluster carrying `f` (`-f`, `-fu`) or the long `--force`, but never the safe
33
+ // `--force-with-lease` / `--force-if-includes`.
34
+ const FORCE_FLAG = String.raw`(?:\s--force(?![\w-])|\s-[a-z]*f[a-z]*(?=\s|$))`;
35
+
36
+ const DEFAULT_DENY_PATTERNS = [
37
+ [
38
+ String.raw`\bgit\s+reset\b[^;&|]*--hard\b`,
39
+ "'git reset --hard' discards uncommitted work for good. Use 'git stash' or " +
40
+ "'git reset --soft', or ask the user to authorize the hard reset explicitly.",
41
+ ],
42
+ [
43
+ String.raw`\bgit\s+push\b[^;&|]*(?:${FORCE_FLAG}|\s\+\S)`,
44
+ "'git push --force' (also -f and a +refspec) rewrites remote history. Use " +
45
+ "'--force-with-lease' if a forced update is really needed, with the user's authorization.",
46
+ ],
47
+ [
48
+ String.raw`\bgit\s+clean\b[^;&|]*${FORCE_FLAG}`,
49
+ "'git clean -f' deletes untracked files for good. Run 'git clean -n' to list them " +
50
+ 'and delete specific paths instead.',
51
+ ],
52
+ [
53
+ '\\btaskkill(?:\\.exe)?\\b[^|;&]*\\s[/-]im\\b|\\bpkill\\s|\\bkillall\\s',
54
+ KILL_BY_NAME_REMEDY,
55
+ ],
56
+ [
57
+ '\\bStop-Process\\b[^|;\\n]*\\s-n(?:a(?:me?)?)?\\b|' +
58
+ '\\bGet-Process\\b[^|;\\n]*\\|\\s*(?:Stop-Process|kill|spps)\\b',
59
+ `Stop-Process by name (or piped from Get-Process): ${KILL_BY_NAME_REMEDY}`,
60
+ ],
61
+ ];
32
62
 
33
- // Process-killing command names, assembled from fragments so the spell checker does not
34
- // read them as prose (the project keeps an empty dictionary by policy).
35
- const KILL_BY_NAME_COMMANDS = ['task' + 'kill', 'p' + 'kill', 'kill' + 'all'];
36
-
37
- // PowerShell's kill-by-name form: `Stop-Process -Name node` (and its Get-Process pipe). On
38
- // Windows this reaches every process of that name exactly like taskkill /IM, so it belongs
39
- // in the same block. Matched separately because its shape (a -Name flag) differs from the
40
- // unix commands above.
41
- const STOP_PROCESS_BY_NAME_SOURCE = String.raw`\bStop-Process\b[^|;\n]*\s-Name\b`;
42
-
43
- // The rm -rf deny source, built from the protected-areas list. Separate from the static
44
- // deny list so a project can edit rmRfProtectedAreas in config and have it take effect at
45
- // runtime: the list is re-read on every call, not baked in at load time.
46
- function rmRfSourceFrom(protectedAreas) {
47
- const rmRfTargets = protectedAreas
48
- .map((area) => area.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
49
- .join('|');
50
- // Allow an optional `./` (or `.\`) prefix before the target, so `rm -rf ./src` and
51
- // `rm -rf .\src` are caught, not just the bare `rm -rf src`. Without this, a leading
52
- // `./` sat between the required whitespace and the target's word boundary and slipped past.
53
- return String.raw`\brm\s+-rf\s+(?:\.[\\/])?(${rmRfTargets})\b`;
54
- }
55
-
56
- /**
57
- * Static destructive-command rules as `[regexSource, reason]`. Sources (not RegExp) so
58
- * they are JSON-serializable and a project can override them through config. rm -rf is not
59
- * here — it is built at runtime from rmRfProtectedAreas (see rmRfSourceFrom).
60
- */
61
- function defaultDenyPatterns() {
62
- const [taskKill, pKill, killAll] = KILL_BY_NAME_COMMANDS;
63
- return [
64
- [
65
- String.raw`\bgit\s+reset\s+--hard\b`,
66
- "'git reset --hard' is destructive and needs explicit authorization.",
67
- ],
68
- [
69
- String.raw`\bgit\s+push\s+.*--force\b`,
70
- "'git push --force' is destructive and is not allowed.",
71
- ],
72
- [
73
- String.raw`\bgit\s+clean\s+-[a-z]*f[a-z]*\b`,
74
- "'git clean -f' is destructive and is not allowed.",
75
- ],
76
- [
77
- // Killing by image name reaches every process with that name on the machine — the
78
- // server the user is watching, another session's, a half-run tool. The damage is
79
- // silent. Keep the PID you started and kill that; if lost, identify by full command
80
- // line and confirm ownership first.
81
- [
82
- String.raw`\b(`,
83
- taskKill,
84
- String.raw`\s+[^|;]*[/]IM|`,
85
- pKill,
86
- String.raw`\s+|`,
87
- killAll,
88
- String.raw`\s+)`,
89
- ].join(''),
90
- 'Killing processes by NAME reaches everything with that name, not just yours. ' +
91
- 'Keep the PID you started and kill that; if lost, find it by its full command ' +
92
- 'line and confirm it is yours before touching it.',
93
- ],
94
- [
95
- STOP_PROCESS_BY_NAME_SOURCE,
96
- 'Stop-Process -Name kills every process of that name on the machine, not just yours. ' +
97
- 'Stop the specific process by its Id (the PID you started); if lost, identify it by ' +
98
- 'its full command line and confirm ownership first.',
99
- ],
100
- ];
101
- }
102
-
103
- // Interpreter name, then any intermediate flags (e.g. --input-type=module), then an
104
- // eval flag. Intermediate flags are matched loosely (`-\S+`) to keep the pattern simple.
105
63
  const INTERPRETER_EVAL_PATTERN =
106
64
  /\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
107
65
  const RAW_FILE_OPS_PATTERN =
108
66
  /\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
109
67
 
110
- function compile(source) {
111
- return new RegExp(source, 'i');
112
- }
113
-
114
- // git's GLOBAL options sit between `git` and the subcommand: `git -C <path> reset --hard`,
115
- // `git -c k=v push`, `git --git-dir=… clean -f`. A pattern that matches `git reset --hard`
116
- // contiguously is evaded by any of them. Stripping these options first — turning
117
- // `git -C /repo reset --hard` back into `git reset --hard` — closes that bypass for every
118
- // git rule at once, instead of teaching each pattern about every global option.
119
- //
120
- // A single global option, matched one at a time and stripped repeatedly (below), so the
121
- // pattern stays simple: an option taking a value (`-C /path`, `--git-dir=…`) or a flag
122
- // (`--no-pager`). The leading `git ` is kept; only the option after it is removed.
123
- const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
124
- const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
125
- const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
126
- String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
127
- 'i',
128
- );
129
-
130
- function normalizeGitOptions(command) {
131
- // Strip one leading global option at a time and re-run, so a stacked
132
- // `git -c a=b -C /x reset` is fully reduced to `git reset` before the deny patterns run.
133
- let previous;
134
- let normalized = command;
135
- do {
136
- previous = normalized;
137
- normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
138
- } while (normalized !== previous);
139
- return normalized;
140
- }
141
-
142
68
  function checkEmbeddedInterpreter(command) {
143
69
  const match = INTERPRETER_EVAL_PATTERN.exec(command);
144
70
  if (!match) return null;
@@ -149,67 +75,164 @@ function checkEmbeddedInterpreter(command) {
149
75
  if (RAW_FILE_OPS_PATTERN.test(inline)) {
150
76
  return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
151
77
  }
152
- return null; // import/require or fetch: logic the native tools do not cover — allowed
78
+ return null;
153
79
  }
154
80
 
155
- /** The text to inspect: a real command's command line, or the delegation prompt. */
156
- function commandTextFrom(toolName, toolInput) {
157
- if (toolInGroups(toolName, ['shell'])) {
158
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
159
- }
160
- return delegationPromptOf(toolInput);
81
+ // ── rm -rf over a protected area ────────────────────────────────────────────────────
82
+ const RM_INVOCATION_PATTERN = /(?:^|[\s;&|(`$'"])(rm\s+([^;&|\n)`]*))/gi;
83
+ const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
84
+
85
+ function isRecursiveFlag(token) {
86
+ return (
87
+ token === '--recursive' || (!token.startsWith('--') && /r/i.test(token))
88
+ );
161
89
  }
162
90
 
163
- /** Static deny rules + the runtime rm -rf rule, both read from config params. */
164
- function checkDestructive(command, parameters) {
165
- // Strip git's global options so `git -C /repo reset --hard` cannot slip past a pattern
166
- // written for `git reset --hard`. Non-git commands are unaffected.
167
- const normalized = normalizeGitOptions(command);
168
-
169
- // denyPatterns may be a flat list of sources or [source, reason] pairs; normalize.
170
- const denyPairs = (parameters.denyPatterns ?? []).map((entry) =>
171
- Array.isArray(entry)
172
- ? entry
173
- : [entry, 'Destructive command is not allowed.'],
91
+ function isForceFlag(token) {
92
+ return (
93
+ token === '--force' || (!token.startsWith('--') && token.includes('f'))
174
94
  );
175
- for (const [source, reason] of denyPairs) {
176
- if (compile(source).test(normalized)) deny(CONFIG_KEY, reason);
95
+ }
96
+
97
+ function stripTrailingQuotes(token) {
98
+ let stripped = token;
99
+ while (stripped.endsWith('"') || stripped.endsWith("'"))
100
+ stripped = stripped.slice(0, -1);
101
+ return stripped;
102
+ }
103
+
104
+ function rmArguments(argumentText) {
105
+ const flags = { recursive: false, force: false };
106
+ const targets = [];
107
+ let optionsEnded = false;
108
+ for (const match of argumentText.matchAll(ARGUMENT_PATTERN)) {
109
+ const token = match[1] ?? match[2] ?? stripTrailingQuotes(match[3]);
110
+ if (optionsEnded || !token.startsWith('-') || token === '-') {
111
+ targets.push(token);
112
+ } else if (token === '--') {
113
+ optionsEnded = true;
114
+ } else {
115
+ flags.recursive ||= isRecursiveFlag(token);
116
+ flags.force ||= isForceFlag(token);
117
+ }
177
118
  }
119
+ return { recursiveForce: flags.recursive && flags.force, targets };
120
+ }
178
121
 
179
- // rm -rf over a protected area: areas read from config at runtime, so editing
180
- // rmRfProtectedAreas takes effect without touching denyPatterns.
181
- const rmRfAreas =
182
- parameters.rmRfProtectedAreas ?? DEFAULT_RM_RF_PROTECTED_AREAS;
183
- if (
184
- rmRfAreas.length > 0 &&
185
- compile(rmRfSourceFrom(rmRfAreas)).test(command)
186
- ) {
187
- deny(CONFIG_KEY, "'rm -rf' over a protected area is not allowed.");
122
+ function normalizeTarget(path) {
123
+ let normalized = String(path).replace(/\\/g, '/');
124
+ while (normalized.startsWith('./')) normalized = normalized.slice(2);
125
+ while (normalized.length > 1 && normalized.endsWith('/'))
126
+ normalized = normalized.slice(0, -1);
127
+ return normalized;
128
+ }
129
+
130
+ function hitsArea(target, area) {
131
+ if (area === '/') return target === '/' || target === '/*';
132
+ return target === area || target.startsWith(`${area}/`);
133
+ }
134
+
135
+ function protectedRecursiveDeletes(command, areas) {
136
+ const normalizedAreas = areas.map(normalizeTarget).filter(Boolean);
137
+ if (normalizedAreas.length === 0) return [];
138
+ const hits = [];
139
+ for (const match of String(command).matchAll(RM_INVOCATION_PATTERN)) {
140
+ const { recursiveForce, targets } = rmArguments(match[2]);
141
+ if (!recursiveForce) continue;
142
+ const target = targets
143
+ .map(normalizeTarget)
144
+ .find((candidate) =>
145
+ normalizedAreas.some((area) => hitsArea(candidate, area)),
146
+ );
147
+ if (target !== undefined) hits.push({ text: match[1].trim(), target });
188
148
  }
149
+ return hits;
150
+ }
189
151
 
152
+ function rmReason(hit, areas) {
153
+ return (
154
+ `'${hit.text}' would wipe '${hit.target}', a protected area (${areas.join(', ')}). ` +
155
+ 'Delete a specific path inside it instead, or edit rmRfProtectedAreas under ' +
156
+ `${CONFIG_KEY} in .ai/config.json.`
157
+ );
158
+ }
159
+
160
+ // ── Rules ───────────────────────────────────────────────────────────────────────────
161
+ function destructiveRules(parameters) {
162
+ return normalizeRulePairs(parameters.denyPatterns, DEFAULT_DENY_REASON)
163
+ .map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
164
+ .filter((rule) => rule.pattern !== null);
165
+ }
166
+
167
+ function checkShellCommand(command, parameters) {
168
+ const normalized = normalizeGitCommand(command);
169
+ for (const { pattern, reason } of destructiveRules(parameters)) {
170
+ const match = pattern.exec(normalized);
171
+ if (match) deny(CONFIG_KEY, `${reason} (matched: "${match[0].trim()}")`);
172
+ }
173
+ const [hit] = protectedRecursiveDeletes(
174
+ command,
175
+ parameters.rmRfProtectedAreas,
176
+ );
177
+ if (hit) deny(CONFIG_KEY, rmReason(hit, parameters.rmRfProtectedAreas));
190
178
  if (parameters.embeddedInterpreterEnabled) {
191
179
  const reason = checkEmbeddedInterpreter(command);
192
180
  if (reason) deny(CONFIG_KEY, reason);
193
181
  }
194
182
  }
195
183
 
184
+ const DELEGATION_REMEDY =
185
+ 'The delegation prompt orders this command; a subagent must not run it either. ' +
186
+ 'If the prompt only describes or forbids it, say so in the same sentence.';
187
+
188
+ function checkDelegationPrompt(prompt, parameters) {
189
+ const normalized = normalizeGitCommand(prompt);
190
+ for (const { pattern, reason } of destructiveRules(parameters)) {
191
+ if (hasRealCommandIntent(normalized, pattern))
192
+ deny(CONFIG_KEY, `${reason} ${DELEGATION_REMEDY}`);
193
+ }
194
+ const areas = parameters.rmRfProtectedAreas;
195
+ for (const hit of protectedRecursiveDeletes(prompt, areas)) {
196
+ const mention = new RegExp(escapeRegExp(hit.text), 'i');
197
+ if (hasRealCommandIntent(prompt, mention))
198
+ deny(CONFIG_KEY, `${rmReason(hit, areas)} ${DELEGATION_REMEDY}`);
199
+ }
200
+ if (parameters.embeddedInterpreterEnabled) {
201
+ const reason = checkEmbeddedInterpreter(prompt);
202
+ if (reason && hasRealCommandIntent(prompt, INTERPRETER_EVAL_PATTERN))
203
+ deny(CONFIG_KEY, reason);
204
+ }
205
+ }
206
+
207
+ // toolInputOf reduces a bare-string tool_input to {}, which would let a command sent in that
208
+ // shape through unread; the raw payload still carries it.
209
+ function shellCommandFrom(rawPayload, toolInput) {
210
+ const command = shellCommandOf(toolInput);
211
+ if (command) return command;
212
+ try {
213
+ const rawInput = JSON.parse(rawPayload)?.tool_input;
214
+ return typeof rawInput === 'string' ? rawInput : '';
215
+ } catch {
216
+ return '';
217
+ }
218
+ }
219
+
196
220
  runGate(
197
221
  {
198
222
  id: GATE_ID,
199
223
  configKey: CONFIG_KEY,
200
224
  enabledByDefault: true,
201
225
  defaultParams: {
202
- denyPatterns: defaultDenyPatterns(),
226
+ denyPatterns: DEFAULT_DENY_PATTERNS,
203
227
  rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
204
228
  embeddedInterpreterEnabled: false,
205
229
  },
206
230
  },
207
- ({ toolName, toolInput, parameters }) => {
208
- // Destructive-command rules act on a real shell command AND on a delegation prompt (a
209
- // subagent can be told "run rm -rf" in prose). commandTextFrom picks the right text.
210
- if (!toolInGroups(toolName, ['shell', 'delegation'])) return;
211
-
212
- const command = commandTextFrom(toolName, toolInput);
213
- checkDestructive(command, parameters);
231
+ ({ rawPayload, toolName, toolInput, parameters }) => {
232
+ if (toolInGroups(toolName, ['shell'])) {
233
+ checkShellCommand(shellCommandFrom(rawPayload, toolInput), parameters);
234
+ } else if (toolInGroups(toolName, ['delegation'])) {
235
+ checkDelegationPrompt(delegationPromptOf(toolInput), parameters);
236
+ }
214
237
  },
215
238
  );