@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Edge audit for forge-flow: fail-open vs fail-closed behavior, and the MCP write/shell
|
|
2
|
+
// matcher hole (same structural gap as autonomous-mode's question hole, applied here to
|
|
3
|
+
// ACTING_TOOLS = TOOL_GROUPS.write + TOOL_GROUPS.shell).
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
15
|
+
|
|
16
|
+
function adoptedProject() {
|
|
17
|
+
const project = mkdtempSync(join(tmpdir(), 'forge-edge-'));
|
|
18
|
+
mkdirSync(join(project, '.git'));
|
|
19
|
+
mkdirSync(join(project, '.ai'));
|
|
20
|
+
writeFileSync(join(project, '.ai', 'forge.json'), '{}');
|
|
21
|
+
return project;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeConfig(project, forgeDatabasePath, enabled = true) {
|
|
25
|
+
const gateEntry = { enabled };
|
|
26
|
+
if (forgeDatabasePath) gateEntry.forgeDatabasePath = forgeDatabasePath;
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(project, '.ai', 'config.json'),
|
|
29
|
+
JSON.stringify({ gates: { requireForgeRunToEdit: gateEntry } }),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runGate(payload, project) {
|
|
34
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
35
|
+
input: JSON.stringify(payload),
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
cwd: project,
|
|
38
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
39
|
+
});
|
|
40
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isDeny(result) {
|
|
44
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isWarn(result) {
|
|
48
|
+
const output = result?.hookSpecificOutput;
|
|
49
|
+
return Boolean(output?.additionalContext) && output.permissionDecision !== 'deny';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeForgeDatabase(directory, activeCwd) {
|
|
53
|
+
const databasePath = join(directory, 'forge.db');
|
|
54
|
+
const database = new DatabaseSync(databasePath);
|
|
55
|
+
database.exec(
|
|
56
|
+
'CREATE TABLE runs (id TEXT, cwd TEXT, current_phase TEXT, status TEXT)',
|
|
57
|
+
);
|
|
58
|
+
if (activeCwd) {
|
|
59
|
+
database
|
|
60
|
+
.prepare('INSERT INTO runs (id, cwd, current_phase, status) VALUES (?,?,?,?)')
|
|
61
|
+
.run('r1', activeCwd, 'build', 'active');
|
|
62
|
+
}
|
|
63
|
+
database.close();
|
|
64
|
+
return databasePath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('DEGRADED-WARN: a corrupt (non-SQLite) DB file allows the write but WARNS visibly', () => {
|
|
68
|
+
const project = adoptedProject();
|
|
69
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
70
|
+
const databasePath = join(home, 'forge.db');
|
|
71
|
+
writeFileSync(databasePath, 'this is not a sqlite file at all');
|
|
72
|
+
writeConfig(project, databasePath);
|
|
73
|
+
const result = runGate(
|
|
74
|
+
{ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
|
|
75
|
+
project,
|
|
76
|
+
);
|
|
77
|
+
// The catch-all no longer returns a silent "run present". A DB that cannot be read yields
|
|
78
|
+
// state 'unknown' -> the gate ALLOWS (a broken lookup must not freeze all work) but emits a
|
|
79
|
+
// warn, so degraded enforcement is never invisible. This is the fix for the old silent
|
|
80
|
+
// fail-open that disabled the whole gate with no trace.
|
|
81
|
+
assert.ok(isWarn(result), 'corrupt DB must allow-with-warning, not allow silently');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('DEGRADED-WARN: DB exists but "runs" table is missing/wrong schema warns visibly', () => {
|
|
85
|
+
const project = adoptedProject();
|
|
86
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
87
|
+
const databasePath = join(home, 'forge.db');
|
|
88
|
+
const database = new DatabaseSync(databasePath);
|
|
89
|
+
database.exec('CREATE TABLE not_runs (id TEXT)');
|
|
90
|
+
database.close();
|
|
91
|
+
writeConfig(project, databasePath);
|
|
92
|
+
const result = runGate(
|
|
93
|
+
{ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
|
|
94
|
+
project,
|
|
95
|
+
);
|
|
96
|
+
assert.ok(isWarn(result), 'schema drift must allow-with-warning, not allow silently');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('FAIL-CLOSED (deny): DB file plainly absent at the configured path denies the write', () => {
|
|
100
|
+
const project = adoptedProject();
|
|
101
|
+
writeConfig(project, join(tmpdir(), 'definitely-does-not-exist-' + Date.now(), 'forge.db'));
|
|
102
|
+
const result = runGate(
|
|
103
|
+
{ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
|
|
104
|
+
project,
|
|
105
|
+
);
|
|
106
|
+
// existsSync(forgeDatabasePath) is false -> hasActiveForgeRun returns false directly
|
|
107
|
+
// (line 72: "no DB yet -> no runs at all -> deny") -- this is the ONE path that is
|
|
108
|
+
// fail-closed; every other failure mode (corrupt file, missing table, locked file,
|
|
109
|
+
// node:sqlite unavailable) is fail-open via the catch block.
|
|
110
|
+
assert.ok(isDeny(result), 'FALLA(ok): absent DB is the sole fail-closed path');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('FIXED: an MCP filesystem write tool is now gated (denied with no active run)', () => {
|
|
114
|
+
const project = adoptedProject();
|
|
115
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
116
|
+
const databasePath = makeForgeDatabase(home); // no active run: a real Write would be denied
|
|
117
|
+
writeConfig(project, databasePath);
|
|
118
|
+
const result = runGate(
|
|
119
|
+
{
|
|
120
|
+
tool_name: 'mcp__filesystem__write_file',
|
|
121
|
+
tool_input: { path: 'x.js', content: 'x' },
|
|
122
|
+
},
|
|
123
|
+
project,
|
|
124
|
+
);
|
|
125
|
+
// ACTING_GROUPS = ['execution'] and toolInGroups matches mcp__*__write_file via the write
|
|
126
|
+
// signal, so an MCP filesystem write is now subject to the active-run requirement exactly
|
|
127
|
+
// like a native Write. No active run -> deny.
|
|
128
|
+
assert.ok(isDeny(result), 'MCP write tool must now be gated like a native Write');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('control: the same DB/config DOES deny a native Write (proves the MCP case above is a real gap, not a config issue)', () => {
|
|
132
|
+
const project = adoptedProject();
|
|
133
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
134
|
+
const databasePath = makeForgeDatabase(home); // no active run
|
|
135
|
+
writeConfig(project, databasePath);
|
|
136
|
+
const result = runGate(
|
|
137
|
+
{ tool_name: 'Write', tool_input: { file_path: 'x.js', content: 'x' } },
|
|
138
|
+
project,
|
|
139
|
+
);
|
|
140
|
+
assert.ok(isDeny(result), 'FALLA(ok): native Write is correctly denied under identical setup');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('FIXED: an MCP shell/exec tool (mcp__ide__executeCode) is now gated', () => {
|
|
144
|
+
const project = adoptedProject();
|
|
145
|
+
const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
|
|
146
|
+
const databasePath = makeForgeDatabase(home); // no active run
|
|
147
|
+
writeConfig(project, databasePath);
|
|
148
|
+
const result = runGate(
|
|
149
|
+
{ tool_name: 'mcp__ide__executeCode', tool_input: { code: 'require("fs").writeFileSync("x.js","x")' } },
|
|
150
|
+
project,
|
|
151
|
+
);
|
|
152
|
+
// forge-flow now consumes the 'execution' group, which includes mcp__ide__executeCode and,
|
|
153
|
+
// via toolInGroups, any mcp__* exec/run surface. No active run -> deny.
|
|
154
|
+
assert.ok(isDeny(result), 'mcp__ide__executeCode must now be gated by forge-flow');
|
|
155
|
+
});
|
|
@@ -1,112 +1,134 @@
|
|
|
1
|
-
// forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
|
|
2
|
-
// intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
|
|
3
|
-
// hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
|
|
4
|
-
// an active forge run for this project — so every change goes through the pipeline and you
|
|
5
|
-
// always know which phase you are in.
|
|
6
|
-
//
|
|
7
|
-
// justification: no existing tool covers this. A forge MCP audit (see memory
|
|
8
|
-
// forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
|
|
9
|
-
// only a Claude Code PreToolUse hook can. This is that hook.
|
|
10
|
-
//
|
|
11
|
-
// ── When it acts (never surprises you) ──────────────────────────────────────────────
|
|
12
|
-
// Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
|
|
13
|
-
// in .ai/config.json). In any other project it stays silent. Off by default in the
|
|
14
|
-
// registry, so it never fires unless a project turns it on.
|
|
15
|
-
//
|
|
16
|
-
// ── How it checks (deterministic, no judgment) ──────────────────────────────────────
|
|
17
|
-
// forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
|
|
18
|
-
// node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
|
|
19
|
-
// cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
|
|
20
|
-
// node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
|
|
21
|
-
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
-
import { homedir } from 'node:os';
|
|
24
|
-
import { dirname, join } from 'node:path';
|
|
25
|
-
import { runGate, deny,
|
|
26
|
-
|
|
27
|
-
const GATE_ID = 'forge-flow';
|
|
28
|
-
const CONFIG_KEY = 'requireForgeRunToEdit';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
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
|
-
)
|
|
1
|
+
// forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
|
|
2
|
+
// intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
|
|
3
|
+
// hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
|
|
4
|
+
// an active forge run for this project — so every change goes through the pipeline and you
|
|
5
|
+
// always know which phase you are in.
|
|
6
|
+
//
|
|
7
|
+
// justification: no existing tool covers this. A forge MCP audit (see memory
|
|
8
|
+
// forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
|
|
9
|
+
// only a Claude Code PreToolUse hook can. This is that hook.
|
|
10
|
+
//
|
|
11
|
+
// ── When it acts (never surprises you) ──────────────────────────────────────────────
|
|
12
|
+
// Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
|
|
13
|
+
// in .ai/config.json). In any other project it stays silent. Off by default in the
|
|
14
|
+
// registry, so it never fires unless a project turns it on.
|
|
15
|
+
//
|
|
16
|
+
// ── How it checks (deterministic, no judgment) ──────────────────────────────────────
|
|
17
|
+
// forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
|
|
18
|
+
// node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
|
|
19
|
+
// cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
|
|
20
|
+
// node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { runGate, deny, warn, toolInGroups } from '../../lib/hook-io.mjs';
|
|
26
|
+
|
|
27
|
+
const GATE_ID = 'forge-flow';
|
|
28
|
+
const CONFIG_KEY = 'requireForgeRunToEdit';
|
|
29
|
+
|
|
30
|
+
// Any code-mutating surface: native write/shell AND their MCP equivalents. `execution`
|
|
31
|
+
// already unions write+shell+mcp__ide__executeCode, and toolInGroups adds the mcp__* signal
|
|
32
|
+
// match — so an MCP filesystem-write or shell-exec tool no longer slips past the enforcer.
|
|
33
|
+
const ACTING_GROUPS = ['execution'];
|
|
34
|
+
const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
|
|
35
|
+
const DEFAULT_FORGE_DB = join('.forge', 'forge-mcp.db');
|
|
36
|
+
const FORGE_MARKER_FILE = join('.ai', 'forge.json');
|
|
37
|
+
const PROJECT_CONFIG_FILE = join('.ai', 'config.json');
|
|
38
|
+
|
|
39
|
+
function projectRootOf(startDirectory) {
|
|
40
|
+
let current = startDirectory;
|
|
41
|
+
while (true) {
|
|
42
|
+
if (
|
|
43
|
+
PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
|
|
44
|
+
) {
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
const parent = dirname(current);
|
|
48
|
+
if (parent === current) return null;
|
|
49
|
+
current = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readJson(path) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when this project opted into forge: a marker file, or forge:true in .ai/config.json. */
|
|
62
|
+
function projectAdoptedForge(root) {
|
|
63
|
+
if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
|
|
64
|
+
const config = readJson(join(root, PROJECT_CONFIG_FILE));
|
|
65
|
+
return config?.forge === true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when forge has an active run whose cwd is this project. Reads forge's SQLite DB with
|
|
70
|
+
* node:sqlite (imported dynamically so an older Node without it degrades instead of
|
|
71
|
+
* throwing). Any failure — module missing, DB absent, locked, unreadable, schema drift —
|
|
72
|
+
* returns true: the gate must fail OPEN, since a broken lookup must never block every edit.
|
|
73
|
+
*/
|
|
74
|
+
// Returns one of three verdicts, so a broken lookup no longer masquerades as "run present":
|
|
75
|
+
// { state: 'active' } → a run for this project exists; allow.
|
|
76
|
+
// { state: 'none' } → DB readable, no active run for this project; deny.
|
|
77
|
+
// { state: 'unknown', reason } → DB absent/locked/corrupt/schema-drift, or node:sqlite
|
|
78
|
+
// missing; we cannot tell. Warn (visible) but allow, so a
|
|
79
|
+
// broken DB never blocks all work AND never disables the
|
|
80
|
+
// enforcer silently — the earlier code returned true here,
|
|
81
|
+
// which looked identical to "run present".
|
|
82
|
+
async function forgeRunState(root, forgeDatabasePath) {
|
|
83
|
+
if (!existsSync(forgeDatabasePath)) return { state: 'none' }; // no DB yet → no runs → deny
|
|
84
|
+
try {
|
|
85
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
86
|
+
const database = new DatabaseSync(forgeDatabasePath, { readOnly: true });
|
|
87
|
+
const rows = database
|
|
88
|
+
.prepare("SELECT cwd FROM runs WHERE status = 'active'")
|
|
89
|
+
.all();
|
|
90
|
+
database.close();
|
|
91
|
+
return rows.some((row) => String(row.cwd) === root)
|
|
92
|
+
? { state: 'active' }
|
|
93
|
+
: { state: 'none' };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { state: 'unknown', reason: error?.message ?? String(error) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DENY_MESSAGE =
|
|
100
|
+
'This project uses forge, but there is no active forge run for it. Every change should ' +
|
|
101
|
+
'go through the pipeline so the next step is always clear. Start or resume a run ' +
|
|
102
|
+
'(forge_start / forge_next) before editing — that is how forge tells you which phase ' +
|
|
103
|
+
'you are in. To work outside the pipeline, turn this gate off in .ai/config.json.';
|
|
104
|
+
|
|
105
|
+
runGate(
|
|
106
|
+
{
|
|
107
|
+
id: GATE_ID,
|
|
108
|
+
configKey: CONFIG_KEY,
|
|
109
|
+
enabledByDefault: false,
|
|
110
|
+
defaultParams: { forgeDatabasePath: join(homedir(), DEFAULT_FORGE_DB) },
|
|
111
|
+
},
|
|
112
|
+
async ({ toolName, parameters }) => {
|
|
113
|
+
if (!toolInGroups(toolName, ACTING_GROUPS)) return;
|
|
114
|
+
|
|
115
|
+
const root = projectRootOf(process.cwd());
|
|
116
|
+
if (!root) return; // no project
|
|
117
|
+
if (!projectAdoptedForge(root)) return; // project did not opt into forge
|
|
118
|
+
|
|
119
|
+
const forgeDatabasePath =
|
|
120
|
+
parameters.forgeDatabasePath ?? join(homedir(), DEFAULT_FORGE_DB);
|
|
121
|
+
const result = await forgeRunState(root, forgeDatabasePath);
|
|
122
|
+
|
|
123
|
+
if (result.state === 'active') return; // pipeline is running → allow
|
|
124
|
+
if (result.state === 'none') deny(GATE_ID, DENY_MESSAGE); // no run → block
|
|
125
|
+
// state 'unknown': the DB could not be read. Allow so a broken lookup never freezes work,
|
|
126
|
+
// but surface it loudly — a silent allow here would disable the enforcer without a trace.
|
|
127
|
+
warn(
|
|
128
|
+
GATE_ID,
|
|
129
|
+
`forge enforcement is degraded: the forge DB could not be read (${result.reason}). ` +
|
|
130
|
+
'Allowing this action, but the pipeline is NOT being enforced. Check that forge is ' +
|
|
131
|
+
`installed and ${forgeDatabasePath} is readable, or turn this gate off if intended.`,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Edge-case probe for implementation-pipeline. Previously the prompt was read only
|
|
2
|
+
// from toolInput.prompt/Prompt/task, so a Task delegation carrying its brief in
|
|
3
|
+
// `description` bypassed the pipeline check entirely. Migrated to
|
|
4
|
+
// delegationPromptOf() (hook-io.mjs), which also reads description/instructions/
|
|
5
|
+
// message/input, so this is now caught.
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
15
|
+
|
|
16
|
+
function runGate(payload, { config } = {}) {
|
|
17
|
+
const project = mkdtempSync(join(tmpdir(), 'implementation-pipeline-edge-'));
|
|
18
|
+
mkdirSync(join(project, '.git'));
|
|
19
|
+
if (config) {
|
|
20
|
+
mkdirSync(join(project, '.ai'));
|
|
21
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
22
|
+
}
|
|
23
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
+
input: JSON.stringify(payload),
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
cwd: project,
|
|
27
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
+
});
|
|
29
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isDeny(result) {
|
|
33
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function enabledConfig() {
|
|
37
|
+
return { gates: { requireImplementationPipeline: true } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
|
|
41
|
+
const payload = {
|
|
42
|
+
tool_name: 'Task',
|
|
43
|
+
tool_input: {
|
|
44
|
+
description: 'Nivel: STANDARD\nImplementá el checkout.',
|
|
45
|
+
subagent_type: 'backend',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
assert.ok(
|
|
49
|
+
isDeny(runGate(payload, { config: enabledConfig() })),
|
|
50
|
+
'delegationPromptOf reads description and the missing-pipeline-stages deny fires',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('OK: the equivalent payload using `prompt` IS caught (control)', () => {
|
|
55
|
+
const payload = {
|
|
56
|
+
tool_name: 'Task',
|
|
57
|
+
tool_input: {
|
|
58
|
+
prompt: 'Nivel: STANDARD\nImplementá el checkout.',
|
|
59
|
+
subagent_type: 'backend',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
assert.ok(
|
|
63
|
+
isDeny(runGate(payload, { config: enabledConfig() })),
|
|
64
|
+
'control failed',
|
|
65
|
+
);
|
|
66
|
+
});
|