@semalt-ai/code 1.8.4 → 1.19.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 (151) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1588 -27
  4. package/README.md +147 -3
  5. package/TECHNICAL_DEBT.md +66 -0
  6. package/examples/embed.js +74 -0
  7. package/index.js +259 -11
  8. package/lib/agent.js +935 -181
  9. package/lib/api.js +308 -55
  10. package/lib/args.js +96 -2
  11. package/lib/audit.js +23 -1
  12. package/lib/background.js +584 -0
  13. package/lib/checkpoints.js +757 -0
  14. package/lib/commands/auth.js +94 -0
  15. package/lib/commands/chat-session.js +306 -0
  16. package/lib/commands/chat-slash.js +399 -0
  17. package/lib/commands/chat-turn.js +446 -0
  18. package/lib/commands/chat.js +403 -0
  19. package/lib/commands/custom.js +157 -0
  20. package/lib/commands/history-utils.js +66 -0
  21. package/lib/commands/index.js +268 -0
  22. package/lib/commands/mcp.js +113 -0
  23. package/lib/commands/oneshot.js +193 -0
  24. package/lib/commands/registry.js +269 -0
  25. package/lib/commands/tasks.js +89 -0
  26. package/lib/compact.js +87 -0
  27. package/lib/config.js +346 -11
  28. package/lib/constants.js +372 -3
  29. package/lib/debug.js +106 -0
  30. package/lib/deny.js +199 -0
  31. package/lib/doctor.js +160 -0
  32. package/lib/headless.js +167 -0
  33. package/lib/hooks.js +286 -0
  34. package/lib/images.js +264 -0
  35. package/lib/internals.js +49 -0
  36. package/lib/mcp/boundary.js +131 -0
  37. package/lib/mcp/client.js +270 -0
  38. package/lib/mcp/oauth.js +134 -0
  39. package/lib/memory.js +209 -0
  40. package/lib/metrics.js +37 -2
  41. package/lib/payload.js +54 -0
  42. package/lib/permission-rules.js +401 -0
  43. package/lib/permissions.js +100 -10
  44. package/lib/pricing.js +67 -0
  45. package/lib/proc.js +158 -0
  46. package/lib/prompts.js +88 -8
  47. package/lib/sandbox.js +568 -0
  48. package/lib/sdk.js +328 -0
  49. package/lib/secrets.js +211 -0
  50. package/lib/skills.js +223 -0
  51. package/lib/subagents.js +516 -0
  52. package/lib/tool_registry.js +2558 -0
  53. package/lib/tool_specs.js +236 -9
  54. package/lib/tools.js +370 -944
  55. package/lib/ui/chat-history.js +19 -1
  56. package/lib/ui/format.js +101 -6
  57. package/lib/ui/input-field.js +16 -7
  58. package/lib/ui/status-bar.js +79 -11
  59. package/lib/ui/terminal.js +10 -4
  60. package/lib/ui/theme.js +1 -0
  61. package/lib/ui/web-activity.js +218 -0
  62. package/lib/ui/writer.js +7 -9
  63. package/lib/verify.js +229 -0
  64. package/lib/web-extract.js +213 -0
  65. package/lib/web-summarize.js +68 -0
  66. package/package.json +19 -4
  67. package/scripts/lint.js +57 -0
  68. package/test/agent-loop.test.js +389 -0
  69. package/test/background.test.js +414 -0
  70. package/test/chat.test.js +114 -0
  71. package/test/checkpoints-agent.test.js +181 -0
  72. package/test/checkpoints.test.js +650 -0
  73. package/test/command-registry.test.js +160 -0
  74. package/test/compact.test.js +116 -0
  75. package/test/completion-lazy.test.js +52 -0
  76. package/test/config-merge.test.js +324 -0
  77. package/test/config-quarantine.test.js +128 -0
  78. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  79. package/test/config-write-guard-skip.test.js +46 -0
  80. package/test/config-write-guard.test.js +153 -0
  81. package/test/context-split.test.js +215 -0
  82. package/test/cost-doctor.test.js +142 -0
  83. package/test/custom-commands-chat.test.js +106 -0
  84. package/test/custom-commands.test.js +230 -0
  85. package/test/deny-windows.test.js +120 -0
  86. package/test/deny.test.js +83 -0
  87. package/test/download-allow-anywhere.test.js +66 -0
  88. package/test/download-confine.test.js +153 -0
  89. package/test/executors.test.js +362 -0
  90. package/test/extract-tool-calls.test.js +315 -0
  91. package/test/fetch-url-validation.test.js +219 -0
  92. package/test/fixtures/tool-calls.js +57 -0
  93. package/test/fixtures/web-page.js +91 -0
  94. package/test/git-tools.test.js +384 -0
  95. package/test/grep-glob-serialize.test.js +242 -0
  96. package/test/grep-glob.test.js +268 -0
  97. package/test/harness/README.md +57 -0
  98. package/test/harness/chat-harness.js +142 -0
  99. package/test/harness/memwarn-headless-child.js +65 -0
  100. package/test/harness/mock-llm.js +120 -0
  101. package/test/harness/mock-mcp-server.js +142 -0
  102. package/test/harness/sse-server.js +69 -0
  103. package/test/headless.test.js +203 -0
  104. package/test/history-utils.test.js +88 -0
  105. package/test/hooks-agent.test.js +238 -0
  106. package/test/hooks-verify-sandbox.test.js +232 -0
  107. package/test/hooks.test.js +216 -0
  108. package/test/http-get-user-agent.test.js +142 -0
  109. package/test/images-api.test.js +208 -0
  110. package/test/images.test.js +238 -0
  111. package/test/max-iterations.test.js +216 -0
  112. package/test/mcp-boundary.test.js +57 -0
  113. package/test/mcp-client.test.js +267 -0
  114. package/test/mcp-oauth.test.js +86 -0
  115. package/test/memory-truncation-warning.test.js +222 -0
  116. package/test/memory.test.js +198 -0
  117. package/test/native-dispatch.test.js +356 -0
  118. package/test/output-chokepoint.test.js +188 -0
  119. package/test/path-guards.test.js +134 -0
  120. package/test/payload.test.js +99 -0
  121. package/test/permission-rules-agent.test.js +210 -0
  122. package/test/permission-rules.test.js +297 -0
  123. package/test/permissions.test.js +163 -0
  124. package/test/plan-mode.test.js +167 -0
  125. package/test/read-paginate.test.js +275 -0
  126. package/test/readonly-tools.test.js +177 -0
  127. package/test/result-cap.test.js +233 -0
  128. package/test/sandbox-agent.test.js +147 -0
  129. package/test/sandbox-integration.test.js +216 -0
  130. package/test/sandbox.test.js +408 -0
  131. package/test/sdk.test.js +234 -0
  132. package/test/shell-output-cap.test.js +181 -0
  133. package/test/skills-chat.test.js +110 -0
  134. package/test/skills.test.js +295 -0
  135. package/test/smoke.test.js +68 -0
  136. package/test/status-bar-pause.test.js +164 -0
  137. package/test/stream-parser.test.js +147 -0
  138. package/test/subagents-agent.test.js +178 -0
  139. package/test/subagents.test.js +222 -0
  140. package/test/tool-registry.test.js +85 -0
  141. package/test/trim-budget.test.js +101 -0
  142. package/test/verify-agent.test.js +317 -0
  143. package/test/verify.test.js +141 -0
  144. package/test/web-activity-ordering.test.js +194 -0
  145. package/test/web-activity.test.js +207 -0
  146. package/test/web-data-extraction-guidance.test.js +71 -0
  147. package/test/web-extract.test.js +185 -0
  148. package/test/web-fetch-agent.test.js +291 -0
  149. package/test/web-fetch-mode.test.js +193 -0
  150. package/test/web-search.test.js +380 -0
  151. package/lib/commands.js +0 -1288
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ // Task W.9 — Shared output-capping chokepoint + navigation guidance.
4
+ //
5
+ // W.5–W.8 each bounded a previously-unbounded path into context, but the capping
6
+ // was ad-hoc per path: scattered capToTokens calls + hand-built untrusted fences
7
+ // across formatGrepResult / formatGlobResult / capShellOutput / formatReadResult /
8
+ // formatMcpResult / formatSubagentResult. The original bugs (grep/glob returning
9
+ // "done", shell unbounded, MCP/subagent unbounded) were all the SAME class — a
10
+ // path that put tool output into context without bounding it. This task
11
+ // consolidates the capToTokens-+-fence step into ONE chokepoint, boundToolOutput,
12
+ // so bounding is uniform and STRUCTURAL: a new tool gets bounding by routing its
13
+ // output through the chokepoint rather than remembering to cap.
14
+ //
15
+ // These tests pin: (1) the chokepoint's behavior + per-path policy (budgets,
16
+ // notices, fence flags are NOT flattened into one); (2) the structural
17
+ // bound-by-construction invariant; (3) MODEL-FACING equivalence with W.5–W.8
18
+ // (the refactor changed nothing observable); and (4) the now-actionable
19
+ // grep-first / read-slice navigation guidance in the system prompt.
20
+
21
+ const { test } = require('node:test');
22
+ const assert = require('node:assert');
23
+
24
+ const {
25
+ boundToolOutput,
26
+ formatGrepResult, formatGlobResult, capShellOutput,
27
+ formatReadResult, formatMcpResult, formatSubagentResult,
28
+ } = require('../lib/agent');
29
+ const {
30
+ DEFAULT_MCP_MAX_RESULT_TOKENS, DEFAULT_SUBAGENT_MAX_RESULT_TOKENS,
31
+ } = require('../lib/constants');
32
+
33
+ const FENCE_OPEN = '<<<UNTRUSTED_EXTERNAL_CONTENT';
34
+ const FENCE_CLOSE = '<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Part 1 — the chokepoint helper itself
38
+ // ---------------------------------------------------------------------------
39
+
40
+ test('boundToolOutput: text under budget passes through unchanged, no truncation', () => {
41
+ const out = boundToolOutput('hello world', { budget: 10000, fenced: false });
42
+ assert.strictEqual(out.text, 'hello world');
43
+ assert.strictEqual(out.truncated, false);
44
+ });
45
+
46
+ test('boundToolOutput: over-budget text is token-capped with the SUPPLIED notice', () => {
47
+ const big = 'x'.repeat(4000); // ~1000 tokens
48
+ const out = boundToolOutput(big, {
49
+ budget: 50,
50
+ notice: ({ tokens, limit }) => `\n\n[NET ${tokens}->${limit}]`,
51
+ fenced: false,
52
+ });
53
+ assert.ok(out.truncated, 'flagged truncated');
54
+ assert.match(out.text, /\[NET \d+->50\]/, 'the caller-supplied notice is used');
55
+ assert.ok(out.text.length < big.length, 'full payload did not pass through');
56
+ });
57
+
58
+ test('boundToolOutput: fenced=true wraps in the untrusted fence; fenced=false does not', () => {
59
+ const fenced = boundToolOutput('data', { budget: 10000, fenced: true });
60
+ assert.ok(fenced.text.startsWith(FENCE_OPEN), 'opens with the fence');
61
+ assert.ok(fenced.text.trimEnd().endsWith(FENCE_CLOSE), 'closes with the fence');
62
+ assert.ok(fenced.text.includes('data'), 'content inside the fence');
63
+
64
+ const plain = boundToolOutput('data', { budget: 10000, fenced: false });
65
+ assert.strictEqual(plain.text, 'data', 'no fence when not requested');
66
+ assert.ok(!plain.text.includes(FENCE_OPEN));
67
+ });
68
+
69
+ test('STRUCTURAL invariant: output routed through the chokepoint is bounded by construction', () => {
70
+ // A "new tool" that surfaces its output via boundToolOutput cannot dump
71
+ // unbounded into context — a huge payload is capped no matter the path. This
72
+ // is the regression-prevention guarantee: bound-by-routing, not bound-by-remembering.
73
+ const huge = 'Z'.repeat(200000);
74
+ const out = boundToolOutput(huge, { budget: 100, fenced: true });
75
+ assert.ok(out.truncated, 'huge payload is bounded by construction');
76
+ assert.ok(out.text.length < huge.length);
77
+ assert.ok(out.text.includes(FENCE_OPEN), 'and still fenced when requested');
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Part 2 — per-path policy preserved (budgets / notices / fence NOT flattened)
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test('fence flag is PER PATH: MCP+subagent fenced; read/shell/grep/glob NOT fenced', () => {
85
+ assert.match(formatMcpResult({ action: 'mcp__s__t', content: 'a', maxTokens: 10000 }), /UNTRUSTED_EXTERNAL_CONTENT/);
86
+ assert.match(formatSubagentResult({ count: 1, content: 'a', maxTokens: 20000 }), /UNTRUSTED_EXTERNAL_CONTENT/);
87
+ assert.doesNotMatch(formatReadResult({ content: 'a\nb', path: '/f' }), /UNTRUSTED_EXTERNAL_CONTENT/);
88
+ assert.doesNotMatch(capShellOutput('a\nb', {}).text, /UNTRUSTED_EXTERNAL_CONTENT/);
89
+ assert.doesNotMatch(formatGrepResult({ matches: [{ file: 'a', line: 1, text: 't' }], pattern: 'p' }), /UNTRUSTED_EXTERNAL_CONTENT/);
90
+ assert.doesNotMatch(formatGlobResult({ files: ['a.ts'], pattern: '*' }), /UNTRUSTED_EXTERNAL_CONTENT/);
91
+ });
92
+
93
+ test('notice text is PER PATH (not flattened): each path emits its own wording', () => {
94
+ const big = 'x'.repeat(200000); // ~50k tokens — over every net at maxTokens=50
95
+ assert.match(formatMcpResult({ action: 'mcp__s__t', content: big, maxTokens: 50 }), /MCP result capped at/);
96
+ assert.match(formatSubagentResult({ count: 1, content: big, maxTokens: 50 }), /subagent result capped at/);
97
+ assert.match(formatReadResult({ content: 'q'.repeat(200000), path: '/f', maxTokens: 50 }), /read token-capped/);
98
+ assert.match(capShellOutput('q'.repeat(200000), { maxTokens: 50 }).text, /output token-capped/);
99
+ });
100
+
101
+ test('budgets are PER PATH: MCP (10k) is strictly stricter than subagent (20k)', () => {
102
+ assert.ok(DEFAULT_MCP_MAX_RESULT_TOKENS < DEFAULT_SUBAGENT_MAX_RESULT_TOKENS);
103
+ // Content sized between the two budgets: capped under MCP, passes under subagent.
104
+ const midTokens = Math.floor((DEFAULT_MCP_MAX_RESULT_TOKENS + DEFAULT_SUBAGENT_MAX_RESULT_TOKENS) / 2);
105
+ const content = 'z'.repeat(midTokens * 4);
106
+ assert.match(formatMcpResult({ action: 'mcp__s__t', content }), /capped at/, 'MCP caps above its stricter budget');
107
+ assert.doesNotMatch(formatSubagentResult({ count: 1, content }), /capped at/, 'subagent passes under its generous budget');
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Part 3 — MODEL-FACING equivalence with W.5–W.8 (refactor changed nothing)
112
+ // ---------------------------------------------------------------------------
113
+ //
114
+ // The fenced paths must compose as `<prefix>` + boundToolOutput(content, …): the
115
+ // prefix sits OUTSIDE the fence, the capped+fenced body is exactly the chokepoint
116
+ // output. This proves the path genuinely routes through the chokepoint.
117
+
118
+ test('equivalence: formatMcpResult == prefix + boundToolOutput(content, {fenced:true})', () => {
119
+ const content = 'payload from server';
120
+ const out = formatMcpResult({ action: 'mcp__s__t', content, maxTokens: 10000 });
121
+ const bounded = boundToolOutput(content, { budget: 10000, fenced: true });
122
+ assert.ok(out.startsWith('MCP tool mcp__s__t result:'), 'prefix outside the fence');
123
+ assert.ok(out.endsWith(bounded.text), 'body is exactly the chokepoint output');
124
+ });
125
+
126
+ test('equivalence: formatSubagentResult == prefix + boundToolOutput(content, {fenced:true})', () => {
127
+ const content = 'CHILD FINDINGS: the project is a CLI';
128
+ const out = formatSubagentResult({ count: 1, content, maxTokens: 20000 });
129
+ const bounded = boundToolOutput(content, { budget: 20000, fenced: true });
130
+ assert.ok(out.includes('Result from 1 subagent'), 'prefix outside the fence');
131
+ assert.ok(out.endsWith(bounded.text), 'body is exactly the chokepoint output');
132
+ });
133
+
134
+ test('equivalence: small grep/glob/read/shell outputs are byte-identical to W.5–W.7 (no token notice)', () => {
135
+ // grep content mode — file:line:text, no token cap notice for small results.
136
+ const grep = formatGrepResult({
137
+ matches: [{ file: 'a.js', line: 3, text: '// TODO' }],
138
+ pattern: 'TODO', output_mode: 'content',
139
+ });
140
+ assert.match(grep, /a\.js:3:\/\/ TODO/);
141
+ assert.doesNotMatch(grep, /token-capped/);
142
+
143
+ // glob — relative path list, no token cap notice.
144
+ const glob = formatGlobResult({ files: ['a.ts', 'src/b.ts'], pattern: '*.ts' });
145
+ assert.match(glob, /^a\.ts$/m);
146
+ assert.doesNotMatch(glob, /token-capped/);
147
+
148
+ // read — under the line cap the body is byte-for-byte the file content.
149
+ const read = formatReadResult({ content: 'one\ntwo\nthree', path: '/x' });
150
+ assert.strictEqual(read, 'File /x:\none\ntwo\nthree');
151
+
152
+ // shell — under the line + token caps, output passes through unchanged.
153
+ const shell = capShellOutput('line a\nline b', {});
154
+ assert.strictEqual(shell.text, 'line a\nline b');
155
+ assert.strictEqual(shell.truncated, false);
156
+ });
157
+
158
+ test('grep/glob now gain a TOKEN safety net via the chokepoint (huge matches bounded)', () => {
159
+ // Pathological: head_limit lets 100 matches through, but each is a 5000-char
160
+ // minified line — the count bound alone does NOT bound tokens (the W.6 lesson).
161
+ // The chokepoint's token net catches it. This is NOT a regression on small
162
+ // results (asserted above) — it's the structural backstop the refactor adds.
163
+ const many = [];
164
+ for (let i = 0; i < 100; i++) many.push({ file: 'min.js', line: i, text: 'q'.repeat(5000) });
165
+ const out = formatGrepResult({ matches: many, pattern: 'q', output_mode: 'content', head_limit: 100 });
166
+ assert.match(out, /grep output token-capped/, 'huge grep result is token-bounded');
167
+
168
+ const files = [];
169
+ for (let i = 0; i < 100; i++) files.push('d/'.repeat(2000) + `f${i}.ts`);
170
+ const gout = formatGlobResult({ files, pattern: '**/*.ts', head_limit: 100 });
171
+ assert.match(gout, /glob output token-capped/, 'huge glob result is token-bounded');
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Part 4 — navigation guidance (now actionable post-W.5)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ test('system prompt carries grep-first / read-slice navigation guidance (BOTH templates)', () => {
179
+ const prompts = require('../lib/prompts');
180
+ const xml = prompts.getSystemPrompt(false, '', ''); // XML template
181
+ const native = prompts.getSystemPrompt(true, '', ''); // native function-calling template
182
+ for (const [label, p] of [['xml', xml], ['native', native]]) {
183
+ assert.match(p, /locate first with .*grep/i, `${label}: grep-first locate guidance`);
184
+ assert.match(p, /count|files_with_matches/, `${label}: count/files_with_matches modes mentioned`);
185
+ assert.match(p, /start_line|end_line/, `${label}: read-slice (start_line/end_line) guidance`);
186
+ assert.match(p, /redirect/i, `${label}: redirect-large-output-to-file guidance`);
187
+ }
188
+ });
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // Characterization tests for the file-path guards isPathSafe and
4
+ // isProtectedSecretPath (Task 1.1). These read the --allow-anywhere /
5
+ // --dangerously-skip-permissions flags from process.argv once at module load;
6
+ // the test runner's argv contains neither, so both default to the safe path.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+
13
+ const { isPathSafe, isProtectedSecretPath, isProtectedConfigPath } = require('../lib/tools');
14
+ const { CONFIG_PATH, protectedConfigDirs } = require('../lib/constants');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // isPathSafe — writes are confined to CWD; system/home-secret dirs are banned.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ test('isPathSafe allows paths inside the current working directory', () => {
21
+ assert.strictEqual(isPathSafe('a.txt'), true);
22
+ assert.strictEqual(isPathSafe('./nested/dir/file.js'), true);
23
+ assert.strictEqual(isPathSafe(path.join(process.cwd(), 'x', 'y.md')), true);
24
+ });
25
+
26
+ test('isPathSafe allows the CWD itself', () => {
27
+ assert.strictEqual(isPathSafe(process.cwd()), true);
28
+ });
29
+
30
+ test('isPathSafe rejects paths outside the working tree', () => {
31
+ assert.strictEqual(isPathSafe('/tmp/outside.txt'), false);
32
+ assert.strictEqual(isPathSafe(path.join(os.homedir(), 'elsewhere.txt')), false);
33
+ });
34
+
35
+ test('isPathSafe rejects banned system directories', () => {
36
+ for (const p of ['/etc/passwd', '/boot/grub/x', '/sys/x', '/proc/1/mem']) {
37
+ assert.strictEqual(isPathSafe(p), false, `${p} must be unsafe`);
38
+ }
39
+ });
40
+
41
+ test('isPathSafe rejects sensitive home subdirectories', () => {
42
+ for (const sub of ['.ssh/id_rsa', '.aws/credentials', '.gnupg/secring']) {
43
+ assert.strictEqual(isPathSafe(path.join(os.homedir(), sub)), false, `~/${sub} must be unsafe`);
44
+ }
45
+ });
46
+
47
+ test('isPathSafe rejects non-string / empty input', () => {
48
+ assert.strictEqual(isPathSafe(''), false);
49
+ assert.strictEqual(isPathSafe(null), false);
50
+ assert.strictEqual(isPathSafe(undefined), false);
51
+ });
52
+
53
+ test('QUIRK: a sibling dir sharing the CWD name prefix is rejected', () => {
54
+ // The guard appends a path separator before prefix-matching, so `${cwd}-evil`
55
+ // does not slip through as "starts with cwd".
56
+ assert.strictEqual(isPathSafe(process.cwd() + '-evil/file'), false);
57
+ });
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // isProtectedSecretPath — config.json / memory.json / audit.log are never
61
+ // readable by the agent, regardless of where they sit.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ test('isProtectedSecretPath flags the config file', () => {
65
+ assert.strictEqual(isProtectedSecretPath(CONFIG_PATH), true);
66
+ });
67
+
68
+ test('isProtectedSecretPath flags memory.json and audit.log', () => {
69
+ const dir = path.join(os.homedir(), '.semalt-ai');
70
+ assert.strictEqual(isProtectedSecretPath(path.join(dir, 'memory.json')), true);
71
+ assert.strictEqual(isProtectedSecretPath(path.join(dir, 'audit.log')), true);
72
+ });
73
+
74
+ test('isProtectedSecretPath resolves relative/.. forms to the same target', () => {
75
+ const messy = path.join(CONFIG_PATH, '..', path.basename(CONFIG_PATH));
76
+ assert.strictEqual(isProtectedSecretPath(messy), true);
77
+ });
78
+
79
+ test('isProtectedSecretPath does not flag ordinary files', () => {
80
+ assert.strictEqual(isProtectedSecretPath('a.txt'), false);
81
+ assert.strictEqual(isProtectedSecretPath(path.join(process.cwd(), 'config.json')), false);
82
+ });
83
+
84
+ test('isProtectedSecretPath rejects non-string / empty input', () => {
85
+ assert.strictEqual(isProtectedSecretPath(''), false);
86
+ assert.strictEqual(isProtectedSecretPath(null), false);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // isProtectedConfigPath — the WRITE-side guard (Pre-Task 5.0b). The whole
91
+ // ~/.semalt-ai dir AND every project .semalt dir are non-writable by the agent,
92
+ // including not-yet-existing files. The test runner's CWD is the repo root
93
+ // (it has .git), so the project layer is <repo>/.semalt.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ test('protectedConfigDirs covers ~/.semalt-ai and the project .semalt dir', () => {
97
+ const dirs = protectedConfigDirs();
98
+ assert.ok(dirs.includes(path.join(os.homedir(), '.semalt-ai')), 'must include the user config dir');
99
+ assert.ok(dirs.includes(path.join(process.cwd(), '.semalt')), 'must include the project .semalt dir');
100
+ });
101
+
102
+ test('isProtectedConfigPath flags files anywhere under ~/.semalt-ai', () => {
103
+ const dir = path.join(os.homedir(), '.semalt-ai');
104
+ assert.strictEqual(isProtectedConfigPath(path.join(dir, 'config.json')), true);
105
+ assert.strictEqual(isProtectedConfigPath(path.join(dir, 'agents', 'r.md')), true);
106
+ assert.strictEqual(isProtectedConfigPath(dir), true, 'the dir itself is protected');
107
+ });
108
+
109
+ test('isProtectedConfigPath flags files under the project .semalt dir (incl. not-yet-existing)', () => {
110
+ const dot = path.join(process.cwd(), '.semalt');
111
+ assert.strictEqual(isProtectedConfigPath(path.join(dot, 'config.json')), true);
112
+ assert.strictEqual(isProtectedConfigPath(path.join(dot, 'agents', 'reviewer.md')), true);
113
+ assert.strictEqual(isProtectedConfigPath('.semalt/config.json'), true, 'relative form resolves to CWD/.semalt');
114
+ assert.strictEqual(isProtectedConfigPath(path.join(dot, 'hooks', 'does-not-exist.sh')), true);
115
+ });
116
+
117
+ test('isProtectedConfigPath resolves .. traversal into a protected dir', () => {
118
+ const messy = path.join(process.cwd(), 'src', '..', '.semalt', 'config.json');
119
+ assert.strictEqual(isProtectedConfigPath(messy), true);
120
+ });
121
+
122
+ test('isProtectedConfigPath does not flag ordinary files', () => {
123
+ assert.strictEqual(isProtectedConfigPath('src/app.js'), false);
124
+ assert.strictEqual(isProtectedConfigPath(path.join(process.cwd(), 'config.json')), false);
125
+ assert.strictEqual(isProtectedConfigPath(path.join(process.cwd(), 'app', 'config.json')), false);
126
+ // A sibling dir whose name merely starts with .semalt is not the config dir.
127
+ assert.strictEqual(isProtectedConfigPath(path.join(process.cwd(), '.semalt-extra', 'x')), false);
128
+ });
129
+
130
+ test('isProtectedConfigPath rejects non-string / empty input', () => {
131
+ assert.strictEqual(isProtectedConfigPath(''), false);
132
+ assert.strictEqual(isProtectedConfigPath(null), false);
133
+ assert.strictEqual(isProtectedConfigPath(undefined), false);
134
+ });
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // Payload-augmentation tests (Task 2.7): prompt caching markers and
4
+ // reasoning_effort. Pure functions are unit-tested; the wiring is verified by
5
+ // inspecting the actual request body the api client sends to the mock LLM.
6
+
7
+ const { test, before, after } = require('node:test');
8
+ const assert = require('node:assert');
9
+
10
+ const ui = require('../lib/ui');
11
+ const { createApiClient } = require('../lib/api');
12
+ const { startMockLLM } = require('./harness/mock-llm');
13
+ const {
14
+ applyPromptCaching, applyReasoningEffort, supportsReasoningEffort,
15
+ } = require('../lib/payload');
16
+
17
+ let prevKey;
18
+ before(() => { prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key'; });
19
+ after(() => { if (prevKey === undefined) delete process.env.SEMALT_API_KEY; else process.env.SEMALT_API_KEY = prevKey; });
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Pure
23
+ // ---------------------------------------------------------------------------
24
+
25
+ test('applyPromptCaching marks the last system message and last tool when enabled', () => {
26
+ const p = {
27
+ messages: [{ role: 'system', content: 'S' }, { role: 'user', content: 'u' }],
28
+ tools: [{ type: 'function', function: { name: 'a' } }, { type: 'function', function: { name: 'b' } }],
29
+ };
30
+ applyPromptCaching(p, true);
31
+ assert.deepStrictEqual(p.messages[0].cache_control, { type: 'ephemeral' });
32
+ assert.strictEqual(p.messages[1].cache_control, undefined);
33
+ assert.deepStrictEqual(p.tools[1].cache_control, { type: 'ephemeral' });
34
+ assert.strictEqual(p.tools[0].cache_control, undefined);
35
+ });
36
+
37
+ test('applyPromptCaching is a no-op when disabled', () => {
38
+ const p = { messages: [{ role: 'system', content: 'S' }], tools: [{ type: 'function', function: { name: 'a' } }] };
39
+ applyPromptCaching(p, false);
40
+ assert.strictEqual(p.messages[0].cache_control, undefined);
41
+ assert.strictEqual(p.tools[0].cache_control, undefined);
42
+ });
43
+
44
+ test('supportsReasoningEffort matches reasoning model families only', () => {
45
+ for (const m of ['o3-mini', 'o1-preview', 'gpt-5', 'deepseek-r1', 'qwq-32b', 'some-reasoning-model']) {
46
+ assert.strictEqual(supportsReasoningEffort(m), true, m);
47
+ }
48
+ for (const m of ['gpt-4o', 'llama-3', 'claude-3-5-sonnet', '']) {
49
+ assert.strictEqual(supportsReasoningEffort(m), false, m);
50
+ }
51
+ });
52
+
53
+ test('applyReasoningEffort sets the field only for supported models / valid effort', () => {
54
+ const a = {}; applyReasoningEffort(a, 'high', 'o3-mini'); assert.strictEqual(a.reasoning_effort, 'high');
55
+ const b = {}; applyReasoningEffort(b, 'high', 'gpt-4o'); assert.strictEqual(b.reasoning_effort, undefined);
56
+ const c = {}; applyReasoningEffort(c, 'bogus', 'o3-mini'); assert.strictEqual(c.reasoning_effort, undefined);
57
+ const d = {}; applyReasoningEffort(d, 'low', 'gpt-4o', { force: true }); assert.strictEqual(d.reasoning_effort, 'low');
58
+ const e = {}; applyReasoningEffort(e, '', 'o3-mini'); assert.strictEqual(e.reasoning_effort, undefined);
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Integration: the real request body the api client emits
63
+ // ---------------------------------------------------------------------------
64
+
65
+ async function captureBody(configExtra, model) {
66
+ const mock = await startMockLLM();
67
+ mock.replyWith('ok');
68
+ const config = {
69
+ api_base: mock.base, api_key: 'test-key', default_model: model,
70
+ temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
71
+ ...configExtra,
72
+ };
73
+ const api = createApiClient({ getConfig: () => config, saveConfig: () => {}, ui });
74
+ try {
75
+ await api.chatStream([{ role: 'system', content: 'S' }, { role: 'user', content: 'u' }], { model, nativeTools: true });
76
+ return JSON.parse(mock.requests[0].body);
77
+ } finally {
78
+ await mock.close();
79
+ }
80
+ }
81
+
82
+ test('reasoning_effort is sent for a supporting model and omitted otherwise', async () => {
83
+ const withEffort = await captureBody({ reasoning_effort: 'high' }, 'o3-mini');
84
+ assert.strictEqual(withEffort.reasoning_effort, 'high');
85
+ const without = await captureBody({ reasoning_effort: 'high' }, 'local-llama-7b');
86
+ assert.strictEqual(without.reasoning_effort, undefined);
87
+ });
88
+
89
+ test('prompt caching markers appear only when config.prompt_caching is true', async () => {
90
+ const cached = await captureBody({ prompt_caching: true }, 'gpt-4o');
91
+ const sys = cached.messages.find((m) => m.role === 'system');
92
+ assert.deepStrictEqual(sys.cache_control, { type: 'ephemeral' });
93
+ assert.ok(Array.isArray(cached.tools) && cached.tools.length);
94
+ assert.deepStrictEqual(cached.tools[cached.tools.length - 1].cache_control, { type: 'ephemeral' });
95
+
96
+ const plain = await captureBody({ prompt_caching: false }, 'gpt-4o');
97
+ assert.strictEqual(plain.messages.find((m) => m.role === 'system').cache_control, undefined);
98
+ assert.ok(!plain.tools.some((t) => t.cache_control), 'no tool carries cache_control when disabled');
99
+ });
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ // Per-pattern permission rules (Task 4.1) driving the REAL runAgentLoop against
4
+ // the mock-LLM harness. Proves the gate integration end-to-end on the XML rail
5
+ // (the native rail converges on the SAME [action, ...args] call tuple and the
6
+ // SAME gate, so one path exercises both). Covers: deny blocks (even under
7
+ // --dangerously-skip-permissions), allow auto-approves what a tier wouldn't, and
8
+ // ask forces a prompt a tier would otherwise skip (→ refused in non-TTY).
9
+ // Also proves composition: an allow rule never re-enables the deny-list, the
10
+ // secret-file guard, or --readonly (all enforced downstream in the executors).
11
+
12
+ const os = require('node:os');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ // Temp $HOME before lib modules load (audit log / config / memory resolve here).
17
+ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-permhome-'));
18
+ const PREV_HOME = process.env.HOME;
19
+ const PREV_USERPROFILE = process.env.USERPROFILE;
20
+ process.env.HOME = TMP_HOME;
21
+ process.env.USERPROFILE = TMP_HOME;
22
+
23
+ const { test, before, after } = require('node:test');
24
+ const assert = require('node:assert');
25
+
26
+ const ui = require('../lib/ui');
27
+ const { createApiClient } = require('../lib/api');
28
+ const { createToolExecutor, extractToolCalls } = require('../lib/tools');
29
+ const { createPermissionManager } = require('../lib/permissions');
30
+ const { createAgentRunner } = require('../lib/agent');
31
+ const { loadRuleLayers } = require('../lib/permission-rules');
32
+ const { startMockLLM } = require('./harness/mock-llm');
33
+
34
+ let prevKey, PREV_CWD, CWD;
35
+ before(() => {
36
+ prevKey = process.env.SEMALT_API_KEY;
37
+ process.env.SEMALT_API_KEY = 'test-key';
38
+ PREV_CWD = process.cwd();
39
+ CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-permcwd-')));
40
+ process.chdir(CWD);
41
+ });
42
+ after(() => {
43
+ process.chdir(PREV_CWD);
44
+ if (prevKey === undefined) delete process.env.SEMALT_API_KEY; else process.env.SEMALT_API_KEY = prevKey;
45
+ if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
46
+ if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
47
+ });
48
+
49
+ // Build a runner whose permission manager carries per-pattern rules + the given
50
+ // manager options (tiers / skip / readonly).
51
+ function buildRunner(base, { user = [], project = [], pmOpts = {} } = {}) {
52
+ const config = {
53
+ api_base: base, api_key: 'test-key', default_model: 'test-model',
54
+ temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
55
+ };
56
+ const getConfig = () => config;
57
+ const saveConfig = (c) => Object.assign(config, c);
58
+ const api = createApiClient({ getConfig, saveConfig, ui });
59
+ const rules = loadRuleLayers({ permissions: { rules: user } }, { permissions: { rules: project } });
60
+ const pm = createPermissionManager(ui, { ...pmOpts, rules, cwd: CWD });
61
+ pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
62
+ const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
63
+ const runner = createAgentRunner({
64
+ chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
65
+ describePermission, permissionManager: pm, ui, getConfig,
66
+ });
67
+ return { runner };
68
+ }
69
+
70
+ function collector() {
71
+ const ev = { tools: [], errors: [] };
72
+ return { ev, cb: { onToolEnd: (tag, result) => ev.tools.push({ tag, result }), onError: (e) => ev.errors.push(e) } };
73
+ }
74
+
75
+ function fedBack(messages) {
76
+ const m = messages.find((x) => x.role === 'user' && /Tool execution results/.test(x.content));
77
+ return m ? m.content : '';
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // deny rule
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test('a deny rule blocks the tool end-to-end — even under --dangerously-skip-permissions', async () => {
85
+ const sentinel = path.join(CWD, 'should-not-exist.txt');
86
+ const mock = await startMockLLM();
87
+ mock.replyWith(`<shell>touch ${sentinel}</shell>`);
88
+ mock.replyWith('understood');
89
+ try {
90
+ const { runner } = buildRunner(mock.base, {
91
+ user: [{ tool: 'shell', action: 'deny', pattern: 'touch *' }],
92
+ pmOpts: { skipPermissions: true }, // deny must win even here
93
+ });
94
+ const { ev, cb } = collector();
95
+ const messages = [{ role: 'user', content: 'make a file' }];
96
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
97
+
98
+ assert.ok(!fs.existsSync(sentinel), 'the denied command never ran');
99
+ assert.strictEqual(ev.tools.length, 0, 'a rule-denied tool never reaches onToolEnd');
100
+ assert.match(fedBack(messages), /DENIED by a permission rule/);
101
+ } finally { mock.close(); }
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // allow rule
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test('an allow rule auto-approves a write that would otherwise be refused (non-TTY, no tier)', async () => {
109
+ const target = path.join(CWD, 'allowed.txt');
110
+ const mock = await startMockLLM();
111
+ mock.replyWith(`<write_file path="${target}">DATA</write_file>`);
112
+ mock.replyWith('done');
113
+ try {
114
+ const { runner } = buildRunner(mock.base, {
115
+ user: [{ tool: 'write_file', action: 'allow', path: '**' }],
116
+ // no skip, no tier — without the rule this write would be refused in non-TTY
117
+ });
118
+ const { cb } = collector();
119
+ const messages = [{ role: 'user', content: 'write it' }];
120
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
121
+
122
+ assert.ok(fs.existsSync(target), 'the allow rule auto-approved the write');
123
+ assert.strictEqual(fs.readFileSync(target, 'utf8'), 'DATA');
124
+ } finally { mock.close(); }
125
+ });
126
+
127
+ test('COMPOSE: an allow shell rule cannot re-enable a deny-listed command', async () => {
128
+ const mock = await startMockLLM();
129
+ mock.replyWith('<shell>rm -rf /</shell>');
130
+ mock.replyWith('ok');
131
+ try {
132
+ const { runner } = buildRunner(mock.base, {
133
+ user: [{ tool: 'shell', action: 'allow', pattern: '*' }], // allow ALL shell
134
+ });
135
+ const { ev, cb } = collector();
136
+ const messages = [{ role: 'user', content: 'wipe' }];
137
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
138
+
139
+ // The deny-list runs inside agentExecShell, downstream of the gate the rule
140
+ // satisfied — so the catastrophic command is still blocked.
141
+ assert.match(fedBack(messages), /Blocked by safety deny-list/);
142
+ } finally { mock.close(); }
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // ask rule
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test('an ask rule forces a prompt a tier flag would skip (→ refused in non-TTY)', async () => {
150
+ const sentinel = path.join(CWD, 'ask-not-run.txt');
151
+ const mock = await startMockLLM();
152
+ mock.replyWith(`<shell>touch ${sentinel}</shell>`);
153
+ mock.replyWith('understood');
154
+ try {
155
+ const { runner } = buildRunner(mock.base, {
156
+ user: [{ tool: 'shell', action: 'ask', pattern: 'touch *' }],
157
+ pmOpts: { allowedTiers: ['exec'] }, // tier would normally auto-approve shell
158
+ });
159
+ const { ev, cb } = collector();
160
+ const messages = [{ role: 'user', content: 'touch it' }];
161
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
162
+
163
+ assert.ok(!fs.existsSync(sentinel), 'ask forced a prompt; non-TTY refused it, so it never ran');
164
+ assert.match(fedBack(messages), /Permission denied/);
165
+ } finally { mock.close(); }
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // compose with --readonly and the secret-file guard
170
+ // ---------------------------------------------------------------------------
171
+
172
+ test('COMPOSE: --readonly still wins over an allow write rule', async () => {
173
+ const target = path.join(CWD, 'ro.txt');
174
+ const mock = await startMockLLM();
175
+ mock.replyWith(`<write_file path="${target}">X</write_file>`);
176
+ mock.replyWith('done');
177
+ try {
178
+ const { runner } = buildRunner(mock.base, {
179
+ user: [{ tool: 'write_file', action: 'allow', path: '**' }],
180
+ pmOpts: { readonly: true },
181
+ });
182
+ const { cb } = collector();
183
+ const messages = [{ role: 'user', content: 'write' }];
184
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
185
+
186
+ assert.ok(!fs.existsSync(target), 'the write was blocked by --readonly despite the allow rule');
187
+ assert.match(fedBack(messages), /readonly/);
188
+ } finally { mock.close(); }
189
+ });
190
+
191
+ test('COMPOSE: an allow read rule cannot re-enable a secret-file read', async () => {
192
+ const { CONFIG_PATH } = require('../lib/constants');
193
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
194
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: 'sekret' }));
195
+ const mock = await startMockLLM();
196
+ mock.replyWith(`<read_file path="${CONFIG_PATH}"/>`);
197
+ mock.replyWith('done');
198
+ try {
199
+ const { runner } = buildRunner(mock.base, {
200
+ user: [{ tool: 'read_file', action: 'allow', path: '**' }],
201
+ });
202
+ const { cb } = collector();
203
+ const messages = [{ role: 'user', content: 'read config' }];
204
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
205
+
206
+ const fed = fedBack(messages);
207
+ assert.match(fed, /holds secrets\/credentials/);
208
+ assert.ok(!/sekret/.test(fed), 'the secret value never reached the model');
209
+ } finally { mock.close(); }
210
+ });