@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +65 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +61 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- package/registry.json +192 -1
|
@@ -1,215 +1,215 @@
|
|
|
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
|
-
|
|
19
|
-
import {
|
|
20
|
-
runGate,
|
|
21
|
-
deny,
|
|
22
|
-
toolInGroups,
|
|
23
|
-
delegationPromptOf,
|
|
24
|
-
} from '../../lib/hook-io.mjs';
|
|
25
|
-
|
|
26
|
-
const GATE_ID = 'bash-commands';
|
|
27
|
-
const CONFIG_KEY = 'blockDestructiveShellCommands';
|
|
28
|
-
|
|
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
|
-
const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
|
|
32
|
-
|
|
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
|
-
const INTERPRETER_EVAL_PATTERN =
|
|
106
|
-
/\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
|
|
107
|
-
const RAW_FILE_OPS_PATTERN =
|
|
108
|
-
/\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
|
|
109
|
-
|
|
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
|
-
function checkEmbeddedInterpreter(command) {
|
|
143
|
-
const match = INTERPRETER_EVAL_PATTERN.exec(command);
|
|
144
|
-
if (!match) return null;
|
|
145
|
-
if (match[1].toLowerCase().startsWith('python')) {
|
|
146
|
-
return 'Inline Python is not allowed in this harness: write the script to a file with Write and run that.';
|
|
147
|
-
}
|
|
148
|
-
const inline = command.slice(match.index + match[0].length);
|
|
149
|
-
if (RAW_FILE_OPS_PATTERN.test(inline)) {
|
|
150
|
-
return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
|
|
151
|
-
}
|
|
152
|
-
return null; // import/require or fetch: logic the native tools do not cover — allowed
|
|
153
|
-
}
|
|
154
|
-
|
|
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);
|
|
161
|
-
}
|
|
162
|
-
|
|
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.'],
|
|
174
|
-
);
|
|
175
|
-
for (const [source, reason] of denyPairs) {
|
|
176
|
-
if (compile(source).test(normalized)) deny(GATE_ID, reason);
|
|
177
|
-
}
|
|
178
|
-
|
|
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(GATE_ID, "'rm -rf' over a protected area is not allowed.");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (parameters.embeddedInterpreterEnabled) {
|
|
191
|
-
const reason = checkEmbeddedInterpreter(command);
|
|
192
|
-
if (reason) deny(GATE_ID, reason);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
runGate(
|
|
197
|
-
{
|
|
198
|
-
id: GATE_ID,
|
|
199
|
-
configKey: CONFIG_KEY,
|
|
200
|
-
enabledByDefault: true,
|
|
201
|
-
defaultParams: {
|
|
202
|
-
denyPatterns: defaultDenyPatterns(),
|
|
203
|
-
rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
|
|
204
|
-
embeddedInterpreterEnabled: false,
|
|
205
|
-
},
|
|
206
|
-
},
|
|
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);
|
|
214
|
-
},
|
|
215
|
-
);
|
|
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
|
+
|
|
19
|
+
import {
|
|
20
|
+
runGate,
|
|
21
|
+
deny,
|
|
22
|
+
toolInGroups,
|
|
23
|
+
delegationPromptOf,
|
|
24
|
+
} from '../../lib/hook-io.mjs';
|
|
25
|
+
|
|
26
|
+
const GATE_ID = 'bash-commands';
|
|
27
|
+
const CONFIG_KEY = 'blockDestructiveShellCommands';
|
|
28
|
+
|
|
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
|
+
const DEFAULT_RM_RF_PROTECTED_AREAS = ['/', '*', 'src', 'tests'];
|
|
32
|
+
|
|
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
|
+
const INTERPRETER_EVAL_PATTERN =
|
|
106
|
+
/\b(node|python3?|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|-c|--eval|--print|-p)\b/i;
|
|
107
|
+
const RAW_FILE_OPS_PATTERN =
|
|
108
|
+
/\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
|
|
109
|
+
|
|
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
|
+
function checkEmbeddedInterpreter(command) {
|
|
143
|
+
const match = INTERPRETER_EVAL_PATTERN.exec(command);
|
|
144
|
+
if (!match) return null;
|
|
145
|
+
if (match[1].toLowerCase().startsWith('python')) {
|
|
146
|
+
return 'Inline Python is not allowed in this harness: write the script to a file with Write and run that.';
|
|
147
|
+
}
|
|
148
|
+
const inline = command.slice(match.index + match[0].length);
|
|
149
|
+
if (RAW_FILE_OPS_PATTERN.test(inline)) {
|
|
150
|
+
return 'Inline interpreter doing a raw file operation (read/write/delete/mkdir). Use Write/Edit/Read instead.';
|
|
151
|
+
}
|
|
152
|
+
return null; // import/require or fetch: logic the native tools do not cover — allowed
|
|
153
|
+
}
|
|
154
|
+
|
|
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);
|
|
161
|
+
}
|
|
162
|
+
|
|
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.'],
|
|
174
|
+
);
|
|
175
|
+
for (const [source, reason] of denyPairs) {
|
|
176
|
+
if (compile(source).test(normalized)) deny(GATE_ID, reason);
|
|
177
|
+
}
|
|
178
|
+
|
|
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(GATE_ID, "'rm -rf' over a protected area is not allowed.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (parameters.embeddedInterpreterEnabled) {
|
|
191
|
+
const reason = checkEmbeddedInterpreter(command);
|
|
192
|
+
if (reason) deny(GATE_ID, reason);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
runGate(
|
|
197
|
+
{
|
|
198
|
+
id: GATE_ID,
|
|
199
|
+
configKey: CONFIG_KEY,
|
|
200
|
+
enabledByDefault: true,
|
|
201
|
+
defaultParams: {
|
|
202
|
+
denyPatterns: defaultDenyPatterns(),
|
|
203
|
+
rmRfProtectedAreas: DEFAULT_RM_RF_PROTECTED_AREAS,
|
|
204
|
+
embeddedInterpreterEnabled: false,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
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);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// brief-approved — denies an implementation delegation (STANDARD/HIGH-RISK, an
|
|
2
|
+
// implementation verb) that targets a feature whose brief/spec has no recorded human
|
|
3
|
+
// approval. Migrated from a real incident: the assistant took an ambiguous user reply
|
|
4
|
+
// ("hagamos el brief") as approval of a spec it had already written, delegated
|
|
5
|
+
// implementation straight away, and never pasted the full brief into the chat for the
|
|
6
|
+
// user to confirm line by line. `sdd-specs` already blocks implementing a feature with
|
|
7
|
+
// NO contract at all; this gate blocks implementing a feature whose contract exists but
|
|
8
|
+
// was never actually approved by the user — a distinct failure this incident exposed.
|
|
9
|
+
//
|
|
10
|
+
// ── What "approved" means here (deliberately narrow) ────────────────────────────────
|
|
11
|
+
// A hook cannot read the chat, so it cannot verify the assistant actually pasted the
|
|
12
|
+
// full brief and that the user actually read it. What IS checkable: the brief file
|
|
13
|
+
// itself carries a frontmatter block written ONLY once that confirmation happened —
|
|
14
|
+
// ---
|
|
15
|
+
// status: approved
|
|
16
|
+
// approved_at: <ISO timestamp>
|
|
17
|
+
// approval_quote: "<user's own words>"
|
|
18
|
+
// ---
|
|
19
|
+
// The gate does not (cannot) verify the quote is genuine; it verifies the field exists
|
|
20
|
+
// and is non-empty, which at minimum forces the assistant to stop and produce a
|
|
21
|
+
// specific artifact instead of silently inferring consent from a vague reply.
|
|
22
|
+
//
|
|
23
|
+
// ── Auto-off when there is no brief to approve ──────────────────────────────────────
|
|
24
|
+
// Same discovery as sdd-specs: only fires once a feature contract tree exists under
|
|
25
|
+
// .ai/features/<name>/ (or the configured catalog's sibling `features` dir) AND that
|
|
26
|
+
// feature has a brief.md/asserts.md file. A project with no such file for the cited
|
|
27
|
+
// feature is out of this gate's scope (sdd-specs already denies that case).
|
|
28
|
+
//
|
|
29
|
+
// ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
|
|
30
|
+
// The frontmatter field names (status/approved_at/approval_quote) are fixed, not a
|
|
31
|
+
// project param — a configurable field name would let a delegation "approve" itself by
|
|
32
|
+
// pointing the gate at whatever key it just wrote.
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import {
|
|
37
|
+
runGate,
|
|
38
|
+
deny,
|
|
39
|
+
toolInGroups,
|
|
40
|
+
delegationPromptOf,
|
|
41
|
+
} from '../../lib/hook-io.mjs';
|
|
42
|
+
|
|
43
|
+
const GATE_ID = 'brief-approved';
|
|
44
|
+
const CONFIG_KEY = 'requireApprovedBriefBeforeImplementing';
|
|
45
|
+
|
|
46
|
+
const CATALOG_FILE_NAME = 'feature_list.json';
|
|
47
|
+
const DEFAULT_CATALOG_LOCATIONS = [
|
|
48
|
+
join('.ai', CATALOG_FILE_NAME),
|
|
49
|
+
CATALOG_FILE_NAME,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
53
|
+
'explore',
|
|
54
|
+
'plan',
|
|
55
|
+
'scout',
|
|
56
|
+
'revision',
|
|
57
|
+
'contraste',
|
|
58
|
+
'test-planner',
|
|
59
|
+
'qa',
|
|
60
|
+
'ui',
|
|
61
|
+
'ux',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Files that count as a feature's contract; the first one found on disk is the one
|
|
65
|
+
* checked for approval (mirrors sdd-specs' CONTRACT_FILES precedence). */
|
|
66
|
+
const CONTRACT_FILES = [
|
|
67
|
+
'brief.md',
|
|
68
|
+
'requirements.md',
|
|
69
|
+
'design.md',
|
|
70
|
+
'tasks.md',
|
|
71
|
+
'asserts.md',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const APPROVED_FRONTMATTER_PATTERN = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/;
|
|
75
|
+
const APPROVED_STATUS_PATTERN = /^status:[ \t]*approved[ \t]*$/im;
|
|
76
|
+
const APPROVAL_QUOTE_PATTERN = /^approval_quote:[ \t]*(\S.*)$/im;
|
|
77
|
+
|
|
78
|
+
function withWordBoundary(alternation) {
|
|
79
|
+
return new RegExp(
|
|
80
|
+
`(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
|
|
81
|
+
'iu',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
86
|
+
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
87
|
+
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
88
|
+
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
89
|
+
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
90
|
+
'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
91
|
+
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const DEMANDING_LEVEL_PATTERN =
|
|
95
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
96
|
+
const EXEMPT_LEVEL_PATTERN =
|
|
97
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
98
|
+
|
|
99
|
+
function findCatalog(catalogLocations) {
|
|
100
|
+
for (const relative of catalogLocations) {
|
|
101
|
+
const path = join(process.cwd(), relative);
|
|
102
|
+
if (existsSync(path)) return path;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function contractTreeRootFor(catalogPath) {
|
|
108
|
+
const candidates = catalogPath
|
|
109
|
+
? [join(dirname(catalogPath), 'features')]
|
|
110
|
+
: [];
|
|
111
|
+
candidates.push(join(process.cwd(), '.ai', 'features'));
|
|
112
|
+
candidates.push(join(process.cwd(), 'features'));
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (existsSync(candidate)) return candidate;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The first existing contract file's path for a feature, or null if none exists. */
|
|
120
|
+
function contractFileFor(treeRoot, featureName) {
|
|
121
|
+
if (!treeRoot) return null;
|
|
122
|
+
const featureDirectory = join(treeRoot, featureName);
|
|
123
|
+
if (!existsSync(featureDirectory)) return null;
|
|
124
|
+
for (const file of CONTRACT_FILES) {
|
|
125
|
+
const path = join(featureDirectory, file);
|
|
126
|
+
if (existsSync(path)) return path;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether a contract file's frontmatter records a real approval: `status: approved`
|
|
132
|
+
* plus a non-empty `approval_quote`. Both must be present — status alone is a label
|
|
133
|
+
* the assistant could set on itself with no evidence a quote was ever collected. */
|
|
134
|
+
function isApproved(contractPath) {
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(contractPath, 'utf8');
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const frontmatter = APPROVED_FRONTMATTER_PATTERN.exec(content)?.groups?.body;
|
|
142
|
+
if (!frontmatter) return false;
|
|
143
|
+
if (!APPROVED_STATUS_PATTERN.test(frontmatter)) return false;
|
|
144
|
+
const quoteMatch = APPROVAL_QUOTE_PATTERN.exec(frontmatter);
|
|
145
|
+
const quote = quoteMatch?.[1]?.replace(/^["']|["']$/g, '').trim();
|
|
146
|
+
return Boolean(quote);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isExemptDelegation(toolInput, prompt, exemptSubagents) {
|
|
150
|
+
const subagentType = String(
|
|
151
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
152
|
+
).toLowerCase();
|
|
153
|
+
if (exemptSubagents.includes(subagentType)) return true;
|
|
154
|
+
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
155
|
+
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
156
|
+
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function featureNamesCitedIn(prompt) {
|
|
161
|
+
const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
|
|
162
|
+
const names = [];
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
|
|
165
|
+
return names;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
runGate(
|
|
169
|
+
{
|
|
170
|
+
id: GATE_ID,
|
|
171
|
+
configKey: CONFIG_KEY,
|
|
172
|
+
enabledByDefault: true,
|
|
173
|
+
defaultParams: {
|
|
174
|
+
catalogLocations: DEFAULT_CATALOG_LOCATIONS,
|
|
175
|
+
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
({ toolName, toolInput, parameters }) => {
|
|
179
|
+
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
180
|
+
|
|
181
|
+
const prompt = delegationPromptOf(toolInput);
|
|
182
|
+
if (!prompt.trim()) return;
|
|
183
|
+
|
|
184
|
+
const exemptSubagents =
|
|
185
|
+
parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
|
|
186
|
+
if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
|
|
187
|
+
|
|
188
|
+
const citedFeatures = featureNamesCitedIn(prompt);
|
|
189
|
+
if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
|
|
190
|
+
|
|
191
|
+
const catalogLocations =
|
|
192
|
+
parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
|
|
193
|
+
const catalogPath = findCatalog(catalogLocations);
|
|
194
|
+
const treeRoot = contractTreeRootFor(catalogPath);
|
|
195
|
+
if (!treeRoot) return; // no SDD harness adopted: stay silent
|
|
196
|
+
|
|
197
|
+
const unapproved = citedFeatures.filter((feature) => {
|
|
198
|
+
const contractPath = contractFileFor(treeRoot, feature);
|
|
199
|
+
if (!contractPath) return false; // sdd-specs already denies this case
|
|
200
|
+
return !isApproved(contractPath);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (unapproved.length === 0) return;
|
|
204
|
+
|
|
205
|
+
deny(
|
|
206
|
+
GATE_ID,
|
|
207
|
+
`This implementation delegation cites feature(s) [${unapproved.join(', ')}] ` +
|
|
208
|
+
'whose brief/contract has no recorded approval. Paste the FULL brief into the ' +
|
|
209
|
+
'chat, get an explicit confirmation from the user (not a vague "dale"/"sigamos" ' +
|
|
210
|
+
'— an actual sentence confirming they read it), then add this frontmatter to ' +
|
|
211
|
+
'the top of the contract file before relaunching:\n' +
|
|
212
|
+
'---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
|
|
213
|
+
'approval_quote: "<the user\'s own confirming words>"\n---',
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
);
|