@komarspn/pi-permission-system 16.0.2
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/CHANGELOG.md +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { RuleOrigin } from "#src/rule";
|
|
3
|
+
import { evaluate } from "#src/rule";
|
|
4
|
+
import {
|
|
5
|
+
composeRuleset,
|
|
6
|
+
synthesizeBaseline,
|
|
7
|
+
synthesizeDefaults,
|
|
8
|
+
} from "#src/synthesize";
|
|
9
|
+
|
|
10
|
+
// ── synthesizeDefaults ─────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("synthesizeDefaults", () => {
|
|
13
|
+
test("emits a single universal catch-all rule with layer 'default' and origin 'builtin'", () => {
|
|
14
|
+
const rules = synthesizeDefaults("ask");
|
|
15
|
+
expect(rules).toHaveLength(1);
|
|
16
|
+
expect(rules[0]).toEqual({
|
|
17
|
+
surface: "*",
|
|
18
|
+
pattern: "*",
|
|
19
|
+
action: "ask",
|
|
20
|
+
layer: "default",
|
|
21
|
+
origin: "builtin",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("reflects the supplied PermissionState as the action", () => {
|
|
26
|
+
expect(synthesizeDefaults("allow")[0].action).toBe("allow");
|
|
27
|
+
expect(synthesizeDefaults("deny")[0].action).toBe("deny");
|
|
28
|
+
expect(synthesizeDefaults("ask")[0].action).toBe("ask");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("universal rule catches any surface via wildcardMatch", () => {
|
|
32
|
+
const rules = synthesizeDefaults("ask");
|
|
33
|
+
expect(evaluate("read", "*", rules).action).toBe("ask");
|
|
34
|
+
expect(evaluate("bash", "git status", rules).action).toBe("ask");
|
|
35
|
+
expect(evaluate("external_directory", "*", rules).action).toBe("ask");
|
|
36
|
+
expect(evaluate("future_surface", "*", rules).action).toBe("ask");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("universal rule has layer 'default'", () => {
|
|
40
|
+
const rules = synthesizeDefaults("allow");
|
|
41
|
+
expect(evaluate("read", "*", rules).layer).toBe("default");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("defaults to origin 'builtin' when no origin supplied", () => {
|
|
45
|
+
const rules = synthesizeDefaults("ask");
|
|
46
|
+
expect(rules[0].origin).toBe("builtin");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("universal rule carries config scope origin when supplied", () => {
|
|
50
|
+
const origin: RuleOrigin = "global";
|
|
51
|
+
const rules = synthesizeDefaults("ask", origin);
|
|
52
|
+
expect(rules[0].origin).toBe("global");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("origin is preserved through evaluate()", () => {
|
|
56
|
+
const rules = synthesizeDefaults("allow", "project");
|
|
57
|
+
const result = evaluate("read", "*", rules);
|
|
58
|
+
expect(result.origin).toBe("project");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("all RuleOrigin values are accepted", () => {
|
|
62
|
+
const origins: RuleOrigin[] = [
|
|
63
|
+
"global",
|
|
64
|
+
"project",
|
|
65
|
+
"agent",
|
|
66
|
+
"project-agent",
|
|
67
|
+
"builtin",
|
|
68
|
+
"baseline",
|
|
69
|
+
"session",
|
|
70
|
+
];
|
|
71
|
+
for (const origin of origins) {
|
|
72
|
+
const rules = synthesizeDefaults("ask", origin);
|
|
73
|
+
expect(rules[0].origin).toBe(origin);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── synthesizeBaseline ─────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("synthesizeBaseline", () => {
|
|
81
|
+
test("returns empty ruleset when config has no mcp allow rules", () => {
|
|
82
|
+
const configRules = [
|
|
83
|
+
{
|
|
84
|
+
surface: "mcp",
|
|
85
|
+
pattern: "*",
|
|
86
|
+
action: "deny" as const,
|
|
87
|
+
layer: "config" as const,
|
|
88
|
+
origin: "global" as const,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns empty ruleset for empty config rules", () => {
|
|
95
|
+
expect(synthesizeBaseline([])).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("synthesizes 5 baseline rules when at least one mcp allow config rule exists", () => {
|
|
99
|
+
const configRules = [
|
|
100
|
+
{
|
|
101
|
+
surface: "mcp",
|
|
102
|
+
pattern: "exa:*",
|
|
103
|
+
action: "allow" as const,
|
|
104
|
+
layer: "config" as const,
|
|
105
|
+
origin: "global" as const,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
const rules = synthesizeBaseline(configRules);
|
|
109
|
+
expect(rules).toHaveLength(5);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("baseline rules all have layer 'baseline', action 'allow', and origin 'baseline'", () => {
|
|
113
|
+
const configRules = [
|
|
114
|
+
{
|
|
115
|
+
surface: "mcp",
|
|
116
|
+
pattern: "exa:*",
|
|
117
|
+
action: "allow" as const,
|
|
118
|
+
layer: "config" as const,
|
|
119
|
+
origin: "global" as const,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
const rules = synthesizeBaseline(configRules);
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
expect(rule.layer).toBe("baseline");
|
|
125
|
+
expect(rule.action).toBe("allow");
|
|
126
|
+
expect(rule.surface).toBe("mcp");
|
|
127
|
+
expect(rule.origin).toBe("baseline");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("baseline rules cover the 5 MCP metadata targets", () => {
|
|
132
|
+
const configRules = [
|
|
133
|
+
{
|
|
134
|
+
surface: "mcp",
|
|
135
|
+
pattern: "exa:*",
|
|
136
|
+
action: "allow" as const,
|
|
137
|
+
layer: "config" as const,
|
|
138
|
+
origin: "global" as const,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const rules = synthesizeBaseline(configRules);
|
|
142
|
+
const patterns = rules.map((r) => r.pattern);
|
|
143
|
+
expect(patterns).toContain("mcp_status");
|
|
144
|
+
expect(patterns).toContain("mcp_list");
|
|
145
|
+
expect(patterns).toContain("mcp_search");
|
|
146
|
+
expect(patterns).toContain("mcp_describe");
|
|
147
|
+
expect(patterns).toContain("mcp_connect");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("baseline is NOT synthesized when allow rule is on a non-mcp surface", () => {
|
|
151
|
+
const configRules = [
|
|
152
|
+
{
|
|
153
|
+
surface: "bash",
|
|
154
|
+
pattern: "git *",
|
|
155
|
+
action: "allow" as const,
|
|
156
|
+
layer: "config" as const,
|
|
157
|
+
origin: "global" as const,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
expect(synthesizeBaseline(configRules)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("baseline auto-allows mcp_status when an mcp allow rule exists", () => {
|
|
164
|
+
const configRules = [
|
|
165
|
+
{
|
|
166
|
+
surface: "mcp",
|
|
167
|
+
pattern: "exa:*",
|
|
168
|
+
action: "allow" as const,
|
|
169
|
+
layer: "config" as const,
|
|
170
|
+
origin: "global" as const,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
const rules = synthesizeBaseline(configRules);
|
|
174
|
+
const result = evaluate("mcp", "mcp_status", rules);
|
|
175
|
+
expect(result.action).toBe("allow");
|
|
176
|
+
expect(result.layer).toBe("baseline");
|
|
177
|
+
expect(result.origin).toBe("baseline");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── composeRuleset ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("composeRuleset", () => {
|
|
184
|
+
test("returns concatenation of all layers in order", () => {
|
|
185
|
+
const defaults = synthesizeDefaults("ask");
|
|
186
|
+
const baseline = synthesizeBaseline([
|
|
187
|
+
{
|
|
188
|
+
surface: "mcp",
|
|
189
|
+
pattern: "exa:*",
|
|
190
|
+
action: "allow",
|
|
191
|
+
layer: "config",
|
|
192
|
+
origin: "global" as const,
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
const config = [
|
|
196
|
+
{
|
|
197
|
+
surface: "bash",
|
|
198
|
+
pattern: "rm -rf *",
|
|
199
|
+
action: "deny" as const,
|
|
200
|
+
origin: "global" as const,
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
204
|
+
expect(composed.length).toBe(
|
|
205
|
+
defaults.length + baseline.length + config.length,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("defaults come first (lowest priority), config comes last (highest priority)", () => {
|
|
210
|
+
const defaults = synthesizeDefaults("ask");
|
|
211
|
+
const config = [
|
|
212
|
+
{
|
|
213
|
+
surface: "bash",
|
|
214
|
+
pattern: "*",
|
|
215
|
+
action: "deny" as const,
|
|
216
|
+
layer: "config" as const,
|
|
217
|
+
origin: "global" as const,
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
const composed = composeRuleset(defaults, [], config);
|
|
221
|
+
const result = evaluate("bash", "echo hello", composed);
|
|
222
|
+
expect(result.action).toBe("deny");
|
|
223
|
+
expect(result.layer).toBe("config");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("config beats default for matching patterns", () => {
|
|
227
|
+
const defaults = synthesizeDefaults("ask");
|
|
228
|
+
const config = [
|
|
229
|
+
{
|
|
230
|
+
surface: "read",
|
|
231
|
+
pattern: "*",
|
|
232
|
+
action: "allow" as const,
|
|
233
|
+
layer: "config" as const,
|
|
234
|
+
origin: "global" as const,
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
const composed = composeRuleset(defaults, [], config);
|
|
238
|
+
const result = evaluate("read", "*", composed);
|
|
239
|
+
expect(result.action).toBe("allow");
|
|
240
|
+
expect(result.layer).toBe("config");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("baseline beats default but config beats baseline", () => {
|
|
244
|
+
const defaults = synthesizeDefaults("ask");
|
|
245
|
+
const baseline = [
|
|
246
|
+
{
|
|
247
|
+
surface: "mcp",
|
|
248
|
+
pattern: "mcp_status",
|
|
249
|
+
action: "allow" as const,
|
|
250
|
+
layer: "baseline" as const,
|
|
251
|
+
origin: "baseline" as const,
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
const config = [
|
|
255
|
+
{
|
|
256
|
+
surface: "mcp",
|
|
257
|
+
pattern: "mcp_status",
|
|
258
|
+
action: "deny" as const,
|
|
259
|
+
layer: "config" as const,
|
|
260
|
+
origin: "global" as const,
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
264
|
+
const result = evaluate("mcp", "mcp_status", composed);
|
|
265
|
+
expect(result.action).toBe("deny");
|
|
266
|
+
expect(result.layer).toBe("config");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("config beats baseline for specific patterns", () => {
|
|
270
|
+
const defaults = synthesizeDefaults("ask");
|
|
271
|
+
const baseline = [
|
|
272
|
+
{
|
|
273
|
+
surface: "mcp",
|
|
274
|
+
pattern: "mcp_status",
|
|
275
|
+
action: "allow" as const,
|
|
276
|
+
layer: "baseline" as const,
|
|
277
|
+
origin: "baseline" as const,
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
const config = [
|
|
281
|
+
{
|
|
282
|
+
surface: "mcp",
|
|
283
|
+
pattern: "exa_web_search",
|
|
284
|
+
action: "allow" as const,
|
|
285
|
+
layer: "config" as const,
|
|
286
|
+
origin: "global" as const,
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
const composed = composeRuleset(defaults, baseline, config);
|
|
290
|
+
const result = evaluate("mcp", "exa_web_search", composed);
|
|
291
|
+
expect(result.action).toBe("allow");
|
|
292
|
+
expect(result.layer).toBe("config");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("handles empty layers gracefully", () => {
|
|
296
|
+
const defaults = synthesizeDefaults("ask");
|
|
297
|
+
const composed = composeRuleset(defaults, [], []);
|
|
298
|
+
expect(composed).toEqual(defaults);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Helpers for building prompt sections.
|
|
10
|
+
function availableToolsSection(tools: string[]): string {
|
|
11
|
+
return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function guidelinesSection(guidelines: string[]): string {
|
|
15
|
+
return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prompt(...sections: string[]): string {
|
|
19
|
+
return sections.join("\n\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
23
|
+
test("keeps allowed tool lines and the header, drops denied ones", () => {
|
|
24
|
+
const input = prompt(
|
|
25
|
+
availableToolsSection(["bash", "read"]),
|
|
26
|
+
"Other content",
|
|
27
|
+
);
|
|
28
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
29
|
+
expect(result.removed).toBe(true);
|
|
30
|
+
expect(result.prompt).toContain("Available tools:");
|
|
31
|
+
expect(result.prompt).toContain("- read");
|
|
32
|
+
expect(result.prompt).not.toContain("- bash");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("leaves the section untouched when every tool is allowed", () => {
|
|
36
|
+
const input = prompt(
|
|
37
|
+
availableToolsSection(["bash", "read"]),
|
|
38
|
+
"Other content",
|
|
39
|
+
);
|
|
40
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
41
|
+
expect(result.removed).toBe(false);
|
|
42
|
+
expect(result.prompt).toBe(input);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Bug #33: findSection extends to lines.length when no subsequent recognised
|
|
46
|
+
// header follows, so content after the last section is silently deleted.
|
|
47
|
+
test("preserves content that follows the Available tools section (bug #33)", () => {
|
|
48
|
+
const input = prompt(
|
|
49
|
+
availableToolsSection(["bash", "read"]),
|
|
50
|
+
"Other content",
|
|
51
|
+
);
|
|
52
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
53
|
+
expect(result.prompt).toContain("Other content");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("removes the whole section when no tool is allowed", () => {
|
|
57
|
+
const input = prompt(
|
|
58
|
+
availableToolsSection(["bash", "read"]),
|
|
59
|
+
"Other content",
|
|
60
|
+
);
|
|
61
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
62
|
+
expect(result.removed).toBe(true);
|
|
63
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
64
|
+
expect(result.prompt).toContain("Other content");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("removed flag is false when no Available tools section is present", () => {
|
|
68
|
+
const input = "Just some instructions.\n\nNo tools section.";
|
|
69
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
70
|
+
expect(result.removed).toBe(false);
|
|
71
|
+
expect(result.prompt).toBe(input);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("keeps non-tool boilerplate prose near the section", () => {
|
|
75
|
+
const input = [
|
|
76
|
+
"Available tools:",
|
|
77
|
+
"- read: Read file contents",
|
|
78
|
+
"- bash: Run shell commands",
|
|
79
|
+
"",
|
|
80
|
+
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
|
81
|
+
].join("\n");
|
|
82
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
83
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
84
|
+
expect(result.prompt).not.toContain("- bash: Run shell commands");
|
|
85
|
+
expect(result.prompt).toContain("In addition to the tools above");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns original prompt reference unchanged when nothing is removed", () => {
|
|
89
|
+
const input = "No tools section here.";
|
|
90
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
91
|
+
expect(result.prompt).toBe(input);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => {
|
|
95
|
+
const allowed = ["read", "edit", "write"];
|
|
96
|
+
const fullProse = [
|
|
97
|
+
"You are an assistant.",
|
|
98
|
+
"",
|
|
99
|
+
"Available tools:",
|
|
100
|
+
"- bash: Run shell commands",
|
|
101
|
+
"- read: Read file contents",
|
|
102
|
+
"- edit: Edit a file",
|
|
103
|
+
"- write: Write a file",
|
|
104
|
+
"",
|
|
105
|
+
"Guidelines:",
|
|
106
|
+
"- use bash for file operations like ls, rg, find",
|
|
107
|
+
"- use read to examine files instead of cat or sed.",
|
|
108
|
+
"- Be concise in your responses",
|
|
109
|
+
].join("\n");
|
|
110
|
+
const narrowedProse = [
|
|
111
|
+
"You are an assistant.",
|
|
112
|
+
"",
|
|
113
|
+
"Available tools:",
|
|
114
|
+
"- read: Read file contents",
|
|
115
|
+
"- edit: Edit a file",
|
|
116
|
+
"- write: Write a file",
|
|
117
|
+
"",
|
|
118
|
+
"Guidelines:",
|
|
119
|
+
"- use read to examine files instead of cat or sed.",
|
|
120
|
+
"- Be concise in your responses",
|
|
121
|
+
].join("\n");
|
|
122
|
+
|
|
123
|
+
const fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt;
|
|
124
|
+
const fromNarrowed = sanitizeAvailableToolsSection(
|
|
125
|
+
narrowedProse,
|
|
126
|
+
allowed,
|
|
127
|
+
).prompt;
|
|
128
|
+
|
|
129
|
+
// Idempotent on the already-narrowed input Pi feeds back on later turns.
|
|
130
|
+
expect(fromNarrowed).toBe(narrowedProse);
|
|
131
|
+
// Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes.
|
|
132
|
+
expect(fromFull).toBe(fromNarrowed);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
|
|
137
|
+
test("removes bash guideline when bash is not in allowed tools", () => {
|
|
138
|
+
const input = prompt(
|
|
139
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
140
|
+
);
|
|
141
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
142
|
+
expect(result.removed).toBe(true);
|
|
143
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("keeps bash guideline when bash is in allowed tools", () => {
|
|
147
|
+
const input = prompt(
|
|
148
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
149
|
+
);
|
|
150
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
151
|
+
expect(result.removed).toBe(false);
|
|
152
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("removes read guideline when read is not allowed", () => {
|
|
156
|
+
const input = prompt(
|
|
157
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
158
|
+
);
|
|
159
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
160
|
+
expect(result.removed).toBe(true);
|
|
161
|
+
expect(result.prompt).not.toContain("use read to examine files");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("keeps read guideline when read is allowed", () => {
|
|
165
|
+
const input = prompt(
|
|
166
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
167
|
+
);
|
|
168
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
169
|
+
expect(result.removed).toBe(false);
|
|
170
|
+
expect(result.prompt).toContain("use read to examine files");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("removes edit guideline when edit is not allowed", () => {
|
|
174
|
+
const input = prompt(
|
|
175
|
+
guidelinesSection([
|
|
176
|
+
"use edit for precise changes (old text must match exactly)",
|
|
177
|
+
]),
|
|
178
|
+
);
|
|
179
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
180
|
+
expect(result.removed).toBe(true);
|
|
181
|
+
expect(result.prompt).not.toContain("use edit for precise changes");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("removes write guideline when write is not allowed", () => {
|
|
185
|
+
const input = prompt(
|
|
186
|
+
guidelinesSection(["use write only for new files or complete rewrites"]),
|
|
187
|
+
);
|
|
188
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
189
|
+
expect(result.removed).toBe(true);
|
|
190
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("removes entire Guidelines section when all bullets are filtered out", () => {
|
|
194
|
+
const input = prompt(
|
|
195
|
+
guidelinesSection([
|
|
196
|
+
"use bash for file operations like ls, rg, find",
|
|
197
|
+
"use write only for new files or complete rewrites",
|
|
198
|
+
]),
|
|
199
|
+
);
|
|
200
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
201
|
+
expect(result.removed).toBe(true);
|
|
202
|
+
expect(result.prompt).not.toContain("Guidelines:");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("preserves unrecognised guidelines regardless of allowed tools", () => {
|
|
206
|
+
const input = prompt(
|
|
207
|
+
guidelinesSection(["some custom guideline not in the rules"]),
|
|
208
|
+
);
|
|
209
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
210
|
+
expect(result.removed).toBe(false);
|
|
211
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("handles both sections together: removes tools section and filters guidelines", () => {
|
|
215
|
+
const input = prompt(
|
|
216
|
+
availableToolsSection(["bash"]),
|
|
217
|
+
guidelinesSection([
|
|
218
|
+
"use bash for file operations like ls, rg, find",
|
|
219
|
+
"use write only for new files or complete rewrites",
|
|
220
|
+
"some custom guideline not in the rules",
|
|
221
|
+
]),
|
|
222
|
+
);
|
|
223
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
224
|
+
expect(result.removed).toBe(true);
|
|
225
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
226
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
227
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
228
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("trims whitespace from allowed tool names", () => {
|
|
232
|
+
const input = prompt(
|
|
233
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
234
|
+
);
|
|
235
|
+
const result = sanitizeAvailableToolsSection(input, [" bash "]);
|
|
236
|
+
expect(result.removed).toBe(false);
|
|
237
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("sanitizeAvailableToolsSection — multi-section prompt", () => {
|
|
242
|
+
test("collapses extra blank lines after removal", () => {
|
|
243
|
+
const input = prompt(
|
|
244
|
+
"Intro",
|
|
245
|
+
availableToolsSection(["bash"]),
|
|
246
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
247
|
+
"Closing",
|
|
248
|
+
);
|
|
249
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
250
|
+
// No run of 3+ consecutive newlines
|
|
251
|
+
expect(result.prompt).not.toMatch(/\n{3,}/);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("sanitizeAvailableToolsSection — findSection boundary edge cases", () => {
|
|
256
|
+
test("preserves content after Guidelines when Guidelines is the last recognised section", () => {
|
|
257
|
+
const input = prompt(
|
|
258
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
259
|
+
"Trailing custom instructions",
|
|
260
|
+
);
|
|
261
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
262
|
+
expect(result.prompt).toContain("Trailing custom instructions");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("preserves trailing prose when both sections are removed", () => {
|
|
266
|
+
const input = prompt(
|
|
267
|
+
availableToolsSection(["bash"]),
|
|
268
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
269
|
+
"Important user note",
|
|
270
|
+
);
|
|
271
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
272
|
+
expect(result.removed).toBe(true);
|
|
273
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
274
|
+
expect(result.prompt).not.toContain("Guidelines:");
|
|
275
|
+
expect(result.prompt).toContain("Important user note");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("section at EOF is removed entirely when no tool is allowed", () => {
|
|
279
|
+
const input = availableToolsSection(["bash", "read"]);
|
|
280
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
281
|
+
expect(result.removed).toBe(true);
|
|
282
|
+
expect(result.prompt).toBe("");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("section followed by blank lines then prose — prose survives removal", () => {
|
|
286
|
+
const input = ["Available tools:", "- bash", "", "", "Custom note"].join(
|
|
287
|
+
"\n",
|
|
288
|
+
);
|
|
289
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
290
|
+
expect(result.removed).toBe(true);
|
|
291
|
+
expect(result.prompt).toContain("Custom note");
|
|
292
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
test("System prompt sanitizer keeps the active tools in the Available tools section", () => {
|
|
301
|
+
const prompt = [
|
|
302
|
+
"Available tools:",
|
|
303
|
+
"- read: Read file contents",
|
|
304
|
+
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
|
305
|
+
"",
|
|
306
|
+
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
|
307
|
+
"",
|
|
308
|
+
"Guidelines:",
|
|
309
|
+
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
310
|
+
"- Be concise in your responses",
|
|
311
|
+
].join("\n");
|
|
312
|
+
|
|
313
|
+
const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
|
|
314
|
+
|
|
315
|
+
expect(result.removed).toBe(false);
|
|
316
|
+
expect(result.prompt).toContain("Available tools:");
|
|
317
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
318
|
+
expect(result.prompt).toContain("- mcp: Discover");
|
|
319
|
+
expect(result.prompt).toContain("In addition to the tools above");
|
|
320
|
+
expect(result.prompt).toMatch(/Guidelines:/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("System prompt sanitizer drops a denied tool's line but keeps the section", () => {
|
|
324
|
+
const prompt = [
|
|
325
|
+
"Available tools:",
|
|
326
|
+
"- read: Read file contents",
|
|
327
|
+
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
|
328
|
+
"",
|
|
329
|
+
"Guidelines:",
|
|
330
|
+
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
331
|
+
"- Be concise in your responses",
|
|
332
|
+
].join("\n");
|
|
333
|
+
|
|
334
|
+
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
|
335
|
+
|
|
336
|
+
expect(result.removed).toBe(true);
|
|
337
|
+
expect(result.prompt).toContain("Available tools:");
|
|
338
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
339
|
+
expect(result.prompt).not.toContain("- mcp: Discover");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
|
|
343
|
+
const prompt = [
|
|
344
|
+
"Guidelines:",
|
|
345
|
+
"- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.",
|
|
346
|
+
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
347
|
+
"- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
|
|
348
|
+
"- Be concise in your responses",
|
|
349
|
+
"- Show file paths clearly when working with files",
|
|
350
|
+
].join("\n");
|
|
351
|
+
|
|
352
|
+
const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]);
|
|
353
|
+
|
|
354
|
+
expect(result.removed).toBe(true);
|
|
355
|
+
expect(result.prompt).not.toContain("Use task when work SHOULD");
|
|
356
|
+
expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
|
|
357
|
+
expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i);
|
|
358
|
+
expect(result.prompt).toMatch(/Be concise in your responses/);
|
|
359
|
+
expect(result.prompt).toMatch(
|
|
360
|
+
/Show file paths clearly when working with files/,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("System prompt sanitizer removes inactive built-in write guidance", () => {
|
|
365
|
+
const prompt = [
|
|
366
|
+
"Guidelines:",
|
|
367
|
+
"- Use write only for new files or complete rewrites",
|
|
368
|
+
"- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
|
369
|
+
"- Be concise in your responses",
|
|
370
|
+
].join("\n");
|
|
371
|
+
|
|
372
|
+
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
|
373
|
+
|
|
374
|
+
expect(result.removed).toBe(true);
|
|
375
|
+
expect(result.prompt).not.toContain(
|
|
376
|
+
"Use write only for new files or complete rewrites",
|
|
377
|
+
);
|
|
378
|
+
expect(result.prompt).not.toContain(
|
|
379
|
+
"do NOT use cat or bash to display what you did",
|
|
380
|
+
);
|
|
381
|
+
expect(result.prompt).toMatch(/Be concise in your responses/);
|
|
382
|
+
});
|