@devrik-tools/claude-gates 0.7.2 → 0.8.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 +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,144 +1,70 @@
|
|
|
1
|
-
// bash-commands — denies destructive shell commands (
|
|
2
|
-
// reset --hard, force push, kill-by-name)
|
|
3
|
-
// delegation prompt
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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;
|
|
78
|
+
return null;
|
|
153
79
|
}
|
|
154
80
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
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
|
);
|
|
@@ -1,113 +1,123 @@
|
|
|
1
1
|
// block-remote-publish — denies publishing to a remote (`git push`, `gh pr merge`,
|
|
2
|
-
// `gh release create`) without fresh human authorization. Split out of bash-commands so
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Default: enabled. The safe posture is that the human states the authorization in chat,
|
|
9
|
-
// naming commits, branch, remote and target — a project that wants the agent to push on its
|
|
10
|
-
// own opts in explicitly by disabling this gate.
|
|
11
|
-
//
|
|
12
|
-
// ── Two-stage intent check, only for delegation prompts ─────────────────────────────
|
|
13
|
-
// A real command's `command` field IS what the shell runs — a single-stage regex is right.
|
|
14
|
-
// A delegation prompt is natural language that may DESCRIBE a command ("I extended the guard
|
|
15
|
-
// to deny git push") without asking anyone to run it. There, publish rules run a second
|
|
16
|
-
// stage: strip quoted/example text, then require that at least one surviving mention is not
|
|
17
|
-
// governed by a reporting verb before denying.
|
|
2
|
+
// `gh release create`) without fresh human authorization. Split out of bash-commands so a
|
|
3
|
+
// project can let the agent push (blockRemotePublish: false) without losing the destructive
|
|
4
|
+
// command protections. Deliberate limits: only a command segment that RUNS git/gh is judged
|
|
5
|
+
// (a mention inside grep, echo or a --grep value is not a publish), a `--dry-run` push writes
|
|
6
|
+
// nothing, and a delegation prompt is denied only when it orders the publish rather than
|
|
7
|
+
// describing or forbidding it.
|
|
18
8
|
|
|
9
|
+
import { hasRealCommandIntent } from '../../lib/delegation.mjs';
|
|
10
|
+
import { normalizeGitCommand } from '../../lib/git.mjs';
|
|
19
11
|
import {
|
|
20
|
-
|
|
12
|
+
compileRegex,
|
|
13
|
+
delegationPromptOf,
|
|
21
14
|
deny,
|
|
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 = 'block-remote-publish';
|
|
27
22
|
const CONFIG_KEY = 'blockRemotePublish';
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
|
|
35
|
-
"Publishing to a remote ('git push') needs fresh authorization from the user naming " +
|
|
36
|
-
'commits, local branch, remote and target. This gate cannot verify that from the ' +
|
|
37
|
-
'command itself — ask the user and let them run it. To let the agent push on its ' +
|
|
38
|
-
'own, set "blockRemotePublish": false in .ai/config.json.',
|
|
39
|
-
],
|
|
40
|
-
[
|
|
41
|
-
String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
|
|
42
|
-
'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
|
|
43
|
-
'authorization from the user naming commits, local branch, remote and target. ' +
|
|
44
|
-
'Ask the user and let them run it. To let the agent publish on its own, set ' +
|
|
45
|
-
'"blockRemotePublish": false in .ai/config.json.',
|
|
46
|
-
],
|
|
47
|
-
];
|
|
48
|
-
}
|
|
24
|
+
const AUTHORIZATION_REMEDY =
|
|
25
|
+
'needs fresh authorization from the user naming commits, local branch, remote and ' +
|
|
26
|
+
'target. This gate cannot verify that from the command itself — ask the user and let ' +
|
|
27
|
+
'them run it. To let the agent publish on its own, set "blockRemotePublish": false in ' +
|
|
28
|
+
'.ai/config.json.';
|
|
49
29
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
const DEFAULT_PUBLISH_RULES = [
|
|
31
|
+
[
|
|
32
|
+
String.raw`\bgit\s+push\b`,
|
|
33
|
+
`Publishing to a remote ('git push') ${AUTHORIZATION_REMEDY}`,
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
|
|
37
|
+
`Publishing via GitHub CLI (merging a PR or creating a release) ${AUTHORIZATION_REMEDY}`,
|
|
38
|
+
],
|
|
39
|
+
];
|
|
40
|
+
const DEFAULT_REASON = `Publishing to a remote ${AUTHORIZATION_REMEDY}`;
|
|
57
41
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
42
|
+
// `gh -R owner/repo pr merge` and `gh pr --repo x merge` name the repo between the words a
|
|
43
|
+
// rule looks for; stripping the option first keeps the rules readable.
|
|
44
|
+
const GH_REPO_OPTION_PATTERN =
|
|
45
|
+
/(\bgh\s+(?:pr\s+|release\s+)?)(?:-R|--repo)(?:=|\s+)\S+\s+/i;
|
|
61
46
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// first — turning `git -C /repo push` back into `git push` — closes that bypass at once.
|
|
65
|
-
const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
|
|
66
|
-
const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
|
|
67
|
-
const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
|
|
68
|
-
String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
|
|
69
|
-
'i',
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
function normalizeGitOptions(command) {
|
|
47
|
+
function normalizeGhCommand(text) {
|
|
48
|
+
let normalized = text;
|
|
73
49
|
let previous;
|
|
74
|
-
let normalized = command;
|
|
75
50
|
do {
|
|
76
51
|
previous = normalized;
|
|
77
|
-
normalized = normalized.replace(
|
|
52
|
+
normalized = normalized.replace(GH_REPO_OPTION_PATTERN, '$1');
|
|
78
53
|
} while (normalized !== previous);
|
|
79
54
|
return normalized;
|
|
80
55
|
}
|
|
81
56
|
|
|
82
|
-
function
|
|
83
|
-
return text
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
57
|
+
function normalizeCommand(text) {
|
|
58
|
+
return normalizeGhCommand(normalizeGitCommand(text));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Command segments that run git/gh ────────────────────────────────────────────────
|
|
62
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n|\$\(|`/;
|
|
63
|
+
const WRAPPER_WORD_PATTERN =
|
|
64
|
+
/^(?:\w+=\S*|command|sudo|exec|env|time|nohup|builtin)$/i;
|
|
65
|
+
|
|
66
|
+
function stripLeadingWrappers(segment) {
|
|
67
|
+
const tokens = segment.replace(/^[\s(]+/, '').split(/\s+/);
|
|
68
|
+
while (tokens.length > 0 && WRAPPER_WORD_PATTERN.test(tokens[0]))
|
|
69
|
+
tokens.shift();
|
|
70
|
+
return tokens.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SHELL_WRAPPER_PATTERN =
|
|
74
|
+
/^(?:sh|bash|zsh|dash|ksh)\s+(-\w+)\s+(?:"([^"]*)"|'([^']*)')/i;
|
|
75
|
+
const QUOTED_PATTERN = /"[^"]*"|'[^']*'/g;
|
|
76
|
+
const PUBLISHING_BINARY_PATTERN = /^(?:git|gh)(?=\s|$)/i;
|
|
77
|
+
|
|
78
|
+
function segmentsRunningGit(command) {
|
|
79
|
+
const segments = [];
|
|
80
|
+
for (const rawSegment of String(command).split(SEGMENT_SEPARATOR)) {
|
|
81
|
+
const segment = stripLeadingWrappers(rawSegment);
|
|
82
|
+
const wrapped = SHELL_WRAPPER_PATTERN.exec(segment);
|
|
83
|
+
if (wrapped && wrapped[1].includes('c')) {
|
|
84
|
+
segments.push(...segmentsRunningGit(wrapped[2] ?? wrapped[3]));
|
|
85
|
+
} else if (PUBLISHING_BINARY_PATTERN.test(segment)) {
|
|
86
|
+
segments.push(segment.replace(QUOTED_PATTERN, ' '));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return segments;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
pattern
|
|
95
|
-
`${pattern.flags.replace('g', '')}g`,
|
|
96
|
-
);
|
|
97
|
-
const matches = [...cleaned.matchAll(global)];
|
|
98
|
-
if (matches.length === 0) return false;
|
|
99
|
-
return matches.some((match) => {
|
|
100
|
-
const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
|
|
101
|
-
return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
|
|
102
|
-
});
|
|
92
|
+
const DRY_RUN_PUSH_PATTERN = /\bgit\s+push\b.*(?:\s--dry-run\b|\s-n\b)/i;
|
|
93
|
+
|
|
94
|
+
function publishRules(parameters) {
|
|
95
|
+
return normalizeRulePairs(parameters.publishRules, DEFAULT_REASON)
|
|
96
|
+
.map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
|
|
97
|
+
.filter((rule) => rule.pattern !== null);
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
function checkShellCommand(command, parameters) {
|
|
101
|
+
const rules = publishRules(parameters);
|
|
102
|
+
for (const segment of segmentsRunningGit(normalizeCommand(command))) {
|
|
103
|
+
if (DRY_RUN_PUSH_PATTERN.test(segment)) continue;
|
|
104
|
+
for (const { pattern, reason } of rules) {
|
|
105
|
+
if (pattern.test(segment))
|
|
106
|
+
deny(CONFIG_KEY, `${reason} (command: "${segment.trim()}")`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkDelegationPrompt(prompt, parameters) {
|
|
112
|
+
const normalized = normalizeCommand(prompt);
|
|
113
|
+
for (const { pattern, reason } of publishRules(parameters)) {
|
|
114
|
+
if (hasRealCommandIntent(normalized, pattern)) {
|
|
115
|
+
deny(
|
|
116
|
+
CONFIG_KEY,
|
|
117
|
+
`${reason} The delegation prompt orders the publish; a subagent must not run it either.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
109
120
|
}
|
|
110
|
-
return delegationPromptOf(toolInput);
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
runGate(
|
|
@@ -116,25 +126,14 @@ runGate(
|
|
|
116
126
|
configKey: CONFIG_KEY,
|
|
117
127
|
enabledByDefault: true,
|
|
118
128
|
defaultParams: {
|
|
119
|
-
publishRules:
|
|
129
|
+
publishRules: DEFAULT_PUBLISH_RULES,
|
|
120
130
|
},
|
|
121
131
|
},
|
|
122
132
|
({ toolName, toolInput, parameters }) => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
const command = commandTextFrom(toolName, toolInput);
|
|
128
|
-
// For a real shell command, normalize git's global options first. For a delegation
|
|
129
|
-
// prompt (free text), the intent check runs on the raw text.
|
|
130
|
-
const shellCommand = normalizeGitOptions(command);
|
|
131
|
-
for (const [source, reason] of parameters.publishRules) {
|
|
132
|
-
const pattern = compile(source);
|
|
133
|
-
if (isShell) {
|
|
134
|
-
if (pattern.test(shellCommand)) deny(CONFIG_KEY, reason);
|
|
135
|
-
} else if (hasRealPublishIntent(command, pattern)) {
|
|
136
|
-
deny(CONFIG_KEY, reason);
|
|
137
|
-
}
|
|
133
|
+
if (toolInGroups(toolName, ['shell'])) {
|
|
134
|
+
checkShellCommand(shellCommandOf(toolInput), parameters);
|
|
135
|
+
} else if (toolInGroups(toolName, ['delegation'])) {
|
|
136
|
+
checkDelegationPrompt(delegationPromptOf(toolInput), parameters);
|
|
138
137
|
}
|
|
139
138
|
},
|
|
140
139
|
);
|