@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,136 +1,165 @@
|
|
|
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(), 'intent-flow-'));
|
|
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 = {
|
|
37
|
-
config: { gates: { requireScopeListBeforeDelegating: true } },
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
test('denies implementing a payment change with real intent and no scope list', () => {
|
|
41
|
-
const result = runGate(
|
|
42
|
-
delegate(
|
|
43
|
-
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
44
|
-
),
|
|
45
|
-
ENABLED,
|
|
46
|
-
);
|
|
47
|
-
assert.ok(isDeny(result));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('
|
|
51
|
-
const
|
|
52
|
-
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
assert.
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('
|
|
62
|
-
const prompt = [
|
|
63
|
-
'Implementa el cobro del pago con la nueva pasarela
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(), 'intent-flow-'));
|
|
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 = {
|
|
37
|
+
config: { gates: { requireScopeListBeforeDelegating: true } },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
test('denies implementing a payment change with real intent and no scope list', () => {
|
|
41
|
+
const result = runGate(
|
|
42
|
+
delegate(
|
|
43
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
44
|
+
),
|
|
45
|
+
ENABLED,
|
|
46
|
+
);
|
|
47
|
+
assert.ok(isDeny(result));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('bilingual control: an EN payment brief with no scope list denies exactly like its ES equivalent', () => {
|
|
51
|
+
const es = delegate(
|
|
52
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
53
|
+
);
|
|
54
|
+
const en = delegate(
|
|
55
|
+
'Implement the payment charge with the new money gateway for checkout.',
|
|
56
|
+
);
|
|
57
|
+
assert.ok(isDeny(runGate(es, ENABLED)));
|
|
58
|
+
assert.ok(isDeny(runGate(en, ENABLED)));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('allows the same request once IN SCOPE / OUT OF SCOPE / EDGE CASES are declared', () => {
|
|
62
|
+
const prompt = [
|
|
63
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.',
|
|
64
|
+
'',
|
|
65
|
+
'QUE SI (alcance): integrar el nuevo proveedor de pago en el checkout.',
|
|
66
|
+
'QUE NO (fuera de alcance): no tocar el flujo de reembolsos.',
|
|
67
|
+
'EDGE CASES: no aplican casos borde adicionales.',
|
|
68
|
+
].join('\n');
|
|
69
|
+
assert.equal(runGate(delegate(prompt), ENABLED), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('denies hard when a high-impact request declares an unresolved unknown', () => {
|
|
73
|
+
const prompt = [
|
|
74
|
+
'Implementa el cobro del pago con la nueva pasarela; todavia no esta claro que hacer con reembolsos parciales, unknown.',
|
|
75
|
+
'QUE SI: cobro basico. QUE NO: reembolsos. EDGE CASES: ninguno.',
|
|
76
|
+
].join('\n');
|
|
77
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('allows a request mentioning payment only as documentation topic', () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
runGate(
|
|
83
|
+
delegate(
|
|
84
|
+
'Crea un documento README.md que explique como funciona el pago y la autenticacion del sistema para el equipo nuevo.',
|
|
85
|
+
),
|
|
86
|
+
ENABLED,
|
|
87
|
+
),
|
|
88
|
+
null,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('allows a normal implementation request with no high-impact signal', () => {
|
|
93
|
+
assert.equal(
|
|
94
|
+
runGate(
|
|
95
|
+
delegate(
|
|
96
|
+
'Arregla el boton que no cambia de color al pasar el mouse en la pagina de inicio.',
|
|
97
|
+
),
|
|
98
|
+
ENABLED,
|
|
99
|
+
),
|
|
100
|
+
null,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A whitelisted read-only subagent name is now void whenever the prompt itself carries
|
|
105
|
+
// a mutation-risk signal (money/auth/data/write/deploy): the label is self-declared,
|
|
106
|
+
// never a verified capability, and a real risk signal in the text must win over it
|
|
107
|
+
// (bug fixed in this gate; see intent-flow.edge.test.mjs). So a prompt naming "plan" is
|
|
108
|
+
// only exempt when it carries no such signal.
|
|
109
|
+
test('allows a read-only subagent when the prompt carries no mutation-risk signal', () => {
|
|
110
|
+
assert.equal(
|
|
111
|
+
runGate(
|
|
112
|
+
delegate('Explica como funciona el flujo de checkout actual.', {
|
|
113
|
+
subagent_type: 'plan',
|
|
114
|
+
}),
|
|
115
|
+
ENABLED,
|
|
116
|
+
),
|
|
117
|
+
null,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a read-only subagent name no longer exempts a real money-mutation prompt', () => {
|
|
122
|
+
assert.ok(
|
|
123
|
+
isDeny(
|
|
124
|
+
runGate(
|
|
125
|
+
delegate(
|
|
126
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.',
|
|
127
|
+
{
|
|
128
|
+
subagent_type: 'plan',
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
ENABLED,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('disabled by config: the gate does not run', () => {
|
|
138
|
+
assert.equal(
|
|
139
|
+
runGate(
|
|
140
|
+
delegate('Implementa el cobro del pago con la nueva pasarela de dinero.'),
|
|
141
|
+
{
|
|
142
|
+
config: { gates: { requireScopeListBeforeDelegating: false } },
|
|
143
|
+
},
|
|
144
|
+
),
|
|
145
|
+
null,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('project highImpactPatterns override narrows what counts as high-impact', () => {
|
|
150
|
+
const config = {
|
|
151
|
+
gates: {
|
|
152
|
+
requireScopeListBeforeDelegating: {
|
|
153
|
+
enabled: true,
|
|
154
|
+
highImpactPatterns: ['pagin[ao]ci[oó]n'],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const prompt =
|
|
159
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.';
|
|
160
|
+
// With the default patterns (money/payment), this same prompt is denied.
|
|
161
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
162
|
+
// No longer matches "dinero/pago" as high-impact once overridden, so no scope list
|
|
163
|
+
// is required and the gate allows.
|
|
164
|
+
assert.equal(runGate(delegate(prompt), { config }), null);
|
|
165
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// lint-commit — denies `git commit` while the project's lint script fails. The gate never
|
|
2
|
+
// invents a lint step: it only runs a `lint` script the project itself declared in
|
|
3
|
+
// package.json (or an explicit override), so a project with no linter configured is left
|
|
4
|
+
// alone (allow, silent).
|
|
5
|
+
//
|
|
6
|
+
// justification: no existing tool covers this. bash-commands (the other shell gate) only
|
|
7
|
+
// pattern-matches the command text; it never SPAWNS a check. This is the first gate that
|
|
8
|
+
// runs an external process as its verdict.
|
|
9
|
+
//
|
|
10
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
11
|
+
// lintCommand explicit command to run instead of autodetecting `npm run lint`.
|
|
12
|
+
// null (default) means: read package.json's `scripts.lint`, and if
|
|
13
|
+
// absent, allow — this gate does not invent a lint step.
|
|
14
|
+
// lintTimeoutMs how long the lint run may take before it is treated as a failure.
|
|
15
|
+
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
16
|
+
// its override replaces.
|
|
17
|
+
//
|
|
18
|
+
// ── How it detects a commit ──────────────────────────────────────────────────────────
|
|
19
|
+
// Reuses bash-commands' git-global-option normalization idea: `git -C <path> commit` or
|
|
20
|
+
// `git -c user.name=x commit` must be caught the same as a bare `git commit`. Only a real
|
|
21
|
+
// commit (not `git commit --help`, not a mention inside a delegation prompt describing one)
|
|
22
|
+
// is gated — this runs on the shell tool only, not on delegation text, because spawning a
|
|
23
|
+
// lint run for a prompt that merely MENTIONS a commit would be wasted work and a false
|
|
24
|
+
// blocker on unrelated delegations.
|
|
25
|
+
//
|
|
26
|
+
// ── Why spawnSync + shell:true on Windows ────────────────────────────────────────────
|
|
27
|
+
// `npm` is `npm.cmd` on Windows; resolving the concrete binary name per platform is more
|
|
28
|
+
// fragile than letting the shell resolve it, so this always spawns through the shell.
|
|
29
|
+
//
|
|
30
|
+
// ── Fail-safe shape ───────────────────────────────────────────────────────────────────
|
|
31
|
+
// No package.json, or package.json with no `scripts.lint` and no `lintCommand` override:
|
|
32
|
+
// allow (nothing to enforce, never invented). Lint run exits non-zero: deny with the last
|
|
33
|
+
// ~15 lines of its combined output. Lint run exits zero: allow.
|
|
34
|
+
|
|
35
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
|
|
39
|
+
|
|
40
|
+
const GATE_ID = 'lint-commit';
|
|
41
|
+
const CONFIG_KEY = 'blockCommitWithFailingLint';
|
|
42
|
+
|
|
43
|
+
const SHELL_GROUPS = ['shell'];
|
|
44
|
+
const DEFAULT_LINT_TIMEOUT_MS = 60000;
|
|
45
|
+
const OUTPUT_TAIL_LINES = 15;
|
|
46
|
+
|
|
47
|
+
// One global git option at a time, stripped repeatedly — same normalization bash-commands
|
|
48
|
+
// uses so `git -C /repo -c x=y commit` reduces to `git commit` before the pattern runs.
|
|
49
|
+
const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
|
|
50
|
+
const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
|
|
51
|
+
const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
|
|
52
|
+
String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
|
|
53
|
+
'i',
|
|
54
|
+
);
|
|
55
|
+
const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
|
|
56
|
+
|
|
57
|
+
function normalizeGitOptions(command) {
|
|
58
|
+
let previous;
|
|
59
|
+
let normalized = command;
|
|
60
|
+
do {
|
|
61
|
+
previous = normalized;
|
|
62
|
+
normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
|
|
63
|
+
} while (normalized !== previous);
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isGitCommit(command) {
|
|
68
|
+
return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function commandTextFrom(toolInput) {
|
|
72
|
+
return String(toolInput.CommandLine ?? toolInput.command ?? '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The project's declared lint command, or null when none is declared (never invented). */
|
|
76
|
+
function resolveLintCommand(cwd, lintCommandOverride) {
|
|
77
|
+
if (lintCommandOverride) return lintCommandOverride;
|
|
78
|
+
|
|
79
|
+
const packageJsonPath = join(cwd, 'package.json');
|
|
80
|
+
if (!existsSync(packageJsonPath)) return null;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
84
|
+
if (packageJson?.scripts?.lint) return 'npm run lint';
|
|
85
|
+
return null;
|
|
86
|
+
} catch {
|
|
87
|
+
// Corrupt package.json is not this gate's problem to diagnose; do not invent a lint step.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function tailLines(text, count) {
|
|
93
|
+
const lines = String(text).split(/\r?\n/).filter((line) => line.length > 0);
|
|
94
|
+
return lines.slice(-count).join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runLint(lintCommand, cwd, timeoutMs) {
|
|
98
|
+
return spawnSync(lintCommand, {
|
|
99
|
+
cwd,
|
|
100
|
+
shell: true,
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
timeout: timeoutMs,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
runGate(
|
|
107
|
+
{
|
|
108
|
+
id: GATE_ID,
|
|
109
|
+
configKey: CONFIG_KEY,
|
|
110
|
+
enabledByDefault: false,
|
|
111
|
+
defaultParams: {
|
|
112
|
+
lintCommand: null,
|
|
113
|
+
lintTimeoutMs: DEFAULT_LINT_TIMEOUT_MS,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
({ toolName, toolInput, parameters }) => {
|
|
117
|
+
if (!toolInGroups(toolName, SHELL_GROUPS)) return;
|
|
118
|
+
|
|
119
|
+
const command = commandTextFrom(toolInput);
|
|
120
|
+
if (!isGitCommit(command)) return;
|
|
121
|
+
|
|
122
|
+
const cwd = process.cwd();
|
|
123
|
+
const lintCommand = resolveLintCommand(cwd, parameters.lintCommand);
|
|
124
|
+
if (!lintCommand) return; // no lint script declared anywhere: nothing to enforce
|
|
125
|
+
|
|
126
|
+
const timeoutMs = parameters.lintTimeoutMs ?? DEFAULT_LINT_TIMEOUT_MS;
|
|
127
|
+
const result = runLint(lintCommand, cwd, timeoutMs);
|
|
128
|
+
|
|
129
|
+
if (result.error) {
|
|
130
|
+
// The lint process itself could not be spawned (bad shell, missing interpreter, or a
|
|
131
|
+
// timeout kill). A commit gate that cannot evaluate must not silently permit — deny
|
|
132
|
+
// with the spawn error so the user can fix the environment, not guess.
|
|
133
|
+
deny(
|
|
134
|
+
GATE_ID,
|
|
135
|
+
`Could not run the lint command ("${lintCommand}"): ${result.error.message}. Fix ` +
|
|
136
|
+
'the lint setup or set lintCommand/blockCommitWithFailingLint in .ai/config.json.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.status !== 0) {
|
|
141
|
+
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
142
|
+
deny(
|
|
143
|
+
GATE_ID,
|
|
144
|
+
`Lint failed (exit ${result.status}) — commit blocked until it passes:\n` +
|
|
145
|
+
`${tailLines(combinedOutput, OUTPUT_TAIL_LINES)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
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 bashPayload(command) {
|
|
12
|
+
return { tool_name: 'Bash', tool_input: { command } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isDeny(result) {
|
|
16
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Runs the gate in a temp project. HOME isolated so the real global config is not read. */
|
|
20
|
+
function runGate(project, payload, { enabled = true } = {}) {
|
|
21
|
+
mkdirSync(join(project, '.git'), { recursive: true });
|
|
22
|
+
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(project, '.ai', 'config.json'),
|
|
25
|
+
JSON.stringify({ gates: { blockCommitWithFailingLint: { enabled } } }),
|
|
26
|
+
);
|
|
27
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
28
|
+
input: JSON.stringify(payload),
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
cwd: project,
|
|
31
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
32
|
+
});
|
|
33
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function projectWithLintScript(exitCode) {
|
|
37
|
+
const project = mkdtempSync(join(tmpdir(), 'lint-commit-'));
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(project, 'package.json'),
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
name: 'fixture',
|
|
42
|
+
scripts: {
|
|
43
|
+
lint: `node -e "process.exit(${exitCode})"`,
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return project;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('lint exits non-zero: git commit is denied with tail of output', () => {
|
|
51
|
+
const project = projectWithLintScript(1);
|
|
52
|
+
const result = runGate(project, bashPayload('git commit -m "x"'));
|
|
53
|
+
assert.ok(isDeny(result), 'expected a deny result');
|
|
54
|
+
assert.match(result.hookSpecificOutput.permissionDecisionReason, /Lint failed/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('lint exits zero: git commit is allowed', () => {
|
|
58
|
+
const project = projectWithLintScript(0);
|
|
59
|
+
const result = runGate(project, bashPayload('git commit -m "x"'));
|
|
60
|
+
assert.equal(result, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('no package.json / no lint script: commit is allowed (never invented)', () => {
|
|
64
|
+
const project = mkdtempSync(join(tmpdir(), 'lint-commit-nolint-'));
|
|
65
|
+
const result = runGate(project, bashPayload('git commit -m "x"'));
|
|
66
|
+
assert.equal(result, null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a non-commit shell command is never blocked, even with failing lint', () => {
|
|
70
|
+
const project = projectWithLintScript(1);
|
|
71
|
+
const result = runGate(project, bashPayload('git status'));
|
|
72
|
+
assert.equal(result, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('git -C <path> commit is still recognized as a commit (global-option bypass closed)', () => {
|
|
76
|
+
const project = projectWithLintScript(1);
|
|
77
|
+
const result = runGate(project, bashPayload(`git -C ${project} commit -m "x"`));
|
|
78
|
+
assert.ok(isDeny(result));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('gate disabled: commit is allowed even with failing lint', () => {
|
|
82
|
+
const project = projectWithLintScript(1);
|
|
83
|
+
const result = runGate(project, bashPayload('git commit -m "x"'), { enabled: false });
|
|
84
|
+
assert.equal(result, null);
|
|
85
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Edge-case probes for mandatory-flow. Previously: (1) the prompt was read only from
|
|
2
|
+
// prompt/Prompt/task, so a Task delegation carrying its brief in `description`
|
|
3
|
+
// bypassed the check (shared root cause with implementation-pipeline, test-matrix,
|
|
4
|
+
// sdd-specs delegation check); (2) the ACTIVA pointer's slug was used unsanitized in
|
|
5
|
+
// join(cwd, '.ai', 'pipeline', slug), so a slug with '..' segments let
|
|
6
|
+
// hasTaskContract() resolve outside .ai/pipeline/ and accept an unrelated file
|
|
7
|
+
// anywhere on disk as the active task's contract. Both are now fixed:
|
|
8
|
+
// delegationPromptOf() reads description too, and readSlug() rejects any slug
|
|
9
|
+
// containing a path separator or a '.'/'..' segment, treating it as empty (which
|
|
10
|
+
// denies the same way a missing slug does).
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
20
|
+
|
|
21
|
+
function makeProject() {
|
|
22
|
+
const project = mkdtempSync(join(tmpdir(), 'mandatory-flow-edge-'));
|
|
23
|
+
mkdirSync(join(project, '.git'));
|
|
24
|
+
return project;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeProjectConfig(project, config) {
|
|
28
|
+
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
29
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runGate(project, payload) {
|
|
33
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
34
|
+
input: JSON.stringify(payload),
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
cwd: project,
|
|
37
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
38
|
+
});
|
|
39
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDeny(result) {
|
|
43
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function enableGate(project) {
|
|
47
|
+
writeProjectConfig(project, { gates: { requireLiveTaskWhenImplementing: true } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
|
|
51
|
+
const project = makeProject();
|
|
52
|
+
enableGate(project);
|
|
53
|
+
// No ACTIVA pointer at all -- with delegationPromptOf reading `description`, this
|
|
54
|
+
// now reaches checkLiveTask and denies for the missing pointer.
|
|
55
|
+
const payload = {
|
|
56
|
+
tool_name: 'Task',
|
|
57
|
+
tool_input: {
|
|
58
|
+
description: 'Nivel: STANDARD\nImplementá el checkout.',
|
|
59
|
+
subagent_type: 'backend',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
assert.ok(
|
|
63
|
+
isDeny(runGate(project, payload)),
|
|
64
|
+
'delegationPromptOf reads description and the no-live-task deny fires',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('OK: the equivalent payload using `prompt` with no ACTIVA pointer IS denied (control)', () => {
|
|
69
|
+
const project = makeProject();
|
|
70
|
+
enableGate(project);
|
|
71
|
+
const payload = {
|
|
72
|
+
tool_name: 'Task',
|
|
73
|
+
tool_input: {
|
|
74
|
+
prompt: 'Nivel: STANDARD\nImplementá el checkout.',
|
|
75
|
+
subagent_type: 'backend',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
assert.ok(isDeny(runGate(project, payload)), 'control failed');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('FIXED: a path-traversal slug in ACTIVA no longer lets an unrelated file satisfy hasTaskContract', () => {
|
|
82
|
+
const project = makeProject();
|
|
83
|
+
enableGate(project);
|
|
84
|
+
mkdirSync(join(project, '.ai', 'pipeline'), { recursive: true });
|
|
85
|
+
mkdirSync(join(project, 'evil'), { recursive: true });
|
|
86
|
+
writeFileSync(join(project, 'evil', 'asserts.md'), 'unrelated content');
|
|
87
|
+
writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), '../../evil');
|
|
88
|
+
const payload = {
|
|
89
|
+
tool_name: 'Agent',
|
|
90
|
+
tool_input: {
|
|
91
|
+
prompt: 'Nivel: STANDARD\nImplementá el checkout.',
|
|
92
|
+
subagent_type: 'backend',
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
assert.ok(
|
|
96
|
+
isDeny(runGate(project, payload)),
|
|
97
|
+
'readSlug must reject the traversal slug (treated as empty), denying instead of ' +
|
|
98
|
+
'accepting the unrelated file outside .ai/pipeline/',
|
|
99
|
+
);
|
|
100
|
+
});
|