@devrik-tools/claude-gates 0.1.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 +17 -0
- package/README.es.md +219 -0
- package/README.md +222 -0
- package/cli/__tests__/config.test.mjs +101 -0
- package/cli/__tests__/init-flags.test.mjs +111 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
- package/cli/__tests__/selection.test.mjs +200 -0
- package/cli/config.mjs +113 -0
- package/cli/constants.mjs +36 -0
- package/cli/index.mjs +97 -0
- package/cli/init.mjs +256 -0
- package/cli/install.mjs +80 -0
- package/cli/materialize.mjs +51 -0
- package/cli/registry.mjs +127 -0
- package/cli/selection.mjs +83 -0
- package/package.json +73 -0
- package/plugins/gates/.claude-plugin/plugin.json +9 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
- package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
- package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
- package/plugins/gates/hooks/hooks.json +266 -0
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
- package/plugins/gates/hooks/lib/config.mjs +165 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
- package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
- package/plugins/tasks/hooks/register-requests.mjs +108 -0
- package/registry.json +668 -0
|
@@ -0,0 +1,99 @@
|
|
|
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 newProject({ config } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'recurrence-lock-'));
|
|
13
|
+
mkdirSync(join(project, '.git'));
|
|
14
|
+
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
15
|
+
if (config) {
|
|
16
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
return project;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function runGateIn(project, payload) {
|
|
22
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
23
|
+
input: JSON.stringify(payload),
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
cwd: project,
|
|
26
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
27
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
28
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
29
|
+
});
|
|
30
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function exec() {
|
|
34
|
+
return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
|
|
35
|
+
}
|
|
36
|
+
function isDeny(result) {
|
|
37
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('denies when an open recurrence class is at/above threshold', () => {
|
|
41
|
+
const project = newProject();
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(project, '.ai', 'reincidencias.json'),
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
classes: [
|
|
46
|
+
{ class: 'duplicate-catch', occurrences: [1, 2], status: 'open' },
|
|
47
|
+
],
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
assert.ok(isDeny(runGateIn(project, exec())));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('allows when the recurrence class is closed', () => {
|
|
54
|
+
const project = newProject();
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(project, '.ai', 'reincidencias.json'),
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
classes: [
|
|
59
|
+
{ class: 'duplicate-catch', occurrences: [1, 2], status: 'closed' },
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
assert.equal(runGateIn(project, exec()), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('degrades to allow when the recurrence file does not exist', () => {
|
|
67
|
+
const project = newProject();
|
|
68
|
+
assert.equal(runGateIn(project, exec()), null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('disabled by config: the gate does not run', () => {
|
|
72
|
+
const project = newProject({
|
|
73
|
+
config: { gates: { blockRegisteredRecurrences: false } },
|
|
74
|
+
});
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(project, '.ai', 'reincidencias.json'),
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(runGateIn(project, exec()), null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('thresholdAppearances override changes the trigger point', () => {
|
|
85
|
+
const project = newProject({
|
|
86
|
+
config: {
|
|
87
|
+
gates: {
|
|
88
|
+
blockRegisteredRecurrences: { enabled: true, thresholdAppearances: 3 },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
writeFileSync(
|
|
93
|
+
join(project, '.ai', 'reincidencias.json'),
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }],
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
assert.equal(runGateIn(project, exec()), null);
|
|
99
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// reuse-before-build — do not reinvent the wheel. Before building a new script, gate,
|
|
2
|
+
// verifier or generic helper, consult the per-project tool map: a record of what already
|
|
3
|
+
// solves a given need, local first, cloud second. This gate is the deterministic half —
|
|
4
|
+
// it consults the map and objects when a build is starting without evidence of a check.
|
|
5
|
+
// It never reaches the network: the cloud search (npm, marketplaces) is done by the
|
|
6
|
+
// assistant or the `claude-gates tool-map` CLI, so the hook stays self-contained.
|
|
7
|
+
//
|
|
8
|
+
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
9
|
+
// toolMapFile path to the per-project tool map, relative to the project root.
|
|
10
|
+
// Default .ai/tool-map.json.
|
|
11
|
+
//
|
|
12
|
+
// ── How it decides (deterministic, no judgment) ─────────────────────────────────────
|
|
13
|
+
// A write that creates an executable tool (by extension) inside a tools folder, or a
|
|
14
|
+
// delegation prompt that asks to build one, must carry evidence that the wheel was
|
|
15
|
+
// checked: either an audit phrase in the text, or the need already recorded in the tool
|
|
16
|
+
// map. Absent both, the gate blocks with an actionable message — consult the map, then
|
|
17
|
+
// (if truly absent) search local→cloud and record the result with the CLI.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, extname, join } from 'node:path';
|
|
21
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
22
|
+
|
|
23
|
+
const GATE_ID = 'reuse-before-build';
|
|
24
|
+
const CONFIG_KEY = 'requireReuseCheckBeforeBuilding';
|
|
25
|
+
|
|
26
|
+
const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
|
|
27
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TOOL_MAP_FILE = join('.ai', 'tool-map.json');
|
|
30
|
+
const EXECUTABLE_EXTENSIONS = new Set([
|
|
31
|
+
'.js',
|
|
32
|
+
'.mjs',
|
|
33
|
+
'.cjs',
|
|
34
|
+
'.ts',
|
|
35
|
+
'.py',
|
|
36
|
+
'.sh',
|
|
37
|
+
'.ps1',
|
|
38
|
+
]);
|
|
39
|
+
const TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/', 'gates/'];
|
|
40
|
+
// A map word must be this long to count as a real signal (skip "the", "csv" is 3 but rare).
|
|
41
|
+
const MIN_SIGNIFICANT_WORD_LENGTH = 4;
|
|
42
|
+
|
|
43
|
+
// A verb of creation next to a tool noun. Deliberately narrow: "write a report" or
|
|
44
|
+
// "create a folder" do not match because the noun is not one of these.
|
|
45
|
+
const BUILD_INTENT_PATTERN =
|
|
46
|
+
/\b(write|create|build|implement|add)\s+(a |the )?(new )?(script|verifier|gate|hook|linter|checker|tool)\b/i;
|
|
47
|
+
// Evidence the wheel was already checked: any phrase stating the audit result.
|
|
48
|
+
const AUDIT_DONE_PATTERN =
|
|
49
|
+
/(already exists|no existing tool|there is no plugin|audited|checked whether|nothing does this|justification:)/i;
|
|
50
|
+
|
|
51
|
+
function projectRootOf(startDirectory) {
|
|
52
|
+
let current = startDirectory;
|
|
53
|
+
while (true) {
|
|
54
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
55
|
+
const parent = dirname(current);
|
|
56
|
+
if (parent === current) return null;
|
|
57
|
+
current = parent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the tool map already records a need matching the given text. The map is a JSON
|
|
63
|
+
* blob; a cheap lowercased contains-check over the significant words of the build target
|
|
64
|
+
* is enough — the gate does not parse the map's shape, so the CLI is free to evolve it.
|
|
65
|
+
* A word of 4+ chars found in the map counts as "this need is already tracked".
|
|
66
|
+
*/
|
|
67
|
+
function needRecordedInMap(startDirectory, toolMapFile, targetText) {
|
|
68
|
+
const root = projectRootOf(startDirectory);
|
|
69
|
+
if (!root) return false;
|
|
70
|
+
const path = join(root, toolMapFile);
|
|
71
|
+
if (!existsSync(path)) return false;
|
|
72
|
+
let map;
|
|
73
|
+
try {
|
|
74
|
+
map = readFileSync(path, 'utf8').toLowerCase();
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const words = targetText
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.split(/[^a-z0-9]+/)
|
|
81
|
+
.filter((word) => word.length >= MIN_SIGNIFICANT_WORD_LENGTH);
|
|
82
|
+
return words.some((word) => map.includes(word));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isExecutableToolPath(filePath) {
|
|
86
|
+
const inToolFolder = TOOL_FOLDERS.some((folder) =>
|
|
87
|
+
filePath.replace(/\\/g, '/').includes(folder),
|
|
88
|
+
);
|
|
89
|
+
return (
|
|
90
|
+
inToolFolder && EXECUTABLE_EXTENSIONS.has(extname(filePath).toLowerCase())
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DENY_MESSAGE =
|
|
95
|
+
'Do not reinvent the wheel. Before building this, consult the project tool map and ' +
|
|
96
|
+
'confirm nothing already covers it, auditing in this order: (1) LOCAL — the repo and ' +
|
|
97
|
+
'installed deps (search by name and usage, read the manifest); (2) CONTEXT7 — resolve ' +
|
|
98
|
+
'the candidate library and read its docs to confirm whether it truly covers the need; ' +
|
|
99
|
+
'(3) WEB — search npm and plugin marketplaces for a maintained package. If it is ' +
|
|
100
|
+
'genuinely absent, record the finding (e.g. `claude-gates tool-map add`) and state the ' +
|
|
101
|
+
'audit in the text, so the exploration is not repeated next time.';
|
|
102
|
+
|
|
103
|
+
/** A build is allowed when its text declares an audit or the need is already in the map. */
|
|
104
|
+
function buildIsCleared(text, cwd, toolMapFile) {
|
|
105
|
+
return (
|
|
106
|
+
AUDIT_DONE_PATTERN.test(text) || needRecordedInMap(cwd, toolMapFile, text)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Delegation: block a build-a-tool prompt that is not cleared. */
|
|
111
|
+
function checkDelegation(toolInput, cwd, toolMapFile) {
|
|
112
|
+
const prompt = String(toolInput.prompt ?? toolInput.description ?? '');
|
|
113
|
+
if (!BUILD_INTENT_PATTERN.test(prompt)) return;
|
|
114
|
+
if (buildIsCleared(prompt, cwd, toolMapFile)) return;
|
|
115
|
+
deny(GATE_ID, DENY_MESSAGE);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write: block a new executable tool that is not cleared by its content or the map. */
|
|
119
|
+
function checkWrite(toolInput, cwd, toolMapFile) {
|
|
120
|
+
const rawPath = String(
|
|
121
|
+
toolInput.file_path ?? toolInput.target_file ?? toolInput.path ?? '',
|
|
122
|
+
);
|
|
123
|
+
const filePath = rawPath.replace(/\\/g, '/');
|
|
124
|
+
if (!isExecutableToolPath(filePath)) return;
|
|
125
|
+
// Editing an EXISTING file is not building a new tool — only creation triggers the audit.
|
|
126
|
+
// A file already on disk is an edit, so it is allowed (lets the gates maintain themselves).
|
|
127
|
+
if (rawPath && existsSync(rawPath)) return;
|
|
128
|
+
const content = String(toolInput.content ?? toolInput.CodeContent ?? '');
|
|
129
|
+
if (AUDIT_DONE_PATTERN.test(content)) return;
|
|
130
|
+
if (needRecordedInMap(cwd, toolMapFile, filePath)) return;
|
|
131
|
+
deny(GATE_ID, DENY_MESSAGE);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
runGate(
|
|
135
|
+
{
|
|
136
|
+
id: GATE_ID,
|
|
137
|
+
configKey: CONFIG_KEY,
|
|
138
|
+
enabledByDefault: false,
|
|
139
|
+
defaultParams: { toolMapFile: DEFAULT_TOOL_MAP_FILE },
|
|
140
|
+
},
|
|
141
|
+
({ toolName, toolInput, parameters }) => {
|
|
142
|
+
const toolMapFile = parameters.toolMapFile ?? DEFAULT_TOOL_MAP_FILE;
|
|
143
|
+
const cwd = process.cwd();
|
|
144
|
+
if (DELEGATION_TOOLS.has(toolName)) {
|
|
145
|
+
checkDelegation(toolInput, cwd, toolMapFile);
|
|
146
|
+
} else if (WRITE_TOOLS.has(toolName)) {
|
|
147
|
+
checkWrite(toolInput, cwd, toolMapFile);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
);
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
// Runs the gate as a child process inside a temp project (with .git so config/root resolve).
|
|
12
|
+
// The gate is off by default, so config enables it unless a test overrides that. An optional
|
|
13
|
+
// tool map is seeded. Returns parsed stdout or null when it allowed.
|
|
14
|
+
function runGate(payload, { config, toolMap } = {}) {
|
|
15
|
+
const project = mkdtempSync(join(tmpdir(), 'reuse-before-build-'));
|
|
16
|
+
mkdirSync(join(project, '.git'));
|
|
17
|
+
mkdirSync(join(project, '.ai'));
|
|
18
|
+
const effectiveConfig = config ?? {
|
|
19
|
+
gates: { requireReuseCheckBeforeBuilding: true },
|
|
20
|
+
};
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(project, '.ai', 'config.json'),
|
|
23
|
+
JSON.stringify(effectiveConfig),
|
|
24
|
+
);
|
|
25
|
+
if (toolMap) {
|
|
26
|
+
writeFileSync(
|
|
27
|
+
join(project, '.ai', 'tool-map.json'),
|
|
28
|
+
JSON.stringify(toolMap),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
32
|
+
input: JSON.stringify(payload),
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
cwd: project,
|
|
35
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
36
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
37
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
38
|
+
});
|
|
39
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeTool(content) {
|
|
43
|
+
return {
|
|
44
|
+
tool_name: 'Write',
|
|
45
|
+
tool_input: { file_path: 'scripts/csv-parser.mjs', content },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function delegate(prompt) {
|
|
49
|
+
return { tool_name: 'Agent', tool_input: { prompt } };
|
|
50
|
+
}
|
|
51
|
+
function isDeny(result) {
|
|
52
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('denies building a new tool without an audit', () => {
|
|
56
|
+
assert.ok(isDeny(runGate(writeTool('export function parse() {}'))));
|
|
57
|
+
assert.ok(
|
|
58
|
+
isDeny(
|
|
59
|
+
runGate(delegate('Please write a new script that parses CSV files.')),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('allows when the text declares an audit was done', () => {
|
|
65
|
+
assert.equal(
|
|
66
|
+
runGate(
|
|
67
|
+
writeTool('// Justification: no existing tool covers this.\nexport {}'),
|
|
68
|
+
),
|
|
69
|
+
null,
|
|
70
|
+
);
|
|
71
|
+
assert.equal(
|
|
72
|
+
runGate(
|
|
73
|
+
delegate('Write a new script for CSV — I audited and nothing does this.'),
|
|
74
|
+
),
|
|
75
|
+
null,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('allows when the need is already recorded in the tool map', () => {
|
|
80
|
+
const toolMap = { tools: [{ path: 'scripts/csv-parser.mjs', audit: 'x' }] };
|
|
81
|
+
assert.equal(runGate(writeTool('export {}'), { toolMap }), null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('ignores non-tool writes (a plain markdown file)', () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
runGate({
|
|
87
|
+
tool_name: 'Write',
|
|
88
|
+
tool_input: { file_path: 'docs/readme.md', content: 'hello' },
|
|
89
|
+
}),
|
|
90
|
+
null,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('disabled by config: the gate does not run', () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
runGate(writeTool('export {}'), {
|
|
97
|
+
config: { gates: { requireReuseCheckBeforeBuilding: false } },
|
|
98
|
+
}),
|
|
99
|
+
null,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// risk-level — denies an implementation delegation that does not declare its risk
|
|
2
|
+
// LEVEL (QUESTION|MICRO|STANDARD|HIGH-RISK), and denies a declared level that
|
|
3
|
+
// contradicts a real high-impact signal in the prompt itself (anything but HIGH-RISK).
|
|
4
|
+
//
|
|
5
|
+
// ── What this does and does not decide ──────────────────────────────────────────────
|
|
6
|
+
// Classifying a task correctly between QUESTION/MICRO/STANDARD stays the delegator's
|
|
7
|
+
// judgment — this gate cannot tell whether STANDARD was the "right" level for a task
|
|
8
|
+
// with no detectable high-impact signal. The only deterministic thing it can and does
|
|
9
|
+
// impose: the declaration EXISTS, and it does not contradict a real, non-quoted signal
|
|
10
|
+
// of high risk. The level vocabulary itself (the four tokens) is a fixed base, not a
|
|
11
|
+
// project param — only which signals count as high-impact, and which subagents are
|
|
12
|
+
// exempt, are configurable.
|
|
13
|
+
//
|
|
14
|
+
// ── Same two-stage intent pattern as intent-flow ────────────────────────────────────
|
|
15
|
+
// Stage 1: cheap lexical prefilter (implementation verb + high-impact signal present
|
|
16
|
+
// anywhere). Stage 2: strip quoted/templated text, require near co-occurrence between
|
|
17
|
+
// the verb and the signal, discard a verb whose object is a documentary deliverable.
|
|
18
|
+
// Over-declaring HIGH-RISK is never penalized — this gate only catches under-declaring.
|
|
19
|
+
|
|
20
|
+
import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
|
|
21
|
+
|
|
22
|
+
const GATE_ID = 'risk-level';
|
|
23
|
+
const CONFIG_KEY = 'requireDeclaredRiskLevel';
|
|
24
|
+
|
|
25
|
+
const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
|
|
26
|
+
|
|
27
|
+
const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
|
|
28
|
+
const DEFAULT_HIGH_IMPACT_PATTERNS = [
|
|
29
|
+
'dinero|pago|cobro|money|payment',
|
|
30
|
+
'auth|autenticaci[oó]n|authentication|permiso|permission|credencial|credential|token|' +
|
|
31
|
+
'sesi[oó]n|session|seguridad|security|PII',
|
|
32
|
+
'borrar|delete|drop|migraci[oó]n|migration|schema',
|
|
33
|
+
'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const CO_OCCURRENCE_WINDOW = 100;
|
|
37
|
+
const VERB_OBJECT_WINDOW = 60;
|
|
38
|
+
// How much of an over-length prompt to quote back in a denial message.
|
|
39
|
+
const PROMPT_EXCERPT_LENGTH = 80;
|
|
40
|
+
|
|
41
|
+
function withUnicodeWordBoundary(alternatives) {
|
|
42
|
+
return new RegExp(
|
|
43
|
+
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
44
|
+
'iu',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
|
|
49
|
+
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
50
|
+
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
|
|
51
|
+
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
52
|
+
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
53
|
+
'escrib(í|e)|escribir|implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy|write',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const QUERY_VERBS = withUnicodeWordBoundary(
|
|
57
|
+
'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
|
|
58
|
+
'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
|
|
59
|
+
'explain|what|how|show|analyze|investigate|review|audit|diagnose',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
|
|
63
|
+
'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
|
|
64
|
+
'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
|
|
65
|
+
);
|
|
66
|
+
const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
|
|
67
|
+
|
|
68
|
+
/** Declared LEVEL: one of the four fixed tokens, near the word "level"/"classification"
|
|
69
|
+
* (in Spanish or English) so a stray mention elsewhere in the prompt is not mistaken
|
|
70
|
+
* for a declaration. */
|
|
71
|
+
const DECLARED_LEVEL_PATTERN =
|
|
72
|
+
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO|STANDARD|HIGH-RISK)\b/iu;
|
|
73
|
+
|
|
74
|
+
function isReadOnlySubagent(toolInput, readOnlySubagents) {
|
|
75
|
+
const type = String(
|
|
76
|
+
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
77
|
+
).toLowerCase();
|
|
78
|
+
return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isImplementationRequest(prompt) {
|
|
82
|
+
return IMPLEMENTATION_VERBS.test(prompt);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isExemptQuery(prompt) {
|
|
86
|
+
if (isImplementationRequest(prompt)) return false;
|
|
87
|
+
return QUERY_VERBS.test(prompt);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stripQuoted(text) {
|
|
91
|
+
return text
|
|
92
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
93
|
+
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
94
|
+
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function allMatches(pattern, text) {
|
|
98
|
+
const flags = pattern.flags.includes('g')
|
|
99
|
+
? pattern.flags
|
|
100
|
+
: `${pattern.flags}g`;
|
|
101
|
+
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function verbProducesDocument(text, verbMatch) {
|
|
105
|
+
const from = verbMatch.index;
|
|
106
|
+
const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
|
|
107
|
+
const objectOfVerb = text.slice(from, to);
|
|
108
|
+
return (
|
|
109
|
+
DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
|
|
110
|
+
DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns the matched high-impact signal text if real (non-quoted) intent survives
|
|
115
|
+
* stage 2, else null. Returns null immediately when stage 1 finds no candidate. */
|
|
116
|
+
function realHighImpactSignal(prompt, highImpactPattern) {
|
|
117
|
+
const isCandidate =
|
|
118
|
+
IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
|
|
119
|
+
if (!isCandidate) return null;
|
|
120
|
+
|
|
121
|
+
const text = stripQuoted(prompt);
|
|
122
|
+
const verbs = allMatches(IMPLEMENTATION_VERBS, text);
|
|
123
|
+
const signals = allMatches(highImpactPattern, text);
|
|
124
|
+
|
|
125
|
+
for (const verb of verbs) {
|
|
126
|
+
if (verbProducesDocument(text, verb)) continue;
|
|
127
|
+
for (const signal of signals) {
|
|
128
|
+
if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
|
|
129
|
+
return signal[0];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function declaredLevel(prompt) {
|
|
136
|
+
const match = DECLARED_LEVEL_PATTERN.exec(prompt);
|
|
137
|
+
return match ? match[2].toUpperCase() : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function denyNoLevelDeclared(prompt) {
|
|
141
|
+
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
142
|
+
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
143
|
+
deny(
|
|
144
|
+
GATE_ID,
|
|
145
|
+
`This implementation delegation ("${excerpt}${ellipsis}") does not declare its risk LEVEL. Add a ` +
|
|
146
|
+
'line such as "LEVEL: STANDARD" (or QUESTION/MICRO/HIGH-RISK, whichever fits) before relaunching ' +
|
|
147
|
+
'this delegation.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function denyLevelContradictsSignal(level, signal) {
|
|
152
|
+
deny(
|
|
153
|
+
GATE_ID,
|
|
154
|
+
`This delegation declares LEVEL: ${level}, but the request touches the risk signal "${signal}" near ` +
|
|
155
|
+
'an implementation verb (not quoted, not the topic of a documentary deliverable) — that requires ' +
|
|
156
|
+
`LEVEL: HIGH-RISK, not ${level}. Raise the declaration to HIGH-RISK before relaunching.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildHighImpactPattern(highImpactPatterns) {
|
|
161
|
+
return new RegExp(
|
|
162
|
+
(highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
|
|
163
|
+
'iu',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
runGate(
|
|
168
|
+
{
|
|
169
|
+
id: GATE_ID,
|
|
170
|
+
configKey: CONFIG_KEY,
|
|
171
|
+
enabledByDefault: false,
|
|
172
|
+
defaultParams: {
|
|
173
|
+
highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
|
|
174
|
+
readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
({ toolName, toolInput, parameters }) => {
|
|
178
|
+
if (!DELEGATION_TOOLS.has(toolName)) return;
|
|
179
|
+
|
|
180
|
+
const prompt = String(
|
|
181
|
+
toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
|
|
182
|
+
);
|
|
183
|
+
if (!prompt.trim()) return;
|
|
184
|
+
if (isReadOnlySubagent(toolInput, parameters.readOnlySubagents)) return;
|
|
185
|
+
if (isExemptQuery(prompt)) return;
|
|
186
|
+
if (!isImplementationRequest(prompt)) return;
|
|
187
|
+
|
|
188
|
+
const highImpactPattern = buildHighImpactPattern(
|
|
189
|
+
parameters.highImpactPatterns,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const level = declaredLevel(prompt);
|
|
193
|
+
const signal = realHighImpactSignal(prompt, highImpactPattern);
|
|
194
|
+
|
|
195
|
+
if (!level) {
|
|
196
|
+
denyNoLevelDeclared(prompt.trim());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (signal && level !== 'HIGH-RISK') {
|
|
200
|
+
denyLevelContradictsSignal(level, signal);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
);
|
|
@@ -0,0 +1,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('allows a read-only subagent without a declared level', () => {
|
|
87
|
+
assert.equal(
|
|
88
|
+
runGate(
|
|
89
|
+
delegate(
|
|
90
|
+
'Implementa el cobro del pago con la nueva pasarela de dinero.',
|
|
91
|
+
{
|
|
92
|
+
subagent_type: 'explore',
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
ENABLED,
|
|
96
|
+
),
|
|
97
|
+
null,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('disabled by config: the gate does not run', () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
runGate(delegate('Arregla el boton que no cambia de color en la pagina.'), {
|
|
104
|
+
config: { gates: { requireDeclaredRiskLevel: false } },
|
|
105
|
+
}),
|
|
106
|
+
null,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('project highImpactPatterns override narrows what forces HIGH-RISK', () => {
|
|
111
|
+
const config = {
|
|
112
|
+
gates: {
|
|
113
|
+
requireDeclaredRiskLevel: {
|
|
114
|
+
enabled: true,
|
|
115
|
+
highImpactPatterns: ['pagin[ao]ci[oó]n'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const prompt =
|
|
120
|
+
'NIVEL: MICRO\n\nImplementa el cobro del pago con la nueva pasarela de dinero.';
|
|
121
|
+
// With the default patterns (money/payment), MICRO contradicts the signal and is denied.
|
|
122
|
+
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
123
|
+
// Once overridden, "dinero/pago" no longer counts as high-impact, so MICRO is allowed.
|
|
124
|
+
assert.equal(runGate(delegate(prompt), { config }), null);
|
|
125
|
+
});
|