@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,100 +0,0 @@
1
- // Edge-case probes for mandatory-flow. Previously: (1) the prompt was read only from
2
- // prompt/Prompt/task, so a Task delegation carrying its brief in `description`
3
- // bypassed the check (shared root cause with implementation-pipeline, test-matrix,
4
- // sdd-specs delegation check); (2) the ACTIVA pointer's slug was used unsanitized in
5
- // join(cwd, '.ai', 'pipeline', slug), so a slug with '..' segments let
6
- // hasTaskContract() resolve outside .ai/pipeline/ and accept an unrelated file
7
- // anywhere on disk as the active task's contract. Both are now fixed:
8
- // delegationPromptOf() reads description too, and readSlug() rejects any slug
9
- // containing a path separator or a '.'/'..' segment, treating it as empty (which
10
- // denies the same way a missing slug does).
11
- import assert from 'node:assert/strict';
12
- import { execFileSync } from 'node:child_process';
13
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
14
- import { tmpdir } from 'node:os';
15
- import { dirname, join } from 'node:path';
16
- import { test } from 'node:test';
17
- import { fileURLToPath } from 'node:url';
18
-
19
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
20
-
21
- function makeProject() {
22
- const project = mkdtempSync(join(tmpdir(), 'mandatory-flow-edge-'));
23
- mkdirSync(join(project, '.git'));
24
- return project;
25
- }
26
-
27
- function writeProjectConfig(project, config) {
28
- mkdirSync(join(project, '.ai'), { recursive: true });
29
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
30
- }
31
-
32
- function runGate(project, payload) {
33
- const out = execFileSync(process.execPath, [GATE], {
34
- input: JSON.stringify(payload),
35
- encoding: 'utf8',
36
- cwd: project,
37
- env: { ...process.env, HOME: project, USERPROFILE: project },
38
- });
39
- return out.trim() ? JSON.parse(out.trim()) : null;
40
- }
41
-
42
- function isDeny(result) {
43
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
44
- }
45
-
46
- function enableGate(project) {
47
- writeProjectConfig(project, { gates: { requireLiveTaskWhenImplementing: true } });
48
- }
49
-
50
- test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
51
- const project = makeProject();
52
- enableGate(project);
53
- // No ACTIVA pointer at all -- with delegationPromptOf reading `description`, this
54
- // now reaches checkLiveTask and denies for the missing pointer.
55
- const payload = {
56
- tool_name: 'Task',
57
- tool_input: {
58
- description: 'Nivel: STANDARD\nImplementá el checkout.',
59
- subagent_type: 'backend',
60
- },
61
- };
62
- assert.ok(
63
- isDeny(runGate(project, payload)),
64
- 'delegationPromptOf reads description and the no-live-task deny fires',
65
- );
66
- });
67
-
68
- test('OK: the equivalent payload using `prompt` with no ACTIVA pointer IS denied (control)', () => {
69
- const project = makeProject();
70
- enableGate(project);
71
- const payload = {
72
- tool_name: 'Task',
73
- tool_input: {
74
- prompt: 'Nivel: STANDARD\nImplementá el checkout.',
75
- subagent_type: 'backend',
76
- },
77
- };
78
- assert.ok(isDeny(runGate(project, payload)), 'control failed');
79
- });
80
-
81
- test('FIXED: a path-traversal slug in ACTIVA no longer lets an unrelated file satisfy hasTaskContract', () => {
82
- const project = makeProject();
83
- enableGate(project);
84
- mkdirSync(join(project, '.ai', 'pipeline'), { recursive: true });
85
- mkdirSync(join(project, 'evil'), { recursive: true });
86
- writeFileSync(join(project, 'evil', 'asserts.md'), 'unrelated content');
87
- writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), '../../evil');
88
- const payload = {
89
- tool_name: 'Agent',
90
- tool_input: {
91
- prompt: 'Nivel: STANDARD\nImplementá el checkout.',
92
- subagent_type: 'backend',
93
- },
94
- };
95
- assert.ok(
96
- isDeny(runGate(project, payload)),
97
- 'readSlug must reject the traversal slug (treated as empty), denying instead of ' +
98
- 'accepting the unrelated file outside .ai/pipeline/',
99
- );
100
- });
@@ -1,119 +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 makeProject() {
12
- const project = mkdtempSync(join(tmpdir(), 'mandatory-flow-'));
13
- mkdirSync(join(project, '.git'));
14
- return project;
15
- }
16
-
17
- function writeProjectConfig(project, config) {
18
- mkdirSync(join(project, '.ai'), { recursive: true });
19
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
20
- }
21
-
22
- function runGate(project, payload) {
23
- const out = execFileSync(process.execPath, [GATE], {
24
- input: JSON.stringify(payload),
25
- encoding: 'utf8',
26
- cwd: project,
27
- // Isolate from the user's real global config: point homedir() at the temp
28
- // project so the global-config fallback finds nothing (registry default).
29
- env: { ...process.env, HOME: project, USERPROFILE: project },
30
- });
31
- return out.trim() ? JSON.parse(out.trim()) : null;
32
- }
33
-
34
- function delegate(prompt, subagentType = 'backend') {
35
- return {
36
- tool_name: 'Agent',
37
- tool_input: { prompt, subagent_type: subagentType },
38
- };
39
- }
40
-
41
- function isDeny(result) {
42
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
43
- }
44
-
45
- function enableGate(project) {
46
- writeProjectConfig(project, {
47
- gates: { requireLiveTaskWhenImplementing: true },
48
- });
49
- }
50
-
51
- test('disabled by default: no pointer file needed', () => {
52
- const project = makeProject();
53
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
54
- assert.equal(runGate(project, delegate(prompt)), null);
55
- });
56
-
57
- test('denies implementing when no active-task pointer exists on disk', () => {
58
- const project = makeProject();
59
- enableGate(project);
60
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
61
- assert.ok(isDeny(runGate(project, delegate(prompt))));
62
- });
63
-
64
- test('allows implementing when the active task has a contract on disk', () => {
65
- const project = makeProject();
66
- enableGate(project);
67
- mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
68
- recursive: true,
69
- });
70
- writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
71
- writeFileSync(
72
- join(project, '.ai', 'pipeline', 'checkout-flow', 'asserts.md'),
73
- '# Asserts\n\nCriterio: cobrar bien.',
74
- );
75
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
76
- assert.equal(runGate(project, delegate(prompt)), null);
77
- });
78
-
79
- test('denies when the pointer names a task with no contract file', () => {
80
- const project = makeProject();
81
- enableGate(project);
82
- mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
83
- recursive: true,
84
- });
85
- writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
86
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
87
- assert.ok(isDeny(runGate(project, delegate(prompt))));
88
- });
89
-
90
- test('exempt subagent type (qa) is never held to the live-task requirement', () => {
91
- const project = makeProject();
92
- enableGate(project);
93
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
94
- assert.equal(runGate(project, delegate(prompt, 'qa')), null);
95
- });
96
-
97
- test('activePointerPath param override points the gate at a custom location', () => {
98
- const project = makeProject();
99
- writeProjectConfig(project, {
100
- gates: {
101
- requireLiveTaskWhenImplementing: {
102
- enabled: true,
103
- activePointerPath: join('.custom', 'ACTIVE'),
104
- },
105
- },
106
- });
107
- mkdirSync(join(project, '.ai', 'pipeline', 'checkout-flow'), {
108
- recursive: true,
109
- });
110
- writeFileSync(join(project, '.ai', 'pipeline', 'ACTIVA'), 'checkout-flow');
111
- writeFileSync(
112
- join(project, '.ai', 'pipeline', 'checkout-flow', 'asserts.md'),
113
- 'Criterio: cobrar bien.',
114
- );
115
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
116
- // the default pointer exists and is well-formed, but the override points elsewhere,
117
- // which does not exist: the gate must deny, proving the param actually took effect.
118
- assert.ok(isDeny(runGate(project, delegate(prompt))));
119
- });
@@ -1,80 +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(), 'neutral-spanish-edge-'));
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
- env: { ...process.env, HOME: project, USERPROFILE: project },
23
- });
24
- return out.trim() ? JSON.parse(out.trim()) : null;
25
- }
26
-
27
- function isWarn(result) {
28
- return result?.hookSpecificOutput?.additionalContext !== undefined;
29
- }
30
-
31
- // FIXED: extractText() now gates on toolInGroups(toolName, ['write']) and reads through
32
- // writtenContentOf() (lib/hook-io.mjs), which covers NotebookEdit's new_source field.
33
- test('FIXED: NotebookEdit carrying regional Spanish is detected', () => {
34
- const payload = {
35
- tool_name: 'NotebookEdit',
36
- tool_input: {
37
- notebook_path: '/repo/notebook.ipynb',
38
- cell_id: 'abc',
39
- new_source: 'vos tenés que revisar esto, dale',
40
- cell_type: 'markdown',
41
- },
42
- };
43
- assert.ok(isWarn(runGate(payload)), 'gate now detects regional Spanish written via NotebookEdit');
44
- });
45
-
46
- // FIXED: write_to_file is recognized by toolInGroups(toolName, ['write']) (native name in
47
- // TOOL_GROUPS.write) and its `content` field is read through writtenContentOf().
48
- test('FIXED: write_to_file with regional Spanish content is detected', () => {
49
- const payload = {
50
- tool_name: 'write_to_file',
51
- tool_input: {
52
- file_path: '/repo/notes.md',
53
- content: 'che, mirá el laburo que hicimos acá',
54
- },
55
- };
56
- assert.ok(isWarn(runGate(payload)), 'gate now detects regional Spanish written via write_to_file');
57
- });
58
-
59
- // EDGE CASE 3: false-positive check on legitimate neutral words that are substrings-adjacent
60
- // to markers. "sos" (voseo for "you are") is in the marker list; verify a word that merely
61
- // CONTAINS "sos" as a substring (e.g. "sospecha", "hermosos") is NOT flagged, since the
62
- // pattern uses \p{L} boundaries.
63
- test('OK: word boundary prevents false positive on words containing a marker as substring', () => {
64
- const result = runGate({
65
- tool_name: 'Write',
66
- tool_input: { file_path: '/repo/notes.md', content: 'Tengo una sospecha sobre estos hermosos resultados.' },
67
- });
68
- assert.equal(result, null, 'false positive: "sos" matched inside sospecha/hermosos');
69
- });
70
-
71
- // EDGE CASE 4: "dale" is in the marker list as Rioplatense slang, but is this over-broad?
72
- // "dale" only appears as that exact word — check it correctly fires only for that token,
73
- // not, e.g., as part of "dalear" or similar. This confirms no over-matching regression.
74
- test('OK: marker "dale" does not fire on a word merely containing it', () => {
75
- const result = runGate({
76
- tool_name: 'Write',
77
- tool_input: { file_path: '/repo/notes.md', content: 'Actualiza el dataleer o el dalext.' },
78
- });
79
- assert.equal(result, null, 'false positive: "dale" matched inside a longer word');
80
- });
@@ -1,65 +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(), 'neutral-spanish-'));
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 write(content) {
30
- return {
31
- tool_name: 'Write',
32
- tool_input: { file_path: '/repo/notes.md', content },
33
- };
34
- }
35
- function isWarn(result) {
36
- return result?.hookSpecificOutput?.additionalContext !== undefined;
37
- }
38
-
39
- test('warns on Rioplatense voseo and lexicon', () => {
40
- assert.ok(isWarn(runGate(write('vos tenés que revisar esto, dale'))));
41
- assert.ok(isWarn(runGate(write('che, mirá el laburo que hicimos acá'))));
42
- });
43
-
44
- test('allows neutral Spanish', () => {
45
- assert.equal(runGate(write('tienes que revisar esto, de acuerdo')), null);
46
- });
47
-
48
- test('disabled by config: the gate does not run', () => {
49
- assert.equal(
50
- runGate(write('vos tenés que revisar esto'), {
51
- config: { gates: { warnNonNeutralSpanish: false } },
52
- }),
53
- null,
54
- );
55
- });
56
-
57
- test('project regionalMarkers override replaces the built-in list', () => {
58
- const config = {
59
- gates: {
60
- warnNonNeutralSpanish: { enabled: true, regionalMarkers: ['bacano'] },
61
- },
62
- };
63
- assert.equal(runGate(write('vos tenés que revisar esto'), { config }), null);
64
- assert.ok(isWarn(runGate(write('que bacano quedo esto'), { config })));
65
- });
@@ -1,71 +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(), 'never-assume-edge-'));
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
- env: { ...process.env, HOME: project, USERPROFILE: project },
23
- });
24
- return out.trim() ? JSON.parse(out.trim()) : null;
25
- }
26
-
27
- function isWarn(result) {
28
- return result?.hookSpecificOutput?.additionalContext !== undefined;
29
- }
30
-
31
- const ENABLE = { config: { gates: { requireVerificationBeforeAssuming: true } } };
32
-
33
- // FIXED: extractContent() now reads through writtenContentOf() (lib/hook-io.mjs), which
34
- // covers NotebookEdit's new_source field, closing the tool-coverage gap this test used to
35
- // document as a bug.
36
- test('FIXED: conjecture phrasing written via NotebookEdit is detected', () => {
37
- const payload = {
38
- tool_name: 'NotebookEdit',
39
- tool_input: {
40
- notebook_path: '/repo/nb.ipynb',
41
- cell_id: 'x',
42
- new_source: '# i assume the timezone is UTC',
43
- cell_type: 'code',
44
- },
45
- };
46
- assert.ok(isWarn(runGate(payload, ENABLE)), 'gate now detects conjecture phrasing via NotebookEdit');
47
- });
48
-
49
- // EDGE CASE (BUG): 'invoke_subagent' is a declared delegation tool name (TOOL_GROUPS.delegation
50
- // in lib/hook-io.mjs) and is matched in hooks.json, but extractContent() branches on
51
- // TOOL_GROUPS.delegation.includes(toolName) which DOES include invoke_subagent — so this one
52
- // should actually work. Verify it does (a real behavioral check, not a bug) to make sure the
53
- // delegation branch itself is sound before concluding the write-side gap is the only issue.
54
- test('OK: conjecture phrasing in an invoke_subagent prompt is detected (delegation branch is sound)', () => {
55
- const payload = {
56
- tool_name: 'invoke_subagent',
57
- tool_input: { prompt: 'i assume the config is already loaded, proceed' },
58
- };
59
- assert.ok(isWarn(runGate(payload, ENABLE)));
60
- });
61
-
62
- // EDGE CASE: false-positive check — "should be" is a very broad conjecture pattern that can
63
- // match ordinary correct engineering prose describing an invariant, not an unverified guess,
64
- // e.g. "the response should be a 200 for valid input" (spec language, not conjecture).
65
- test('OK/expected-noise: "should be" fires even on spec-style invariant language (broad pattern, not a bug but a known false-positive source)', () => {
66
- const result = runGate(
67
- { tool_name: 'Write', tool_input: { file_path: '/repo/x.js', content: '// per the spec, the response should be a 200 for valid input' } },
68
- ENABLE,
69
- );
70
- assert.ok(isWarn(result), 'documents that "should be" over-fires on legitimate spec prose, not just conjecture');
71
- });
@@ -1,95 +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(), 'never-assume-'));
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 write(content) {
30
- return {
31
- tool_name: 'Write',
32
- tool_input: { file_path: '/repo/src/x.js', content },
33
- };
34
- }
35
- function isWarn(result) {
36
- return result?.hookSpecificOutput?.additionalContext !== undefined;
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- const ENABLE = {
43
- config: { gates: { requireVerificationBeforeAssuming: true } },
44
- };
45
-
46
- test('warns on conjecture phrasing', () => {
47
- assert.ok(isWarn(runGate(write('// i assume the timezone is UTC'), ENABLE)));
48
- assert.ok(isWarn(runGate(write('// probably fine to skip this'), ENABLE)));
49
- });
50
-
51
- test('never denies, only warns', () => {
52
- const result = runGate(write('// i assume this works'), ENABLE);
53
- assert.ok(isWarn(result));
54
- assert.ok(!isDeny(result));
55
- });
56
-
57
- test('allows content without conjecture phrasing', () => {
58
- assert.equal(runGate(write('const x = 1;'), ENABLE), null);
59
- });
60
-
61
- test('disabled by default (registry default is false)', () => {
62
- assert.equal(runGate(write('// i assume this works')), null);
63
- });
64
-
65
- test('warns on conjecture phrasing in Spanish', () => {
66
- assert.ok(isWarn(runGate(write('// supongo que la zona horaria es UTC'), ENABLE)));
67
- assert.ok(isWarn(runGate(write('// probablemente sea correcto omitir esto'), ENABLE)));
68
- assert.ok(isWarn(runGate(write('// deberia ser suficiente con este chequeo'), ENABLE)));
69
- });
70
-
71
- test('bilingual control: ES and EN equivalent conjecture briefs both warn', () => {
72
- const es = write('// creo que el usuario ya esta autenticado, no lo verifique');
73
- const en = write('// i assume the user is already authenticated, did not verify it');
74
- assert.ok(isWarn(runGate(es, ENABLE)));
75
- assert.ok(isWarn(runGate(en, ENABLE)));
76
- });
77
-
78
- test('allows neutral Spanish content without conjecture phrasing', () => {
79
- assert.equal(runGate(write('const contrasena = "verificada";'), ENABLE), null);
80
- });
81
-
82
- test('project conjecturePatterns override replaces the built-in list', () => {
83
- const config = {
84
- gates: {
85
- requireVerificationBeforeAssuming: {
86
- enabled: true,
87
- conjecturePatterns: ['totally made up'],
88
- },
89
- },
90
- };
91
- assert.equal(runGate(write('// i assume this works'), { config }), null);
92
- assert.ok(
93
- isWarn(runGate(write('// totally made up value here'), { config })),
94
- );
95
- });
@@ -1,109 +0,0 @@
1
- // Edge-case audit for no-blocking: bypasses and false positives.
2
- import assert from 'node:assert/strict';
3
- import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
- import { dirname, join } from 'node:path';
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
- function runGate(payload, { config } = {}) {
13
- const project = mkdtempSync(join(tmpdir(), 'no-blocking-edge-'));
14
- mkdirSync(join(project, '.git'));
15
- if (config) {
16
- mkdirSync(join(project, '.ai'));
17
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
18
- }
19
- const out = execFileSync(process.execPath, [GATE], {
20
- input: JSON.stringify(payload),
21
- encoding: 'utf8',
22
- cwd: project,
23
- env: { ...process.env, HOME: project, USERPROFILE: project },
24
- });
25
- return out.trim() ? JSON.parse(out.trim()) : null;
26
- }
27
-
28
- const ENABLED = { gates: { blockWaitingCommands: true } };
29
-
30
- function bash(command, extra = {}) {
31
- return { tool_name: 'Bash', tool_input: { command, ...extra } };
32
- }
33
- function mcpTool(name, input) {
34
- return { tool_name: name, tool_input: input };
35
- }
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- // ── BUG: MCP shell-equivalent tools are not in TOOL_GROUPS.shell/delegation.
41
- test('FIXED: an mcp shell-equivalent tool running "sleep 30" is now recognized', () => {
42
- const result = runGate(mcpTool('mcp__shell__run', { command: 'sleep 30' }), {
43
- config: ENABLED,
44
- });
45
- assert.ok(isDeny(result)); // toolInGroups classifies mcp__shell__run into the shell group
46
- });
47
-
48
- // ── BUG (false positive / evasion pairing): the WAIT-JUSTIFIED marker escape requires
49
- // NO reason validation beyond "two more tokens exist after the marker" — the reason text
50
- // itself is never checked for plausibility, so trivially defeats the intent of a
51
- // "declared, justified wait": any two words work as an unchecked excuse.
52
- test('BUG (weak justification check): any two-word text after the marker escapes the block regardless of content', () => {
53
- const result = runGate(
54
- bash('sleep 9999 # WAIT-JUSTIFIED: because yes'),
55
- { config: ENABLED },
56
- );
57
- assert.equal(result, null); // allowed: marker escape has no semantic validation, only shape
58
- });
59
-
60
- // ── BUG candidate: `timeout 30 someserver` is matched by the sleep|timeout pattern
61
- // requiring the command to START the line or follow |;&/then/do. A background-launched
62
- // dev server that is piped through `| cat` first, e.g. `npm run dev | cat`, should still
63
- // match \b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b since that's a
64
- // substring test with no anchoring requirement (unlike sleep/timeout). Confirm no bypass.
65
- test('OK: piping a dev server through another command does not bypass the dev-server pattern', () => {
66
- assert.ok(
67
- isDeny(runGate(bash('npm run dev | cat'), { config: ENABLED })),
68
- );
69
- });
70
-
71
- // ── BUG: `NOT_TAKING_THE_TURN` includes `-d\b` as a generic "detached" flag marker. This
72
- // is dangerously broad: ANY command containing a standalone `-d` token — including one
73
- // completely unrelated to backgrounding — escapes every blocking pattern. E.g. `sleep 30
74
- // -d` (nonsensical flag for sleep, but matches) or more realistically a docker/curl
75
- // command with an unrelated `-d` (data) flag combined with a blocking pattern.
76
- test('FIXED: an unrelated "-d" flag (curl -d) no longer escapes the blocking check', () => {
77
- // curl -d is "data", not detach. The bare -d escape was removed, so a genuinely foreground
78
- // sleep on the same command line is now denied despite the unrelated -d flag.
79
- const result = runGate(bash('sleep 30; curl -d "payload" http://x'), {
80
- config: ENABLED,
81
- });
82
- assert.ok(isDeny(result));
83
- });
84
-
85
- // ── BUG candidate: `npm run dev &` (trailing background operator with a space before
86
- // EOL, e.g. training whitespace) — NOT_TAKING_THE_TURN requires `&\s*$`. A trailing
87
- // newline in the JSON string (unlikely from a real shell, but a delegation prompt or a
88
- // tool that appends '\n') would still satisfy `\s*$` since \s includes \n. Not pursued
89
- // further as a distinct bug; documenting as checked.
90
- test('OK: trailing background operator followed by trailing whitespace still escapes correctly (not a bug)', () => {
91
- assert.equal(
92
- runGate(bash('npm run dev & '), { config: ENABLED }),
93
- null,
94
- );
95
- });
96
-
97
- // ── BUG candidate: PowerShell `Start-Job` / `Start-Process ... -NoNewWindow` background
98
- // forms are not recognized by NOT_TAKING_THE_TURN, but that only matters if the command
99
- // is ALSO caught by a blocking pattern in the first place. `Start-Sleep` alone, run as
100
- // `Start-Job { Start-Sleep 30 }` (already backgrounded via Start-Job) is still matched by
101
- // \bStart-Sleep\b and NOT recognized as non-blocking, causing a FALSE POSITIVE: a
102
- // genuinely backgrounded PowerShell job gets denied because its "backgrounded" wrapper
103
- // (Start-Job) is not in NOT_TAKING_THE_TURN.
104
- test('FIXED: Start-Sleep inside a Start-Job background wrapper is now allowed', () => {
105
- const result = runGate(bash('Start-Job { Start-Sleep 30 }'), {
106
- config: ENABLED,
107
- });
108
- assert.equal(result, null); // Start-Job is now recognized as a backgrounding mechanism
109
- });