@devrik-tools/claude-gates 0.2.1 → 0.3.1

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 (97) 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/stop-pending/index.mjs +6 -1
  16. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  17. package/plugins/gates/hooks/hooks.json +19 -0
  18. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  19. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  20. package/registry.json +738 -793
  21. package/cli/__tests__/config.test.mjs +0 -113
  22. package/cli/__tests__/init-flags.test.mjs +0 -111
  23. package/cli/__tests__/install.test.mjs +0 -102
  24. package/cli/__tests__/materialize.test.mjs +0 -95
  25. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  26. package/cli/__tests__/selection.test.mjs +0 -200
  27. package/cli/__tests__/task.test.mjs +0 -124
  28. package/cli/__tests__/version-consistency.test.mjs +0 -44
  29. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  30. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  31. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  32. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  33. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  34. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  35. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  36. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  37. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  38. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  39. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  40. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  41. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  42. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  43. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  46. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  47. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  48. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  49. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  50. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  51. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  52. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  53. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  54. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  55. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  56. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  57. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  58. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  59. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  60. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  61. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  62. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  63. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  64. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  65. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  66. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  67. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  68. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  69. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  70. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  71. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  72. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  73. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  74. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  75. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  76. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  77. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  78. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  79. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  81. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  82. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  83. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  84. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  85. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  86. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  87. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  88. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  89. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  90. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  91. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  92. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  93. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  95. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  96. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  97. 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
- });