@devrik-tools/claude-gates 0.1.2 → 0.2.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 +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- package/registry.json +793 -686
|
@@ -1,125 +1,153 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
-
|
|
11
|
-
function runGate(payload, { config } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'risk-level-'));
|
|
13
|
-
mkdirSync(join(project, '.git'));
|
|
14
|
-
if (config) {
|
|
15
|
-
mkdirSync(join(project, '.ai'));
|
|
16
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
-
}
|
|
18
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
-
input: JSON.stringify(payload),
|
|
20
|
-
encoding: 'utf8',
|
|
21
|
-
cwd: project,
|
|
22
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
-
});
|
|
26
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function delegate(prompt, extra = {}) {
|
|
30
|
-
return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
|
|
31
|
-
}
|
|
32
|
-
function isDeny(result) {
|
|
33
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
|
|
37
|
-
|
|
38
|
-
test('denies an implementation delegation with no declared level', () => {
|
|
39
|
-
assert.ok(
|
|
40
|
-
isDeny(
|
|
41
|
-
runGate(
|
|
42
|
-
delegate('Arregla el boton que no cambia de color en la pagina.'),
|
|
43
|
-
ENABLED,
|
|
44
|
-
),
|
|
45
|
-
),
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('allows a declared level consistent with a low-risk request', () => {
|
|
50
|
-
assert.equal(
|
|
51
|
-
runGate(
|
|
52
|
-
delegate(
|
|
53
|
-
'NIVEL: STANDARD\n\nArregla el boton que no cambia de color en la pagina.',
|
|
54
|
-
),
|
|
55
|
-
ENABLED,
|
|
56
|
-
),
|
|
57
|
-
null,
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('denies a declared level that contradicts a real high-impact signal', () => {
|
|
62
|
-
assert.ok(
|
|
63
|
-
isDeny(
|
|
64
|
-
runGate(
|
|
65
|
-
delegate(
|
|
66
|
-
'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
|
|
67
|
-
),
|
|
68
|
-
ENABLED,
|
|
69
|
-
),
|
|
70
|
-
),
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('allows HIGH-RISK declared for a high-impact request', () => {
|
|
75
|
-
assert.equal(
|
|
76
|
-
runGate(
|
|
77
|
-
delegate(
|
|
78
|
-
'NIVEL: HIGH-RISK\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
|
|
79
|
-
),
|
|
80
|
-
ENABLED,
|
|
81
|
-
),
|
|
82
|
-
null,
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
test('
|
|
102
|
-
assert.equal(
|
|
103
|
-
runGate(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'risk-level-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
23
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
24
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
25
|
+
});
|
|
26
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function delegate(prompt, extra = {}) {
|
|
30
|
+
return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
|
|
31
|
+
}
|
|
32
|
+
function isDeny(result) {
|
|
33
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ENABLED = { config: { gates: { requireDeclaredRiskLevel: true } } };
|
|
37
|
+
|
|
38
|
+
test('denies an implementation delegation with no declared level', () => {
|
|
39
|
+
assert.ok(
|
|
40
|
+
isDeny(
|
|
41
|
+
runGate(
|
|
42
|
+
delegate('Arregla el boton que no cambia de color en la pagina.'),
|
|
43
|
+
ENABLED,
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('allows a declared level consistent with a low-risk request', () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
runGate(
|
|
52
|
+
delegate(
|
|
53
|
+
'NIVEL: STANDARD\n\nArregla el boton que no cambia de color en la pagina.',
|
|
54
|
+
),
|
|
55
|
+
ENABLED,
|
|
56
|
+
),
|
|
57
|
+
null,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('denies a declared level that contradicts a real high-impact signal', () => {
|
|
62
|
+
assert.ok(
|
|
63
|
+
isDeny(
|
|
64
|
+
runGate(
|
|
65
|
+
delegate(
|
|
66
|
+
'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
|
|
67
|
+
),
|
|
68
|
+
ENABLED,
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('allows HIGH-RISK declared for a high-impact request', () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
runGate(
|
|
77
|
+
delegate(
|
|
78
|
+
'NIVEL: HIGH-RISK\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
|
|
79
|
+
),
|
|
80
|
+
ENABLED,
|
|
81
|
+
),
|
|
82
|
+
null,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('bilingual control: an EN money-mutation brief contradicts a low level exactly like its ES equivalent', () => {
|
|
87
|
+
const es = delegate(
|
|
88
|
+
'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.',
|
|
89
|
+
);
|
|
90
|
+
const en = delegate(
|
|
91
|
+
'LEVEL: MICRO\n\nImplement the payment charge with the new money gateway.',
|
|
92
|
+
);
|
|
93
|
+
assert.ok(isDeny(runGate(es, ENABLED)));
|
|
94
|
+
assert.ok(isDeny(runGate(en, ENABLED)));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// A whitelisted read-only subagent name is now void whenever the prompt itself carries
|
|
98
|
+
// a mutation-risk signal (money/auth/data/write/deploy): the label is self-declared,
|
|
99
|
+
// never a verified capability, and a real risk signal in the text must win over it
|
|
100
|
+
// (bug fixed in this gate; see risk-level.edge.test.mjs).
|
|
101
|
+
test('allows a read-only subagent without a declared level when the prompt carries no mutation-risk signal', () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
runGate(
|
|
104
|
+
delegate('Explica como funciona el flujo de checkout actual.', {
|
|
105
|
+
subagent_type: 'explore',
|
|
106
|
+
}),
|
|
107
|
+
ENABLED,
|
|
108
|
+
),
|
|
109
|
+
null,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a read-only subagent name no longer exempts a real money-mutation prompt from declaring a level', () => {
|
|
114
|
+
assert.ok(
|
|
115
|
+
isDeny(
|
|
116
|
+
runGate(
|
|
117
|
+
delegate(
|
|
118
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.',
|
|
119
|
+
{
|
|
120
|
+
subagent_type: 'explore',
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
ENABLED,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('disabled by config: the gate does not run', () => {
|
|
130
|
+
assert.equal(
|
|
131
|
+
runGate(delegate('Arregla el boton que no cambia de color en la pagina.'), {
|
|
132
|
+
config: { gates: { requireDeclaredRiskLevel: false } },
|
|
133
|
+
}),
|
|
134
|
+
null,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('project highImpactPatterns override narrows what forces HIGH-RISK', () => {
|
|
139
|
+
const config = {
|
|
140
|
+
gates: {
|
|
141
|
+
requireDeclaredRiskLevel: {
|
|
142
|
+
enabled: true,
|
|
143
|
+
highImpactPatterns: ['pagin[ao]ci[oó]n'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const prompt =
|
|
148
|
+
'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.';
|
|
149
|
+
// With the default patterns (money/payment), MICRO contradicts the signal and is denied.
|
|
150
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
151
|
+
// Once overridden, "dinero/pago" no longer counts as high-impact, so MICRO is allowed.
|
|
152
|
+
assert.equal(runGate(delegate(prompt), { config }), null);
|
|
153
|
+
});
|
|
@@ -1,50 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
import {
|
|
2
|
+
runGate,
|
|
3
|
+
deny,
|
|
4
|
+
toolInGroups,
|
|
5
|
+
writtenContentOf,
|
|
6
|
+
delegationPromptOf,
|
|
7
|
+
} from '../../lib/hook-io.mjs';
|
|
8
|
+
|
|
9
|
+
const GATE_ID = 'root-cause-first';
|
|
10
|
+
const CONFIG_KEY = 'requireRootCauseBeforePatch';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
|
|
13
|
+
|
|
14
|
+
// The text to scan: a delegation's brief, or the content a write puts on disk. writtenContentOf
|
|
15
|
+
// covers every native and MCP write shape (Write's content, Edit's new_string, NotebookEdit's
|
|
16
|
+
// new_source, replace_file_content's new_content) — the old reader missed new_source, so a
|
|
17
|
+
// deferral marker written via NotebookEdit was never caught by this DENY gate.
|
|
18
|
+
function textToScan(toolName, toolInput) {
|
|
19
|
+
if (toolInGroups(toolName, ['delegation'])) return delegationPromptOf(toolInput);
|
|
20
|
+
return writtenContentOf(toolInput);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
runGate(
|
|
24
|
+
{
|
|
25
|
+
id: GATE_ID,
|
|
26
|
+
configKey: CONFIG_KEY,
|
|
27
|
+
enabledByDefault: false,
|
|
28
|
+
defaultParams: {
|
|
29
|
+
patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
({ toolName, toolInput, parameters }) => {
|
|
33
|
+
if (!toolInGroups(toolName, ['write', 'delegation'])) return;
|
|
34
|
+
|
|
35
|
+
const content = textToScan(toolName, toolInput);
|
|
36
|
+
if (!content) return;
|
|
37
|
+
|
|
38
|
+
const patterns = parameters.patchMarkerPatterns.map(
|
|
39
|
+
(source) => new RegExp(source, 'i'),
|
|
40
|
+
);
|
|
41
|
+
const matched = patterns.find((pattern) => pattern.test(content));
|
|
42
|
+
if (!matched) return;
|
|
43
|
+
|
|
44
|
+
deny(
|
|
45
|
+
GATE_ID,
|
|
46
|
+
`Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Simplified vs. the source guard (guard-root-cause-first.mjs): the original
|
|
52
|
+
// used an auxiliary lib/embedded-content-detection.mjs module to tell real
|
|
53
|
+
// file content apart from a quoted example inside markdown. That module does
|
|
54
|
+
// not exist in this repo, so this gate matches directly against the new
|
|
55
|
+
// content/prompt. Distinguishing markdown quotes from real code is future
|
|
56
|
+
// work if false positives on quoted examples become a problem.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
+
|
|
11
|
+
function runGate(payload, { config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'root-cause-first-edge-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
if (config) {
|
|
15
|
+
mkdirSync(join(project, '.ai'));
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
19
|
+
input: JSON.stringify(payload),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
cwd: project,
|
|
22
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
23
|
+
});
|
|
24
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isDeny(result) {
|
|
28
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ENABLE = { config: { gates: { requireRootCauseBeforePatch: true } } };
|
|
32
|
+
|
|
33
|
+
// FIXED: writtenContentOf reads NotebookEdit's new_source, so a deferral marker written via
|
|
34
|
+
// NotebookEdit is now caught by this DENY gate.
|
|
35
|
+
test('FIXED: patch-marker comment via NotebookEdit is now denied', () => {
|
|
36
|
+
const payload = {
|
|
37
|
+
tool_name: 'NotebookEdit',
|
|
38
|
+
tool_input: {
|
|
39
|
+
notebook_path: '/repo/nb.ipynb',
|
|
40
|
+
cell_id: 'x',
|
|
41
|
+
new_source: '// TODO fix later patch',
|
|
42
|
+
cell_type: 'code',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
assert.ok(isDeny(runGate(payload, ENABLE)), 'a patch marker via NotebookEdit must now be denied');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// FIXED: writtenContentOf reads replace_file_content's new_content field too.
|
|
49
|
+
test('FIXED: patch-marker via replace_file_content (new_content field) is now denied', () => {
|
|
50
|
+
const payload = {
|
|
51
|
+
tool_name: 'replace_file_content',
|
|
52
|
+
tool_input: {
|
|
53
|
+
file_path: '/repo/x.js',
|
|
54
|
+
new_content: '// TODO fix later patch',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
assert.ok(isDeny(runGate(payload, ENABLE)), 'the field-name variant must now be denied');
|
|
58
|
+
});
|