@devrik-tools/claude-gates 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- 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 +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- 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 +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- 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 +53 -95
- 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 +338 -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/no-trivial-scripts/index.mjs +114 -0
- 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/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- 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 +152 -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 +451 -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 +148 -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 +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -1,33 +1,29 @@
|
|
|
1
|
-
// no-memory-dependency — denies
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// matters, it must travel IN the prompt, in a file the subagent reads, or in an
|
|
5
|
-
// already-persisted decision. The dependency on unavailable memory is a complete, objective
|
|
6
|
-
// fact once stage 2 has ruled out a real persistence instruction — so this blocks.
|
|
1
|
+
// no-memory-dependency — denies a delegation whose prompt refers the subagent to something
|
|
2
|
+
// said earlier in the conversation ("como te dije", "as we discussed") instead of carrying
|
|
3
|
+
// the data itself: a fresh subagent has none of the delegator's history.
|
|
7
4
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// genuinely depends on a file, not on model memory. A persistence noun/verb that merely
|
|
14
|
-
// co-occurs in the window without forming an instruction TO persist THIS remembered
|
|
15
|
-
// thing (e.g. an unrelated file named elsewhere in the same sentence) does not suppress
|
|
16
|
-
// the deny.
|
|
17
|
-
//
|
|
18
|
-
// ── Escape hatch ─────────────────────────────────────────────────────────────────────
|
|
19
|
-
// A memory phrase can be a false positive: "no te olvides de cerrar el server al final"
|
|
20
|
-
// directs the SUBAGENT'S own future action, not data it must recall from the delegator. For
|
|
21
|
-
// that case the author adds the escapeHatch marker to the prompt to state, explicitly, that
|
|
22
|
-
// no cross-conversation memory is actually required.
|
|
5
|
+
// Decisions: the default phrases are references to PRIOR CONVERSATION only — "don't forget
|
|
6
|
+
// to X", "keep in mind that <fact>", "remember to run the tests" carry their own content and
|
|
7
|
+
// are allowed. A reference next to a real persistence instruction ("como quedamos, guardá la
|
|
8
|
+
// decisión en .ai/decision.md") depends on a file, not on memory, and is allowed. The escape
|
|
9
|
+
// hatch marker states that no recalled data is actually needed.
|
|
23
10
|
|
|
11
|
+
import {
|
|
12
|
+
allMatches,
|
|
13
|
+
promptExcerpt,
|
|
14
|
+
stripQuoted,
|
|
15
|
+
} from '../../lib/delegation.mjs';
|
|
24
16
|
import {
|
|
25
17
|
runGate,
|
|
26
18
|
deny,
|
|
19
|
+
compileRegexList,
|
|
27
20
|
toolInGroups,
|
|
28
21
|
delegationPromptOf,
|
|
29
22
|
} from '../../lib/hook-io.mjs';
|
|
30
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
PERSISTENCE_VERB,
|
|
25
|
+
withUnicodeWordBoundary,
|
|
26
|
+
} from '../../lib/signals.mjs';
|
|
31
27
|
|
|
32
28
|
const GATE_ID = 'no-memory-dependency';
|
|
33
29
|
const CONFIG_KEY = 'warnMemoryDependencyInBrief';
|
|
@@ -35,64 +31,39 @@ const CONFIG_KEY = 'warnMemoryDependencyInBrief';
|
|
|
35
31
|
const DEFAULT_ESCAPE_HATCH = 'memory-not-needed';
|
|
36
32
|
|
|
37
33
|
const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
|
|
34
|
+
'recuerda que|acu[eé]rdate de (?:lo )?que|acordate de (?:lo )?que|como te dije',
|
|
35
|
+
'como (vimos|hablamos|dijimos|quedamos)|lo de antes',
|
|
36
|
+
'lo que te (dije|comenté|pasé)|ya sabes cu[aá]l',
|
|
37
|
+
'el (archivo|cambio|tema) de antes',
|
|
38
|
+
'remember that we|as (i|we) (said|discussed|mentioned|agreed)|as discussed',
|
|
39
|
+
'as mentioned (earlier|before|above)|the one from (before|earlier)',
|
|
40
|
+
'what we (discussed|talked about|decided)|you know which',
|
|
41
|
+
'like (before|last time)|the previous (one|file|change) i mentioned',
|
|
43
42
|
];
|
|
44
43
|
|
|
45
|
-
// A real persistence INSTRUCTION requires an imperative persistence VERB (an actual
|
|
46
|
-
// directive to save/persist/write THIS remembered thing), not merely a persistence-
|
|
47
|
-
// related NOUN mentioned somewhere nearby (a stray "incident.md" in the same sentence
|
|
48
|
-
// names a file without instructing anything be saved to it). Centralized in
|
|
49
|
-
// lib/signals.mjs (ES+EN) so this class of signal is not duplicated per gate.
|
|
50
|
-
const PERSISTENCE_VERB_PATTERN = PERSISTENCE_VERB;
|
|
51
|
-
|
|
52
44
|
const PERSISTENCE_WINDOW = 80;
|
|
53
45
|
|
|
54
|
-
function withUnicodeWordBoundary(alternatives) {
|
|
55
|
-
return new RegExp(
|
|
56
|
-
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
57
|
-
'iu',
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function stripQuoted(text) {
|
|
62
|
-
return text
|
|
63
|
-
.replace(/```[\s\S]*?```/g, ' ')
|
|
64
|
-
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
65
|
-
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function allMatches(pattern, text) {
|
|
69
|
-
const flags = pattern.flags.includes('g')
|
|
70
|
-
? pattern.flags
|
|
71
|
-
: `${pattern.flags}g`;
|
|
72
|
-
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** True only when the window around a memory phrase carries a real persistence
|
|
76
|
-
* INSTRUCTION — an imperative persistence verb — not merely a persistence-related noun
|
|
77
|
-
* mentioned in passing. A noun alone ("el reporte esta en incident.md") names a file
|
|
78
|
-
* without instructing anything be saved to it, so it must NOT suppress the warning. */
|
|
79
46
|
function hasPersistenceInstructionNearby(text, match) {
|
|
80
47
|
const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
|
|
81
48
|
const to = Math.min(
|
|
82
49
|
text.length,
|
|
83
50
|
match.index + match[0].length + PERSISTENCE_WINDOW,
|
|
84
51
|
);
|
|
85
|
-
|
|
86
|
-
return PERSISTENCE_VERB_PATTERN.test(window);
|
|
52
|
+
return PERSISTENCE_VERB.test(text.slice(from, to));
|
|
87
53
|
}
|
|
88
54
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
55
|
+
function memoryPatternFrom(sources) {
|
|
56
|
+
const { patterns } = compileRegexList(sources, 'iu');
|
|
57
|
+
if (patterns.length === 0) return null;
|
|
58
|
+
return withUnicodeWordBoundary(
|
|
59
|
+
patterns.map((pattern) => `(?:${pattern.source})`).join('|'),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
94
62
|
|
|
95
|
-
|
|
63
|
+
function unresolvedMemoryPhrases(prompt, memoryPattern) {
|
|
64
|
+
if (!memoryPattern.test(prompt)) return [];
|
|
65
|
+
const text = stripQuoted(prompt);
|
|
66
|
+
return allMatches(memoryPattern, text)
|
|
96
67
|
.filter((match) => !hasPersistenceInstructionNearby(text, match))
|
|
97
68
|
.map((match) => match[0].trim());
|
|
98
69
|
}
|
|
@@ -113,15 +84,13 @@ runGate(
|
|
|
113
84
|
const prompt = delegationPromptOf(toolInput);
|
|
114
85
|
if (!prompt.trim()) return;
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
const escapeHatch = (
|
|
118
|
-
parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
|
|
119
|
-
).toLowerCase();
|
|
87
|
+
const escapeHatch = String(parameters.escapeHatch ?? '').toLowerCase();
|
|
120
88
|
if (escapeHatch && prompt.toLowerCase().includes(escapeHatch)) return;
|
|
121
89
|
|
|
122
|
-
const memoryPattern =
|
|
123
|
-
|
|
90
|
+
const memoryPattern = memoryPatternFrom(
|
|
91
|
+
parameters.memoryDependencyPatterns,
|
|
124
92
|
);
|
|
93
|
+
if (!memoryPattern) return;
|
|
125
94
|
|
|
126
95
|
const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
|
|
127
96
|
if (phrases.length === 0) return;
|
|
@@ -129,10 +98,10 @@ runGate(
|
|
|
129
98
|
const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
|
|
130
99
|
deny(
|
|
131
100
|
CONFIG_KEY,
|
|
132
|
-
`This delegation depends on the subagent remembering something
|
|
133
|
-
|
|
134
|
-
'reads, a flag, or an already-persisted decision. If the phrase
|
|
135
|
-
`
|
|
101
|
+
`This delegation ("${promptExcerpt(prompt)}") depends on the subagent remembering something ` +
|
|
102
|
+
`(${quotedPhrases}), but a fresh subagent has none of this conversation. Put the data IN the ` +
|
|
103
|
+
'prompt, in a file it reads, a flag, or an already-persisted decision. If the phrase needs no ' +
|
|
104
|
+
`recalled data, add the marker "${escapeHatch || DEFAULT_ESCAPE_HATCH}" to the prompt.`,
|
|
136
105
|
);
|
|
137
106
|
},
|
|
138
107
|
);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// no-reconfirm — asking again what the user already approved gets a reminder to proceed.
|
|
2
|
+
// Advisory only. An approval is a human turn that STARTS with an affirmative; a turn that
|
|
3
|
+
// merely contains "proceed" ("how should I proceed?") is a question, not an approval. Only
|
|
4
|
+
// the tail of the transcript is read: an approval given hundreds of turns ago is stale.
|
|
5
|
+
|
|
6
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
|
|
7
|
+
import { runGate, warn, toolInGroups } from '../../lib/hook-io.mjs';
|
|
3
8
|
|
|
4
9
|
const GATE_ID = 'no-reconfirm';
|
|
5
10
|
const CONFIG_KEY = 'requireNoReconfirmOfApproved';
|
|
@@ -9,17 +14,33 @@ const MIN_SHARED_WORDS = 2;
|
|
|
9
14
|
const MIN_SIGNIFICANT_WORD_LENGTH = 5;
|
|
10
15
|
const QUESTION_PREVIEW_LENGTH = 100;
|
|
11
16
|
const APPROVING_TURN_PREVIEW_LENGTH = 140;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'
|
|
22
|
-
|
|
17
|
+
const TRANSCRIPT_TAIL_LINES = 400;
|
|
18
|
+
const KIB = 1024;
|
|
19
|
+
const TRANSCRIPT_TAIL_BYTES = KIB * KIB;
|
|
20
|
+
|
|
21
|
+
const AFFIRMATIVES = [
|
|
22
|
+
'yes',
|
|
23
|
+
'yeah',
|
|
24
|
+
'yep',
|
|
25
|
+
'ok',
|
|
26
|
+
'okay',
|
|
27
|
+
'sure',
|
|
28
|
+
'go ahead',
|
|
29
|
+
'proceed',
|
|
30
|
+
'approved',
|
|
31
|
+
'do it',
|
|
32
|
+
's[ií]',
|
|
33
|
+
'de acuerdo',
|
|
34
|
+
'adelante',
|
|
35
|
+
'procede',
|
|
36
|
+
'hazlo',
|
|
37
|
+
'correcto',
|
|
38
|
+
'confirmo',
|
|
39
|
+
'aprobado',
|
|
40
|
+
];
|
|
41
|
+
const APPROVAL_START_PATTERN = new RegExp(
|
|
42
|
+
String.raw`^\s*(?:${AFFIRMATIVES.join('|')})(?![\p{L}\p{N}_])`,
|
|
43
|
+
'iu',
|
|
23
44
|
);
|
|
24
45
|
|
|
25
46
|
const STOP_WORDS = new Set([
|
|
@@ -67,18 +88,29 @@ function textOf(event) {
|
|
|
67
88
|
return content.map((block) => block.text || '').join(' ');
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let raw;
|
|
91
|
+
function tailLinesOf(path) {
|
|
92
|
+
let descriptor;
|
|
74
93
|
try {
|
|
75
|
-
|
|
94
|
+
descriptor = openSync(path, 'r');
|
|
95
|
+
const { size } = fstatSync(descriptor);
|
|
96
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
97
|
+
const buffer = Buffer.alloc(length);
|
|
98
|
+
readSync(descriptor, buffer, 0, length, size - length);
|
|
99
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
100
|
+
if (length < size) lines.shift();
|
|
101
|
+
return lines.slice(-TRANSCRIPT_TAIL_LINES);
|
|
76
102
|
} catch {
|
|
77
103
|
return null;
|
|
104
|
+
} finally {
|
|
105
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
78
106
|
}
|
|
107
|
+
}
|
|
79
108
|
|
|
109
|
+
function humanTurnsFrom(transcriptPath) {
|
|
110
|
+
const lines = tailLinesOf(transcriptPath);
|
|
111
|
+
if (lines === null) return null;
|
|
80
112
|
const turns = [];
|
|
81
|
-
for (const line of
|
|
113
|
+
for (const line of lines) {
|
|
82
114
|
if (!line.trim()) continue;
|
|
83
115
|
let event;
|
|
84
116
|
try {
|
|
@@ -105,10 +137,14 @@ function significantWords(text) {
|
|
|
105
137
|
);
|
|
106
138
|
}
|
|
107
139
|
|
|
140
|
+
function optionsOf(question) {
|
|
141
|
+
return Array.isArray(question?.options) ? question.options : [];
|
|
142
|
+
}
|
|
143
|
+
|
|
108
144
|
function topicOfQuestion(question) {
|
|
109
145
|
const header = String(question?.header || '');
|
|
110
146
|
const body = String(question?.question || '');
|
|
111
|
-
const optionLabels = (question
|
|
147
|
+
const optionLabels = optionsOf(question)
|
|
112
148
|
.map((option) => String(option?.label || ''))
|
|
113
149
|
.join(' ');
|
|
114
150
|
return `${header} ${body} ${optionLabels}`;
|
|
@@ -123,8 +159,7 @@ function turnThatAlreadyApprovedThisTopic(
|
|
|
123
159
|
if (topicWords.size === 0) return null;
|
|
124
160
|
|
|
125
161
|
for (const turn of humanTurns) {
|
|
126
|
-
if (!
|
|
127
|
-
|
|
162
|
+
if (!APPROVAL_START_PATTERN.test(turn)) continue;
|
|
128
163
|
const turnWords = significantWords(turn);
|
|
129
164
|
let shared = 0;
|
|
130
165
|
for (const word of topicWords) {
|
|
@@ -144,6 +179,17 @@ function transcriptPathOf(rawPayload) {
|
|
|
144
179
|
}
|
|
145
180
|
}
|
|
146
181
|
|
|
182
|
+
function questionsOf(toolInput) {
|
|
183
|
+
if (Array.isArray(toolInput?.questions)) {
|
|
184
|
+
return toolInput.questions.filter(
|
|
185
|
+
(question) => question && typeof question === 'object',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (typeof toolInput?.question === 'string')
|
|
189
|
+
return [{ question: toolInput.question }];
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
147
193
|
function noticeFor(question, approvingTurn) {
|
|
148
194
|
const questionPreview = String(question?.question || '').slice(
|
|
149
195
|
0,
|
|
@@ -155,47 +201,39 @@ function noticeFor(question, approvingTurn) {
|
|
|
155
201
|
return `Question "${questionPreview}" shares its topic with a turn where the user already gave explicit approval: "${approvingTurnPreview}". If it is the same decision, do not re-ask — proceed.`;
|
|
156
202
|
}
|
|
157
203
|
|
|
158
|
-
function collectReconfirmationNotices(questions, humanTurns, overlapThreshold) {
|
|
159
|
-
const notices = [];
|
|
160
|
-
for (const question of questions) {
|
|
161
|
-
const approvingTurn = turnThatAlreadyApprovedThisTopic(
|
|
162
|
-
humanTurns,
|
|
163
|
-
question,
|
|
164
|
-
overlapThreshold,
|
|
165
|
-
);
|
|
166
|
-
if (approvingTurn) notices.push(noticeFor(question, approvingTurn));
|
|
167
|
-
}
|
|
168
|
-
return notices;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
204
|
runGate(
|
|
172
205
|
{
|
|
173
206
|
id: GATE_ID,
|
|
174
207
|
configKey: CONFIG_KEY,
|
|
175
208
|
enabledByDefault: true,
|
|
209
|
+
severity: 'warn',
|
|
176
210
|
defaultParams: {
|
|
177
211
|
overlapThreshold: DEFAULT_OVERLAP_THRESHOLD,
|
|
178
212
|
},
|
|
179
213
|
},
|
|
180
214
|
({ toolName, toolInput, parameters, rawPayload }) => {
|
|
181
|
-
if (!
|
|
215
|
+
if (!toolInGroups(toolName, ['question'])) return;
|
|
182
216
|
|
|
183
|
-
const questions =
|
|
184
|
-
? toolInput.questions
|
|
185
|
-
: [];
|
|
217
|
+
const questions = questionsOf(toolInput);
|
|
186
218
|
if (questions.length === 0) return;
|
|
187
219
|
|
|
188
220
|
const transcriptPath = transcriptPathOf(rawPayload);
|
|
189
|
-
if (!transcriptPath) return;
|
|
221
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return;
|
|
190
222
|
|
|
191
223
|
const humanTurns = humanTurnsFrom(transcriptPath);
|
|
192
|
-
if (humanTurns
|
|
193
|
-
|
|
194
|
-
const notices =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
224
|
+
if (!humanTurns || humanTurns.length === 0) return;
|
|
225
|
+
|
|
226
|
+
const notices = questions
|
|
227
|
+
.map((question) => ({
|
|
228
|
+
question,
|
|
229
|
+
approvingTurn: turnThatAlreadyApprovedThisTopic(
|
|
230
|
+
humanTurns,
|
|
231
|
+
question,
|
|
232
|
+
parameters.overlapThreshold,
|
|
233
|
+
),
|
|
234
|
+
}))
|
|
235
|
+
.filter((entry) => entry.approvingTurn)
|
|
236
|
+
.map((entry) => noticeFor(entry.question, entry.approvingTurn));
|
|
199
237
|
if (notices.length === 0) return;
|
|
200
238
|
warn(CONFIG_KEY, notices.join('\n\n'));
|
|
201
239
|
},
|
|
@@ -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
|
+
);
|