@devrik-tools/claude-gates 0.2.1 → 0.3.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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  16. package/plugins/gates/hooks/hooks.json +19 -0
  17. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  18. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  19. package/registry.json +738 -793
  20. package/cli/__tests__/config.test.mjs +0 -113
  21. package/cli/__tests__/init-flags.test.mjs +0 -111
  22. package/cli/__tests__/install.test.mjs +0 -102
  23. package/cli/__tests__/materialize.test.mjs +0 -95
  24. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  25. package/cli/__tests__/selection.test.mjs +0 -200
  26. package/cli/__tests__/task.test.mjs +0 -124
  27. package/cli/__tests__/version-consistency.test.mjs +0 -44
  28. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  29. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  30. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  31. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  32. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  33. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  34. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  35. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  36. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  37. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  38. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  39. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  40. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  41. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  42. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  43. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  46. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  47. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  48. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  49. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  50. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  51. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  52. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  53. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  54. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  55. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  56. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  57. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  58. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  59. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  60. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  61. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  62. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  63. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  64. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  65. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  66. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  67. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  68. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  69. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  70. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  71. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  72. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  73. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  74. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  75. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  76. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  77. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  78. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  79. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  81. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  82. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  83. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  84. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  85. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  86. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  87. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  88. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  89. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  94. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  95. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  96. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,135 +0,0 @@
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 { DatabaseSync } from 'node:sqlite';
7
- import { test } from 'node:test';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
-
12
- // Builds a forge SQLite DB with the runs table and optional active run for a given cwd.
13
- function makeForgeDatabase(directory, activeCwd) {
14
- const databasePath = join(directory, 'forge.db');
15
- const database = new DatabaseSync(databasePath);
16
- database.exec(
17
- 'CREATE TABLE runs (id TEXT, cwd TEXT, current_phase TEXT, status TEXT)',
18
- );
19
- if (activeCwd) {
20
- database
21
- .prepare(
22
- 'INSERT INTO runs (id, cwd, current_phase, status) VALUES (?,?,?,?)',
23
- )
24
- .run('r1', activeCwd, 'build', 'active');
25
- }
26
- database.close();
27
- return databasePath;
28
- }
29
-
30
- // Runs the gate in a temp project. `adopted` writes the forge marker; `forgeDatabasePath` is
31
- // passed as the gate's param via config. HOME is isolated so the real global config is
32
- // never read. Returns the parsed deny output, or null when the gate allowed.
33
- function runGate(payload, { adopted, forgeDatabasePath, enabled = true } = {}) {
34
- const project = mkdtempSync(join(tmpdir(), 'forge-flow-'));
35
- mkdirSync(join(project, '.git'));
36
- mkdirSync(join(project, '.ai'));
37
- if (adopted) writeFileSync(join(project, '.ai', 'forge.json'), '{}');
38
- const gateEntry = { enabled };
39
- if (forgeDatabasePath) gateEntry.forgeDatabasePath = forgeDatabasePath;
40
- writeFileSync(
41
- join(project, '.ai', 'config.json'),
42
- JSON.stringify({ gates: { requireForgeRunToEdit: gateEntry } }),
43
- );
44
- const out = execFileSync(process.execPath, [GATE], {
45
- input: JSON.stringify(payload),
46
- encoding: 'utf8',
47
- cwd: project,
48
- env: { ...process.env, HOME: project, USERPROFILE: project },
49
- });
50
- return { output: out.trim() ? JSON.parse(out.trim()) : null, project };
51
- }
52
-
53
- function write(filePath) {
54
- return {
55
- tool_name: 'Write',
56
- tool_input: { file_path: filePath, content: 'x' },
57
- };
58
- }
59
- function isDeny(result) {
60
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
61
- }
62
-
63
- test('a project that did NOT adopt forge is never blocked', () => {
64
- const { output } = runGate(write('src/x.js'), { adopted: false });
65
- assert.equal(output, null);
66
- });
67
-
68
- test('adopted forge, no active run: a write is denied', () => {
69
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
70
- const databasePath = makeForgeDatabase(home); // no active run
71
- const { output } = runGate(write('src/x.js'), {
72
- adopted: true,
73
- forgeDatabasePath: databasePath,
74
- });
75
- assert.ok(isDeny(output));
76
- });
77
-
78
- test('adopted forge, active run for this project: the write is allowed', () => {
79
- // The run's cwd must equal the project root the gate resolves. Run once to learn the
80
- // project path, then rebuild the DB with that cwd — the helper makes a fresh project each
81
- // call, so we seed the DB against the project it actually created.
82
- const project = mkdtempSync(join(tmpdir(), 'forge-flow-'));
83
- mkdirSync(join(project, '.git'));
84
- mkdirSync(join(project, '.ai'));
85
- writeFileSync(join(project, '.ai', 'forge.json'), '{}');
86
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
87
- const databasePath = makeForgeDatabase(home, project); // active run whose cwd = this project
88
- writeFileSync(
89
- join(project, '.ai', 'config.json'),
90
- JSON.stringify({
91
- gates: {
92
- requireForgeRunToEdit: {
93
- enabled: true,
94
- forgeDatabasePath: databasePath,
95
- },
96
- },
97
- }),
98
- );
99
- const out = execFileSync(process.execPath, [GATE], {
100
- input: JSON.stringify(write('src/x.js')),
101
- encoding: 'utf8',
102
- cwd: project,
103
- env: { ...process.env, HOME: project, USERPROFILE: project },
104
- });
105
- assert.equal(out.trim(), '', 'allowed: no output');
106
- });
107
-
108
- test('adopted forge but DB missing: denied (no runs at all)', () => {
109
- const { output } = runGate(write('src/x.js'), {
110
- adopted: true,
111
- forgeDatabasePath: join(tmpdir(), 'does-not-exist', 'forge.db'),
112
- });
113
- assert.ok(isDeny(output));
114
- });
115
-
116
- test('disabled by config: not blocked even in an adopted project', () => {
117
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
118
- const databasePath = makeForgeDatabase(home);
119
- const { output } = runGate(write('src/x.js'), {
120
- adopted: true,
121
- forgeDatabasePath: databasePath,
122
- enabled: false,
123
- });
124
- assert.equal(output, null);
125
- });
126
-
127
- test('a read-only tool (no write/shell) is never blocked', () => {
128
- const home = mkdtempSync(join(tmpdir(), 'forge-db-'));
129
- const databasePath = makeForgeDatabase(home);
130
- const { output } = runGate(
131
- { tool_name: 'Read', tool_input: { file_path: 'src/x.js' } },
132
- { adopted: true, forgeDatabasePath: databasePath },
133
- );
134
- assert.equal(output, null);
135
- });
@@ -1,66 +0,0 @@
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
- });
@@ -1,86 +0,0 @@
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(), 'implementation-pipeline-'));
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, subagentType = 'backend') {
30
- return {
31
- tool_name: 'Agent',
32
- tool_input: { prompt, subagent_type: subagentType },
33
- };
34
- }
35
-
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- function enabledConfig(extraParameters) {
41
- return {
42
- gates: {
43
- requireImplementationPipeline: extraParameters
44
- ? { enabled: true, ...extraParameters }
45
- : true,
46
- },
47
- };
48
- }
49
-
50
- test('disabled by default: a builder delegation with no pipeline stages is allowed', () => {
51
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
52
- assert.equal(runGate(delegate(prompt)), null);
53
- });
54
-
55
- test('denies a builder delegation missing all three pipeline stages', () => {
56
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
57
- assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
58
- });
59
-
60
- test('allows a builder delegation that declares all three stages', () => {
61
- const prompt =
62
- 'Nivel: STANDARD\nImplementá el checkout. Contexto verificado: .ai/features/checkout/brief.md. ' +
63
- 'Verificación: npm test debe pasar. Revisión: el principal revisa el diff antes de cerrar.';
64
- assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
65
- });
66
-
67
- test('exempt subagent type (scout) is never held to the pipeline', () => {
68
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
69
- assert.equal(
70
- runGate(delegate(prompt, 'scout'), { config: enabledConfig() }),
71
- null,
72
- );
73
- });
74
-
75
- test('a prompt that only describes, does not order, is exempt via QUESTION level', () => {
76
- const prompt = 'Nivel: QUESTION\nExplicá cómo funciona el checkout hoy.';
77
- assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
78
- });
79
-
80
- test('builderSubagents param override holds a custom type to the pipeline', () => {
81
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
82
- const config = enabledConfig({ builderSubagents: ['custom-builder'] });
83
- assert.ok(isDeny(runGate(delegate(prompt, 'custom-builder'), { config })));
84
- // a type NOT in the overridden list is not assumed to be a builder
85
- assert.equal(runGate(delegate(prompt, 'backend'), { config }), null);
86
- });
@@ -1,96 +0,0 @@
1
- // Edge-case audit for intent-flow. Each test demonstrates a confirmed BUG or an OK.
2
- // Run: node --test intent-flow.edge.test.mjs
3
- import assert from 'node:assert/strict';
4
- import { execFileSync } from 'node:child_process';
5
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { dirname, join } from 'node:path';
8
- import { test } from 'node:test';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
12
-
13
- function runGate(payload, { config } = {}) {
14
- const project = mkdtempSync(join(tmpdir(), 'intent-flow-edge-'));
15
- mkdirSync(join(project, '.git'));
16
- if (config) {
17
- mkdirSync(join(project, '.ai'));
18
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
19
- }
20
- const out = execFileSync(process.execPath, [GATE], {
21
- input: JSON.stringify(payload),
22
- encoding: 'utf8',
23
- cwd: project,
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function isDeny(result) {
30
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
31
- }
32
-
33
- const ENABLED = { config: { gates: { requireScopeListBeforeDelegating: true } } };
34
-
35
- const SENSITIVE_NO_SCOPE =
36
- 'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.';
37
-
38
- test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
39
- const result = runGate(
40
- { tool_name: 'mcp__orchestrator__spawn_agent', tool_input: { prompt: SENSITIVE_NO_SCOPE } },
41
- ENABLED,
42
- );
43
- assert.ok(isDeny(result), 'gate now denies the sensitive-no-scope prompt under the MCP tool name too');
44
- });
45
-
46
- test('FIXED: prompt carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
47
- const result = runGate(
48
- { tool_name: 'Agent', tool_input: { instructions: SENSITIVE_NO_SCOPE } },
49
- ENABLED,
50
- );
51
- assert.ok(isDeny(result), 'gate now sees the prompt under "instructions" and denies it for missing scope list');
52
- });
53
-
54
- test('FIXED: a mutation-risk signal in the prompt overrides a whitelisted read-only subagent name', () => {
55
- // isReadOnlySubagent now voids the name-based exemption whenever the prompt itself
56
- // carries a mutation-risk signal (money/auth/data/write/deploy): a subagent named
57
- // "explore" cannot exempt a real money-mutation delegation just by using that label.
58
- const result = runGate(
59
- { tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_SCOPE, subagent_type: 'explore' } },
60
- ENABLED,
61
- );
62
- assert.ok(
63
- isDeny(result),
64
- 'gate no longer exempts a real money-mutation delegation solely because subagent_type says "explore"',
65
- );
66
- });
67
-
68
- test('OK: a whitelisted read-only subagent name with no mutation-risk signal is still exempt', () => {
69
- assert.equal(
70
- runGate(
71
- {
72
- tool_name: 'Agent',
73
- tool_input: {
74
- prompt: 'Arregla el boton que no cambia de color al pasar el mouse.',
75
- subagent_type: 'explore',
76
- },
77
- },
78
- ENABLED,
79
- ),
80
- null,
81
- );
82
- });
83
-
84
- test('OK: same sensitive prompt without a read-only subagent name is denied', () => {
85
- const result = runGate({ tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED);
86
- assert.ok(isDeny(result));
87
- });
88
-
89
- test('OK: Task and invoke_subagent tool names are both covered', () => {
90
- assert.ok(isDeny(runGate({ tool_name: 'Task', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED)));
91
- assert.ok(
92
- isDeny(
93
- runGate({ tool_name: 'invoke_subagent', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED),
94
- ),
95
- );
96
- });
@@ -1,165 +0,0 @@
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
- });
@@ -1,85 +0,0 @@
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
- });