@devrik-tools/claude-gates 0.8.0 → 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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +66 -8
- package/README.md +56 -7
- package/cli/registry.mjs +1 -0
- package/cli/smoke-fixtures.json +45 -3
- package/cli/task.mjs +69 -4
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
- package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/hooks.json +41 -1
- package/plugins/gates/hooks/lib/hook-io.mjs +5 -2
- package/plugins/gates/hooks/lib/testing.mjs +15 -4
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +48 -5
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// circuit-breaker PostToolUse tracker — records a delegation attempt AFTER it was allowed
|
|
2
|
+
// by all PreToolUse hooks (including this gate's own). A delegation rejected by another
|
|
3
|
+
// gate (intent-flow, brief-before-delegate, etc.) never reaches PostToolUse, so it does
|
|
4
|
+
// not inflate the retry counter. Without this split, the PreToolUse gate counted rejected
|
|
5
|
+
// delegations as retries and tripped the breaker on the corrected second attempt.
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { isGateEnabled } from '../../lib/config.mjs';
|
|
9
|
+
import {
|
|
10
|
+
allow,
|
|
11
|
+
delegationPromptOf,
|
|
12
|
+
readHookPayload,
|
|
13
|
+
sessionIdOf,
|
|
14
|
+
toolInputOf,
|
|
15
|
+
toolNameOf,
|
|
16
|
+
toolInGroups,
|
|
17
|
+
} from '../../lib/hook-io.mjs';
|
|
18
|
+
import {
|
|
19
|
+
readSessionState,
|
|
20
|
+
writeSessionState,
|
|
21
|
+
} from '../../lib/session-state.mjs';
|
|
22
|
+
|
|
23
|
+
const GATE_ID = 'circuit-breaker';
|
|
24
|
+
const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
|
|
25
|
+
|
|
26
|
+
const MAX_ENTRIES_PER_KEY = 12;
|
|
27
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
28
|
+
|
|
29
|
+
const SECTION_HEADING_NAMES = [
|
|
30
|
+
'scope',
|
|
31
|
+
'steps',
|
|
32
|
+
'haceres',
|
|
33
|
+
'output',
|
|
34
|
+
'criteria',
|
|
35
|
+
'criterio',
|
|
36
|
+
'handoff',
|
|
37
|
+
'out of scope',
|
|
38
|
+
'in scope',
|
|
39
|
+
'que no',
|
|
40
|
+
'que si',
|
|
41
|
+
'que sí',
|
|
42
|
+
'edge cases',
|
|
43
|
+
'casos borde',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function headingNamePattern(name) {
|
|
47
|
+
return new RegExp(
|
|
48
|
+
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+'),
|
|
49
|
+
'i',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
|
|
54
|
+
|
|
55
|
+
function indexOfSectionMarker(text) {
|
|
56
|
+
let earliest = -1;
|
|
57
|
+
for (const namePattern of HEADING_NAME_PATTERNS) {
|
|
58
|
+
const match = namePattern.exec(text);
|
|
59
|
+
if (!match) continue;
|
|
60
|
+
const after = text.slice(match.index + match[0].length);
|
|
61
|
+
if (!/^\s*:/.test(after)) continue;
|
|
62
|
+
if (earliest === -1 || match.index < earliest) earliest = match.index;
|
|
63
|
+
}
|
|
64
|
+
return earliest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LEVEL_LINE_PATTERNS = [
|
|
68
|
+
new RegExp('^level\\s*:?$', 'i'),
|
|
69
|
+
new RegExp('^nivel\\s*:?$', 'i'),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function stripLeadingMarkup(line) {
|
|
73
|
+
return line
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^#{1,4}\s*/, '')
|
|
76
|
+
.replace(/^[-*]\s*/, '')
|
|
77
|
+
.replace(/^\*\*/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isScaffoldingLine(line) {
|
|
81
|
+
const stripped = stripLeadingMarkup(line);
|
|
82
|
+
if (LEVEL_LINE_PATTERNS.some((pattern) => pattern.test(stripped)))
|
|
83
|
+
return true;
|
|
84
|
+
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
85
|
+
const match = namePattern.exec(stripped);
|
|
86
|
+
return (
|
|
87
|
+
match &&
|
|
88
|
+
match.index === 0 &&
|
|
89
|
+
/^\s*:?$/.test(stripped.slice(match[0].length))
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
|
|
95
|
+
const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
|
|
96
|
+
|
|
97
|
+
function isEndOfInScope(line) {
|
|
98
|
+
const stripped = stripLeadingMarkup(line);
|
|
99
|
+
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
100
|
+
const match = namePattern.exec(stripped);
|
|
101
|
+
return match && match.index === 0;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function contentAfterHeading(line) {
|
|
106
|
+
const colon = line.indexOf(':');
|
|
107
|
+
return colon === -1 ? '' : line.slice(colon + 1).trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const STOP_WORDS = new Set(
|
|
111
|
+
(
|
|
112
|
+
'de la el los las un una unos unas y o a en del al lo que se su sus por para con como es son ser este esta ' +
|
|
113
|
+
'esto ese esa eso mas si no ni pero cuando donde cual cuales hay hace hacer debe deben debes puede pueden ' +
|
|
114
|
+
'the of to and in for on with a an is are be it its this that'
|
|
115
|
+
).split(' '),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
function stripDiacritics(text) {
|
|
119
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function tokenize(text) {
|
|
123
|
+
return stripDiacritics(String(text).toLowerCase())
|
|
124
|
+
.split(/[^a-z0-9]+/)
|
|
125
|
+
.filter(
|
|
126
|
+
(token) => token.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(token),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
|
|
131
|
+
const KNOWN_EXTENSIONS = [
|
|
132
|
+
'mjs',
|
|
133
|
+
'cjs',
|
|
134
|
+
'json',
|
|
135
|
+
'jsx',
|
|
136
|
+
'js',
|
|
137
|
+
'mts',
|
|
138
|
+
'cts',
|
|
139
|
+
'tsx',
|
|
140
|
+
'ts',
|
|
141
|
+
'vue',
|
|
142
|
+
'md',
|
|
143
|
+
'py',
|
|
144
|
+
'sh',
|
|
145
|
+
'ps1',
|
|
146
|
+
'yaml',
|
|
147
|
+
'yml',
|
|
148
|
+
'toml',
|
|
149
|
+
'sql',
|
|
150
|
+
'css',
|
|
151
|
+
'html',
|
|
152
|
+
];
|
|
153
|
+
const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
|
|
154
|
+
`[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
|
|
155
|
+
'g',
|
|
156
|
+
);
|
|
157
|
+
const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
|
|
158
|
+
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
|
|
159
|
+
|
|
160
|
+
function looksLikeRelativePath(run) {
|
|
161
|
+
return /[\\/]/.test(run) && !/^[\\/]+$/.test(run);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stripTrailingPunctuation(text) {
|
|
165
|
+
let end = text.length;
|
|
166
|
+
while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
|
|
167
|
+
return text.slice(0, end);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mentionedPaths(prompt) {
|
|
171
|
+
const found = new Set();
|
|
172
|
+
const text = String(prompt);
|
|
173
|
+
const fixedShapeMatches = [
|
|
174
|
+
WINDOWS_DRIVE_PATH_PATTERN,
|
|
175
|
+
KNOWN_EXTENSION_FILENAME_PATTERN,
|
|
176
|
+
].flatMap((pattern) => text.match(pattern) ?? []);
|
|
177
|
+
const relativePathMatches = (text.match(PATH_LIKE_RUN_PATTERN) ?? []).filter(
|
|
178
|
+
looksLikeRelativePath,
|
|
179
|
+
);
|
|
180
|
+
for (const raw of [...fixedShapeMatches, ...relativePathMatches]) {
|
|
181
|
+
const normalized = stripTrailingPunctuation(
|
|
182
|
+
stripDiacritics(raw.toLowerCase()).replace(/\\/g, '/'),
|
|
183
|
+
);
|
|
184
|
+
const base = normalized.split('/').filter(Boolean).pop();
|
|
185
|
+
if (base && /[a-z0-9]/.test(base)) found.add(base);
|
|
186
|
+
}
|
|
187
|
+
return [...found].sort();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function identityText(prompt) {
|
|
191
|
+
const lines = String(prompt).split(/\r?\n/);
|
|
192
|
+
const parts = [];
|
|
193
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
194
|
+
const strippedLine = stripLeadingMarkup(lines[index])
|
|
195
|
+
.replace(/\*\*\s*/g, '')
|
|
196
|
+
.replace(' :', ':');
|
|
197
|
+
const goal = strippedLine.match(GOAL_HEADING_PATTERN);
|
|
198
|
+
if (goal) {
|
|
199
|
+
const goalText = goal[2].trim();
|
|
200
|
+
const cut = indexOfSectionMarker(goalText);
|
|
201
|
+
parts.push(cut > 0 ? goalText.slice(0, cut) : goalText);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
|
|
205
|
+
parts.push(contentAfterHeading(strippedLine));
|
|
206
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
207
|
+
if (isEndOfInScope(lines[cursor])) break;
|
|
208
|
+
parts.push(lines[cursor]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return parts.join(' ').trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function textWithoutScaffolding(prompt) {
|
|
216
|
+
return String(prompt)
|
|
217
|
+
.split(/\r?\n/)
|
|
218
|
+
.filter((line) => !isScaffoldingLine(line))
|
|
219
|
+
.join(' ')
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function identitySignature(prompt) {
|
|
224
|
+
const identity =
|
|
225
|
+
identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
|
|
226
|
+
const tokens = tokenize(identity);
|
|
227
|
+
const features = [];
|
|
228
|
+
if (tokens.length >= 2) {
|
|
229
|
+
for (let index = 0; index < tokens.length - 1; index += 1)
|
|
230
|
+
features.push(`${tokens[index]} ${tokens[index + 1]}`);
|
|
231
|
+
} else {
|
|
232
|
+
features.push(...tokens);
|
|
233
|
+
}
|
|
234
|
+
for (const path of mentionedPaths(prompt))
|
|
235
|
+
features.push(`path:${path}`, `path:${path}`);
|
|
236
|
+
return features;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function identityKey(signature) {
|
|
240
|
+
return createHash('sha256').update(signature.join(' ')).digest('hex');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function occurrencesFor(state, key) {
|
|
244
|
+
const value = state[key];
|
|
245
|
+
if (!Array.isArray(value)) return [];
|
|
246
|
+
return value.filter((entry) => entry && Array.isArray(entry.signature));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main() {
|
|
250
|
+
const rawPayload = readHookPayload();
|
|
251
|
+
if (rawPayload === null) allow();
|
|
252
|
+
const toolName = toolNameOf(rawPayload) ?? '';
|
|
253
|
+
if (!toolInGroups(toolName, ['delegation'])) allow();
|
|
254
|
+
|
|
255
|
+
const cwd = process.cwd();
|
|
256
|
+
if (!isGateEnabled(CONFIG_KEY, false, cwd)) allow();
|
|
257
|
+
|
|
258
|
+
const toolInput = toolInputOf(rawPayload);
|
|
259
|
+
const prompt = delegationPromptOf(toolInput);
|
|
260
|
+
if (!prompt.trim()) allow();
|
|
261
|
+
|
|
262
|
+
const sessionId = sessionIdOf(rawPayload);
|
|
263
|
+
const stateOptions = { cwd };
|
|
264
|
+
const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
|
|
265
|
+
const signature = identitySignature(prompt);
|
|
266
|
+
const key = identityKey(signature);
|
|
267
|
+
|
|
268
|
+
const occurrences = [
|
|
269
|
+
...occurrencesFor(state, key),
|
|
270
|
+
{ signature, seenAt: Date.now() },
|
|
271
|
+
].slice(-MAX_ENTRIES_PER_KEY);
|
|
272
|
+
writeSessionState(
|
|
273
|
+
GATE_ID,
|
|
274
|
+
sessionId,
|
|
275
|
+
{ ...state, [key]: occurrences },
|
|
276
|
+
stateOptions,
|
|
277
|
+
);
|
|
278
|
+
allow();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
main();
|
|
283
|
+
} catch {
|
|
284
|
+
allow();
|
|
285
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// force-parallel —
|
|
2
|
-
// batch.
|
|
3
|
-
//
|
|
1
|
+
// force-parallel — denies delegations that keep arriving one turn at a time instead of as a
|
|
2
|
+
// batch. Deterministic: when the sequential count hits the threshold, the delegation is
|
|
3
|
+
// blocked outright — the agent must collect independent delegations and send them together.
|
|
4
4
|
//
|
|
5
5
|
// Decisions: delegations landing within BATCH_GAP_MS of each other are ONE batch (a parallel
|
|
6
6
|
// launch in a single message) and do not raise the sequential count; only a gap between the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
runGate,
|
|
13
|
-
|
|
13
|
+
deny,
|
|
14
14
|
toolInGroups,
|
|
15
15
|
delegationPromptOf,
|
|
16
16
|
} from '../../lib/hook-io.mjs';
|
|
@@ -51,8 +51,7 @@ runGate(
|
|
|
51
51
|
{
|
|
52
52
|
id: GATE_ID,
|
|
53
53
|
configKey: CONFIG_KEY,
|
|
54
|
-
enabledByDefault:
|
|
55
|
-
severity: 'warn',
|
|
54
|
+
enabledByDefault: true,
|
|
56
55
|
defaultParams: {
|
|
57
56
|
sequentialThreshold: DEFAULT_SEQUENTIAL_THRESHOLD,
|
|
58
57
|
sequentialWindowMs: DEFAULT_SEQUENTIAL_WINDOW_MS,
|
|
@@ -81,13 +80,13 @@ runGate(
|
|
|
81
80
|
const windowSeconds = Math.round(
|
|
82
81
|
parameters.sequentialWindowMs / MS_PER_SECOND,
|
|
83
82
|
);
|
|
84
|
-
|
|
83
|
+
deny(
|
|
85
84
|
CONFIG_KEY,
|
|
86
|
-
`This is the ${ordinal(count)} delegation sent one-by-one within ${windowSeconds}s.
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'genuinely depends on a prior result,
|
|
90
|
-
`"${marker || DEFAULT_JUSTIFIED_MARKER}" to
|
|
85
|
+
`This is the ${ordinal(count)} delegation sent one-by-one within ${windowSeconds}s. ` +
|
|
86
|
+
'Independent delegations MUST be launched together in a single message (multiple tool ' +
|
|
87
|
+
'calls in one response). Collect the remaining independent delegations and send them as ' +
|
|
88
|
+
'a batch. If this delegation genuinely depends on a prior result, add ' +
|
|
89
|
+
`"${marker || DEFAULT_JUSTIFIED_MARKER}" to the prompt to declare the dependency.`,
|
|
91
90
|
);
|
|
92
91
|
},
|
|
93
92
|
);
|
|
@@ -5,6 +5,8 @@ import { projectRootOf } from '../../lib/config.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
deny,
|
|
7
7
|
runGate,
|
|
8
|
+
shellCommandOf,
|
|
9
|
+
shellWrittenPaths,
|
|
8
10
|
toolInGroups,
|
|
9
11
|
writtenContentOf,
|
|
10
12
|
writtenPathOf,
|
|
@@ -190,6 +192,33 @@ export function knowledgeStatus(state, name) {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
const HEREDOC_START_PATTERN = /<<-?\s*['"]?(\w+)['"]?(?:\s*>{1,2}\s*\S+)?$/;
|
|
196
|
+
|
|
197
|
+
function extractHeredocBodies(command) {
|
|
198
|
+
const lines = String(command).split(/\r?\n/);
|
|
199
|
+
const bodies = [];
|
|
200
|
+
let collecting = null;
|
|
201
|
+
let body = [];
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (collecting) {
|
|
204
|
+
if (line.trim() === collecting) {
|
|
205
|
+
bodies.push(body.join('\n'));
|
|
206
|
+
collecting = null;
|
|
207
|
+
body = [];
|
|
208
|
+
} else {
|
|
209
|
+
body.push(line);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const match = HEREDOC_START_PATTERN.exec(line.trimEnd());
|
|
214
|
+
if (match) {
|
|
215
|
+
collecting = match[1];
|
|
216
|
+
body = [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return bodies;
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
function remedyFor(name, status) {
|
|
194
223
|
const steps = [];
|
|
195
224
|
if (!status.searched && !status.documentation)
|
|
@@ -217,46 +246,93 @@ runGate(
|
|
|
217
246
|
},
|
|
218
247
|
},
|
|
219
248
|
({ toolName, toolInput, sessionId, parameters, cwd }) => {
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
!parameters.codeExtensions.includes(extname(writtenPath).toLowerCase())
|
|
225
|
-
)
|
|
226
|
-
return;
|
|
249
|
+
const isWrite = toolInGroups(toolName, ['write']);
|
|
250
|
+
const isShell = toolInGroups(toolName, ['shell']);
|
|
251
|
+
if (!isWrite && !isShell) return;
|
|
252
|
+
|
|
227
253
|
const root = projectRootOf(cwd) ?? cwd;
|
|
228
|
-
const absolutePath = isAbsolute(writtenPath)
|
|
229
|
-
? writtenPath
|
|
230
|
-
: join(root, writtenPath);
|
|
231
254
|
const ignored = new Set(
|
|
232
255
|
parameters.ignoredPackages.map((name) => String(name).toLowerCase()),
|
|
233
256
|
);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
|
|
258
|
+
// Collect { path, content } pairs to check.
|
|
259
|
+
const targets = [];
|
|
260
|
+
|
|
261
|
+
if (isWrite) {
|
|
262
|
+
const writtenPath = writtenPathOf(toolInput);
|
|
263
|
+
if (
|
|
264
|
+
writtenPath &&
|
|
265
|
+
parameters.codeExtensions.includes(extname(writtenPath).toLowerCase())
|
|
266
|
+
) {
|
|
267
|
+
targets.push({
|
|
268
|
+
path: writtenPath,
|
|
269
|
+
content: writtenContentOf(toolInput),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isShell) {
|
|
275
|
+
const command = shellCommandOf(toolInput);
|
|
276
|
+
const writtenPaths = shellWrittenPaths(command).filter((path) =>
|
|
277
|
+
parameters.codeExtensions.includes(extname(path).toLowerCase()),
|
|
278
|
+
);
|
|
279
|
+
if (writtenPaths.length > 0) {
|
|
280
|
+
const heredocBodies = extractHeredocBodies(command);
|
|
281
|
+
const allContent = heredocBodies.join('\n');
|
|
282
|
+
for (const path of writtenPaths) {
|
|
283
|
+
targets.push({ path, content: allContent || null });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (targets.length === 0) return;
|
|
289
|
+
|
|
290
|
+
const allBlocked = [];
|
|
248
291
|
const state = readSessionState(GATE_ID, sessionId, {}, { cwd });
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
292
|
+
|
|
293
|
+
for (const { path, content } of targets) {
|
|
294
|
+
const absolutePath = isAbsolute(path) ? path : join(root, path);
|
|
295
|
+
|
|
296
|
+
if (content === null) {
|
|
297
|
+
// Shell command writes a code file but content cannot be inspected (no heredoc).
|
|
298
|
+
deny(
|
|
299
|
+
CONFIG_KEY,
|
|
300
|
+
`This shell command writes to ${path} (a code file) via redirection. Use the Write ` +
|
|
301
|
+
'tool for code files so library usage can be verified. Heredocs are extractable ' +
|
|
302
|
+
'but plain redirections are opaque to this gate.',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const already = existingImports(absolutePath);
|
|
307
|
+
const candidates = importedPackagesOf(content, path).filter(
|
|
308
|
+
(name) => !already.has(name) && !ignored.has(name.toLowerCase()),
|
|
309
|
+
);
|
|
310
|
+
if (candidates.length === 0) continue;
|
|
311
|
+
|
|
312
|
+
const usedElsewhere = packagesUsedElsewhere(
|
|
313
|
+
root,
|
|
314
|
+
absolutePath,
|
|
315
|
+
parameters.codeExtensions,
|
|
316
|
+
parameters.maxScanFiles,
|
|
317
|
+
);
|
|
318
|
+
const unknown = candidates.filter((name) => !usedElsewhere.has(name));
|
|
319
|
+
if (unknown.length === 0) continue;
|
|
320
|
+
|
|
321
|
+
const blocked = unknown
|
|
322
|
+
.map((name) => ({ name, status: knowledgeStatus(state, name) }))
|
|
323
|
+
.filter((entry) => !entry.status.known);
|
|
324
|
+
allBlocked.push(...blocked);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (allBlocked.length === 0) return;
|
|
328
|
+
const lines = allBlocked.map(
|
|
254
329
|
(entry) => `${entry.name}: ${remedyFor(entry.name, entry.status)}`,
|
|
255
330
|
);
|
|
256
331
|
deny(
|
|
257
332
|
CONFIG_KEY,
|
|
258
|
-
`This write introduces ${
|
|
259
|
-
`in this session shows how to use them. Do not guess an API.
|
|
333
|
+
`This ${isShell ? 'shell command' : 'write'} introduces ${allBlocked.length} package(s) this project does not use ` +
|
|
334
|
+
`anywhere yet, and nothing in this session shows how to use them. Do not guess an API. ` +
|
|
335
|
+
`${lines.join(' | ')}. Then retry${isShell ? ' using the Write tool' : ''}.`,
|
|
260
336
|
);
|
|
261
337
|
},
|
|
262
338
|
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// no-trivial-scripts — denies inline interpreter scripts (node -e, python -c, sed -i,
|
|
2
|
+
// perl -e, PowerShell one-liners with [IO.File]/Set-Content/Add-Content) when they perform
|
|
3
|
+
// file operations that the Edit or Write tool would handle directly: inserting a line,
|
|
4
|
+
// replacing text, creating a file, appending content. Legitimate uses of inline scripts
|
|
5
|
+
// (JSON processing, running a test harness, computing a value) are not caught because they
|
|
6
|
+
// don't touch the filesystem.
|
|
7
|
+
//
|
|
8
|
+
// Deterministic: pattern match on the command, unconditional deny. No model judgment.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
deny,
|
|
12
|
+
runGate,
|
|
13
|
+
shellCommandOf,
|
|
14
|
+
toolInGroups,
|
|
15
|
+
} from '../../lib/hook-io.mjs';
|
|
16
|
+
|
|
17
|
+
const GATE_ID = 'no-trivial-scripts';
|
|
18
|
+
const CONFIG_KEY = 'blockTrivialInlineScripts';
|
|
19
|
+
|
|
20
|
+
// Node/Deno/Bun inline eval doing file operations
|
|
21
|
+
const NODE_EVAL_PATTERN =
|
|
22
|
+
/\b(?:node|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|--eval|-p|--print)\b/i;
|
|
23
|
+
const NODE_FILE_OPS =
|
|
24
|
+
/\b(?:writeFileSync|appendFileSync|mkdirSync|unlinkSync|renameSync|copyFileSync|fs\.writeFile|fs\.appendFile|fs\.unlink|fs\.rename|fs\.mkdir|fs\.copyFile|createWriteStream)\b/;
|
|
25
|
+
|
|
26
|
+
// Python inline doing file operations
|
|
27
|
+
const PYTHON_EVAL_PATTERN = /\bpython3?\b(?:\s+-\S+)*?\s+-c\b/i;
|
|
28
|
+
const PYTHON_OPEN_FOR_WRITING = /\bopen\s*\([^)]*,\s*['"][wax]/;
|
|
29
|
+
const PYTHON_FILE_OPS =
|
|
30
|
+
/\.write\s*\(|\bshutil\.|\bos\.(?:rename|remove|unlink|makedirs)\b|\bpathlib\.Path\b[^)]*\.write_text/;
|
|
31
|
+
|
|
32
|
+
// sed/awk/perl inline edits — these are always file edits
|
|
33
|
+
const SED_INLINE_PATTERN = /\bsed\s+(?:-[^;\s]*\s+)*-i/;
|
|
34
|
+
const PERL_INLINE_PATTERN = /\bperl\s+(?:-[^;\s]*\s+)*-[ip]/;
|
|
35
|
+
const AWK_INLINE_PATTERN = /\bawk\s+(?:-[^;\s]*\s+)*-i\s+inplace\b/;
|
|
36
|
+
|
|
37
|
+
// PowerShell file manipulation one-liners
|
|
38
|
+
const PS_FILE_OPS_PATTERN =
|
|
39
|
+
/\b(?:Set-Content|Add-Content|Out-File|\[System\.IO\.File\]::(?:WriteAll|AppendAll)|New-Item\s[^|;]*-ItemType\s+File)\b/i;
|
|
40
|
+
|
|
41
|
+
const REMEDY =
|
|
42
|
+
'This command uses an inline script for a file operation that the Edit or Write tool ' +
|
|
43
|
+
'handles directly. Use Edit to modify existing files (insert, replace, delete lines) ' +
|
|
44
|
+
'or Write to create new files. Inline scripts are for computation, not file manipulation.';
|
|
45
|
+
|
|
46
|
+
function checkCommand(command) {
|
|
47
|
+
if (!command) return;
|
|
48
|
+
|
|
49
|
+
// Node/Deno/Bun -e with file operations
|
|
50
|
+
const nodeMatch = NODE_EVAL_PATTERN.exec(command);
|
|
51
|
+
if (nodeMatch) {
|
|
52
|
+
const afterEval = command.slice(nodeMatch.index + nodeMatch[0].length);
|
|
53
|
+
if (NODE_FILE_OPS.test(afterEval)) {
|
|
54
|
+
deny(CONFIG_KEY, `${REMEDY} (detected: inline Node.js file operation)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Python -c with file operations
|
|
59
|
+
const pythonMatch = PYTHON_EVAL_PATTERN.exec(command);
|
|
60
|
+
if (pythonMatch) {
|
|
61
|
+
const afterEval = command.slice(pythonMatch.index + pythonMatch[0].length);
|
|
62
|
+
if (
|
|
63
|
+
PYTHON_OPEN_FOR_WRITING.test(afterEval) ||
|
|
64
|
+
PYTHON_FILE_OPS.test(afterEval)
|
|
65
|
+
) {
|
|
66
|
+
deny(CONFIG_KEY, `${REMEDY} (detected: inline Python file operation)`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// sed -i is always an in-place file edit
|
|
71
|
+
if (SED_INLINE_PATTERN.test(command)) {
|
|
72
|
+
deny(
|
|
73
|
+
CONFIG_KEY,
|
|
74
|
+
`${REMEDY} (detected: sed -i in-place edit — use Edit tool instead)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// perl -i/-p is always an in-place file edit
|
|
79
|
+
if (PERL_INLINE_PATTERN.test(command)) {
|
|
80
|
+
deny(
|
|
81
|
+
CONFIG_KEY,
|
|
82
|
+
`${REMEDY} (detected: perl -i/-p in-place edit — use Edit tool instead)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// awk -i inplace
|
|
87
|
+
if (AWK_INLINE_PATTERN.test(command)) {
|
|
88
|
+
deny(
|
|
89
|
+
CONFIG_KEY,
|
|
90
|
+
`${REMEDY} (detected: awk -i inplace — use Edit tool instead)`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// PowerShell file write one-liners
|
|
95
|
+
if (PS_FILE_OPS_PATTERN.test(command)) {
|
|
96
|
+
deny(
|
|
97
|
+
CONFIG_KEY,
|
|
98
|
+
`${REMEDY} (detected: PowerShell file write cmdlet — use Edit or Write tool instead)`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
runGate(
|
|
104
|
+
{
|
|
105
|
+
id: GATE_ID,
|
|
106
|
+
configKey: CONFIG_KEY,
|
|
107
|
+
enabledByDefault: true,
|
|
108
|
+
defaultParams: {},
|
|
109
|
+
},
|
|
110
|
+
({ toolName, toolInput }) => {
|
|
111
|
+
if (!toolInGroups(toolName, ['shell'])) return;
|
|
112
|
+
checkCommand(shellCommandOf(toolInput));
|
|
113
|
+
},
|
|
114
|
+
);
|