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