@gotgenes/pi-permission-system 4.8.0 → 5.0.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/CHANGELOG.md +43 -0
- package/README.md +18 -5
- package/config/config.example.json +2 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/config-modal.ts +25 -3
- package/src/extension-config.ts +13 -1
- package/src/external-directory.ts +96 -1
- package/src/handlers/tool-call.ts +87 -61
- package/src/index.ts +4 -0
- package/src/normalize.ts +2 -2
- package/src/permission-manager.ts +72 -17
- package/src/rule.ts +26 -2
- package/src/runtime.ts +17 -0
- package/src/session-rules.ts +7 -1
- package/src/synthesize.ts +7 -2
- package/src/tool-input-preview.ts +7 -1
- package/src/types.ts +6 -0
- package/tests/bash-external-directory.test.ts +50 -0
- package/tests/config-modal.test.ts +83 -0
- package/tests/handlers/tool-call.test.ts +149 -1
- package/tests/normalize.test.ts +64 -22
- package/tests/permission-manager-unified.test.ts +215 -0
- package/tests/permission-prompts.test.ts +8 -1
- package/tests/permission-system.test.ts +12 -0
- package/tests/pi-infrastructure-read.test.ts +245 -0
- package/tests/rule.test.ts +76 -8
- package/tests/runtime.test.ts +45 -0
- package/tests/session-rules.test.ts +7 -1
- package/tests/skill-prompt-sanitizer.test.ts +1 -1
- package/tests/synthesize.test.ts +64 -4
- package/tests/tool-input-preview.test.ts +29 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
discoverGlobalNodeModulesRoot,
|
|
6
|
+
isPiInfrastructureRead,
|
|
7
|
+
} from "../src/external-directory";
|
|
8
|
+
|
|
9
|
+
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("discoverGlobalNodeModulesRoot", () => {
|
|
12
|
+
test("returns the node_modules dir when the file is inside one", () => {
|
|
13
|
+
const url =
|
|
14
|
+
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
|
15
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
16
|
+
"/opt/homebrew/lib/node_modules",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns node_modules for a deeply nested file", () => {
|
|
21
|
+
const url =
|
|
22
|
+
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
|
|
23
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
24
|
+
"/home/user/.nvm/versions/node/v20/lib/node_modules",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns node_modules for a bun global install path", () => {
|
|
29
|
+
const url =
|
|
30
|
+
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
|
|
31
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
32
|
+
"/home/user/.bun/install/global/node_modules",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
|
|
37
|
+
// The walk-up algorithm stops at the first node_modules dir it encounters,
|
|
38
|
+
// which is the innermost one when the file is inside a nested install.
|
|
39
|
+
// In practice this never happens for a real global install — the extension
|
|
40
|
+
// is always directly at <global_root>/node_modules/pi-permission-system/…
|
|
41
|
+
const url =
|
|
42
|
+
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
|
|
43
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
|
44
|
+
"/opt/lib/node_modules/some-pkg/node_modules",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns null when the file is not inside any node_modules directory", () => {
|
|
49
|
+
const url =
|
|
50
|
+
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
|
|
51
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null for a root-level file", () => {
|
|
55
|
+
const url = "file:///external-directory.js";
|
|
56
|
+
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns null for an invalid URL", () => {
|
|
60
|
+
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("works with the real import.meta.url of this extension (smoke test)", () => {
|
|
64
|
+
// The extension IS installed inside a node_modules tree when running in CI
|
|
65
|
+
// or global install. In a local dev checkout the result may be null — that's
|
|
66
|
+
// the documented graceful-degradation path.
|
|
67
|
+
const result = discoverGlobalNodeModulesRoot();
|
|
68
|
+
expect(result === null || result.endsWith("node_modules")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the discovered path includes the pi-permission-system package directory", () => {
|
|
72
|
+
const url =
|
|
73
|
+
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
|
74
|
+
const root = discoverGlobalNodeModulesRoot(url);
|
|
75
|
+
expect(root).not.toBeNull();
|
|
76
|
+
expect(join(root!, "pi-permission-system")).toBe(
|
|
77
|
+
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const INFRA_DIRS = [
|
|
85
|
+
"/home/user/.pi/agent",
|
|
86
|
+
"/home/user/.pi/agent/git",
|
|
87
|
+
"/opt/homebrew/lib/node_modules",
|
|
88
|
+
];
|
|
89
|
+
const CWD = "/home/user/project";
|
|
90
|
+
|
|
91
|
+
describe("isPiInfrastructureRead", () => {
|
|
92
|
+
// ── read tools allowed for infra paths ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test("allows 'read' tool for a file inside agentDir", () => {
|
|
95
|
+
expect(
|
|
96
|
+
isPiInfrastructureRead(
|
|
97
|
+
"read",
|
|
98
|
+
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
|
99
|
+
INFRA_DIRS,
|
|
100
|
+
CWD,
|
|
101
|
+
),
|
|
102
|
+
).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("allows 'find' tool for a path inside node_modules infra dir", () => {
|
|
106
|
+
expect(
|
|
107
|
+
isPiInfrastructureRead(
|
|
108
|
+
"find",
|
|
109
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
|
|
110
|
+
INFRA_DIRS,
|
|
111
|
+
CWD,
|
|
112
|
+
),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("allows 'grep' tool for a path inside agentDir/git", () => {
|
|
117
|
+
expect(
|
|
118
|
+
isPiInfrastructureRead(
|
|
119
|
+
"grep",
|
|
120
|
+
"/home/user/.pi/agent/git/some-package/README.md",
|
|
121
|
+
INFRA_DIRS,
|
|
122
|
+
CWD,
|
|
123
|
+
),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
|
|
128
|
+
expect(
|
|
129
|
+
isPiInfrastructureRead(
|
|
130
|
+
"ls",
|
|
131
|
+
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
|
132
|
+
INFRA_DIRS,
|
|
133
|
+
CWD,
|
|
134
|
+
),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── write tools never allowed even for infra paths ───────────────────────
|
|
139
|
+
|
|
140
|
+
test("blocks 'write' tool for a file inside agentDir", () => {
|
|
141
|
+
expect(
|
|
142
|
+
isPiInfrastructureRead(
|
|
143
|
+
"write",
|
|
144
|
+
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
|
145
|
+
INFRA_DIRS,
|
|
146
|
+
CWD,
|
|
147
|
+
),
|
|
148
|
+
).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("blocks 'edit' tool for a file inside node_modules", () => {
|
|
152
|
+
expect(
|
|
153
|
+
isPiInfrastructureRead(
|
|
154
|
+
"edit",
|
|
155
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
|
|
156
|
+
INFRA_DIRS,
|
|
157
|
+
CWD,
|
|
158
|
+
),
|
|
159
|
+
).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("blocks 'bash' tool regardless of path", () => {
|
|
163
|
+
expect(
|
|
164
|
+
isPiInfrastructureRead(
|
|
165
|
+
"bash",
|
|
166
|
+
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
|
|
167
|
+
INFRA_DIRS,
|
|
168
|
+
CWD,
|
|
169
|
+
),
|
|
170
|
+
).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── non-infra paths not allowed ──────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
test("does not allow 'read' for a path outside all infra dirs", () => {
|
|
176
|
+
expect(isPiInfrastructureRead("read", "/etc/passwd", INFRA_DIRS, CWD)).toBe(
|
|
177
|
+
false,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
|
|
182
|
+
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
|
|
183
|
+
expect(
|
|
184
|
+
isPiInfrastructureRead(
|
|
185
|
+
"read",
|
|
186
|
+
"/home/user/.pi/agent-other/config.json",
|
|
187
|
+
INFRA_DIRS,
|
|
188
|
+
CWD,
|
|
189
|
+
),
|
|
190
|
+
).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
|
|
194
|
+
|
|
195
|
+
test("allows 'read' for a path inside project-local .pi/npm/", () => {
|
|
196
|
+
expect(
|
|
197
|
+
isPiInfrastructureRead(
|
|
198
|
+
"read",
|
|
199
|
+
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
|
200
|
+
INFRA_DIRS,
|
|
201
|
+
CWD,
|
|
202
|
+
),
|
|
203
|
+
).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("allows 'read' for a path inside project-local .pi/git/", () => {
|
|
207
|
+
expect(
|
|
208
|
+
isPiInfrastructureRead(
|
|
209
|
+
"read",
|
|
210
|
+
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
|
|
211
|
+
INFRA_DIRS,
|
|
212
|
+
CWD,
|
|
213
|
+
),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
|
|
218
|
+
expect(
|
|
219
|
+
isPiInfrastructureRead(
|
|
220
|
+
"write",
|
|
221
|
+
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
|
222
|
+
INFRA_DIRS,
|
|
223
|
+
CWD,
|
|
224
|
+
),
|
|
225
|
+
).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── empty / edge cases ───────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
|
|
231
|
+
expect(isPiInfrastructureRead("read", "/etc/passwd", [], CWD)).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
|
|
235
|
+
// Project-local paths are checked separately from the dirs array.
|
|
236
|
+
expect(
|
|
237
|
+
isPiInfrastructureRead(
|
|
238
|
+
"read",
|
|
239
|
+
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
|
|
240
|
+
[],
|
|
241
|
+
CWD,
|
|
242
|
+
),
|
|
243
|
+
).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/tests/rule.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import type { Rule, Ruleset } from "../src/rule";
|
|
2
|
+
import type { Rule, RuleOrigin, Ruleset } from "../src/rule";
|
|
3
3
|
import { evaluate, evaluateFirst } from "../src/rule";
|
|
4
4
|
|
|
5
5
|
describe("evaluate", () => {
|
|
@@ -7,23 +7,37 @@ describe("evaluate", () => {
|
|
|
7
7
|
surface: "bash",
|
|
8
8
|
pattern: "git *",
|
|
9
9
|
action: "allow",
|
|
10
|
+
origin: "global",
|
|
10
11
|
};
|
|
11
12
|
const denyBashGitPush: Rule = {
|
|
12
13
|
surface: "bash",
|
|
13
14
|
pattern: "git push *",
|
|
14
15
|
action: "deny",
|
|
16
|
+
origin: "global",
|
|
17
|
+
};
|
|
18
|
+
const allowRead: Rule = {
|
|
19
|
+
surface: "read",
|
|
20
|
+
pattern: "*",
|
|
21
|
+
action: "allow",
|
|
22
|
+
origin: "global",
|
|
23
|
+
};
|
|
24
|
+
const askMcp: Rule = {
|
|
25
|
+
surface: "mcp",
|
|
26
|
+
pattern: "*",
|
|
27
|
+
action: "ask",
|
|
28
|
+
origin: "global",
|
|
15
29
|
};
|
|
16
|
-
const allowRead: Rule = { surface: "read", pattern: "*", action: "allow" };
|
|
17
|
-
const askMcp: Rule = { surface: "mcp", pattern: "*", action: "ask" };
|
|
18
30
|
const allowSkillLibrarian: Rule = {
|
|
19
31
|
surface: "skill",
|
|
20
32
|
pattern: "librarian",
|
|
21
33
|
action: "allow",
|
|
34
|
+
origin: "global",
|
|
22
35
|
};
|
|
23
36
|
const askSpecialExtDir: Rule = {
|
|
24
37
|
surface: "special",
|
|
25
38
|
pattern: "external_directory",
|
|
26
39
|
action: "ask",
|
|
40
|
+
origin: "global",
|
|
27
41
|
};
|
|
28
42
|
|
|
29
43
|
test("returns matching rule when a rule matches", () => {
|
|
@@ -76,11 +90,17 @@ describe("evaluate", () => {
|
|
|
76
90
|
});
|
|
77
91
|
|
|
78
92
|
test("last-match-wins: broad deny followed by specific allow", () => {
|
|
79
|
-
const denyAll: Rule = {
|
|
93
|
+
const denyAll: Rule = {
|
|
94
|
+
surface: "bash",
|
|
95
|
+
pattern: "*",
|
|
96
|
+
action: "deny",
|
|
97
|
+
origin: "global",
|
|
98
|
+
};
|
|
80
99
|
const allowStatus: Rule = {
|
|
81
100
|
surface: "bash",
|
|
82
101
|
pattern: "git status",
|
|
83
102
|
action: "allow",
|
|
103
|
+
origin: "global",
|
|
84
104
|
};
|
|
85
105
|
const result = evaluate("bash", "git status", [denyAll, allowStatus]);
|
|
86
106
|
expect(result).toEqual(allowStatus);
|
|
@@ -91,6 +111,7 @@ describe("evaluate", () => {
|
|
|
91
111
|
surface: "*",
|
|
92
112
|
pattern: "*",
|
|
93
113
|
action: "allow",
|
|
114
|
+
origin: "global",
|
|
94
115
|
};
|
|
95
116
|
expect(evaluate("bash", "anything", [universalAllow]).action).toBe("allow");
|
|
96
117
|
expect(evaluate("mcp", "something", [universalAllow]).action).toBe("allow");
|
|
@@ -108,10 +129,10 @@ describe("evaluate", () => {
|
|
|
108
129
|
|
|
109
130
|
test("merged rulesets: rules from later scope take priority", () => {
|
|
110
131
|
const globalRules: Ruleset = [
|
|
111
|
-
{ surface: "bash", pattern: "git *", action: "ask" },
|
|
132
|
+
{ surface: "bash", pattern: "git *", action: "ask", origin: "global" },
|
|
112
133
|
];
|
|
113
134
|
const agentRules: Ruleset = [
|
|
114
|
-
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
135
|
+
{ surface: "bash", pattern: "git *", action: "allow", origin: "agent" },
|
|
115
136
|
];
|
|
116
137
|
const merged = [...globalRules, ...agentRules];
|
|
117
138
|
const result = evaluate("bash", "git status", merged);
|
|
@@ -120,10 +141,10 @@ describe("evaluate", () => {
|
|
|
120
141
|
|
|
121
142
|
test("merged rulesets: earlier scope used when later scope has no match", () => {
|
|
122
143
|
const globalRules: Ruleset = [
|
|
123
|
-
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
144
|
+
{ surface: "bash", pattern: "git *", action: "allow", origin: "global" },
|
|
124
145
|
];
|
|
125
146
|
const agentRules: Ruleset = [
|
|
126
|
-
{ surface: "bash", pattern: "npm *", action: "deny" },
|
|
147
|
+
{ surface: "bash", pattern: "npm *", action: "deny", origin: "agent" },
|
|
127
148
|
];
|
|
128
149
|
// git status matches global but not agent rule
|
|
129
150
|
const merged = [...globalRules, ...agentRules];
|
|
@@ -144,17 +165,20 @@ describe("evaluate", () => {
|
|
|
144
165
|
pattern: "git *",
|
|
145
166
|
action: "allow",
|
|
146
167
|
layer: "config",
|
|
168
|
+
origin: "global",
|
|
147
169
|
};
|
|
148
170
|
const withoutLayer: Rule = {
|
|
149
171
|
surface: "bash",
|
|
150
172
|
pattern: "git *",
|
|
151
173
|
action: "allow",
|
|
174
|
+
origin: "global",
|
|
152
175
|
};
|
|
153
176
|
const withDefault: Rule = {
|
|
154
177
|
surface: "bash",
|
|
155
178
|
pattern: "*",
|
|
156
179
|
action: "ask",
|
|
157
180
|
layer: "default",
|
|
181
|
+
origin: "builtin",
|
|
158
182
|
};
|
|
159
183
|
// Both rules with and without layer field produce the same match.
|
|
160
184
|
expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
|
|
@@ -168,6 +192,46 @@ describe("evaluate", () => {
|
|
|
168
192
|
withDefault,
|
|
169
193
|
);
|
|
170
194
|
});
|
|
195
|
+
|
|
196
|
+
test("evaluate() preserves origin on a matched rule", () => {
|
|
197
|
+
const origin: RuleOrigin = "project";
|
|
198
|
+
const rule: Rule = {
|
|
199
|
+
surface: "bash",
|
|
200
|
+
pattern: "git *",
|
|
201
|
+
action: "allow",
|
|
202
|
+
layer: "config",
|
|
203
|
+
origin,
|
|
204
|
+
};
|
|
205
|
+
const result = evaluate("bash", "git status", [rule]);
|
|
206
|
+
expect(result.origin).toBe("project");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("evaluate() synthetic fallback rule has origin 'builtin'", () => {
|
|
210
|
+
const result = evaluate("bash", "npm install", []);
|
|
211
|
+
expect(result.origin).toBe("builtin");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("RuleOrigin covers all seven provenance values", () => {
|
|
215
|
+
const origins: RuleOrigin[] = [
|
|
216
|
+
"global",
|
|
217
|
+
"project",
|
|
218
|
+
"agent",
|
|
219
|
+
"project-agent",
|
|
220
|
+
"builtin",
|
|
221
|
+
"baseline",
|
|
222
|
+
"session",
|
|
223
|
+
];
|
|
224
|
+
for (const origin of origins) {
|
|
225
|
+
const rule: Rule = {
|
|
226
|
+
surface: "read",
|
|
227
|
+
pattern: "*",
|
|
228
|
+
action: "allow",
|
|
229
|
+
layer: "config",
|
|
230
|
+
origin,
|
|
231
|
+
};
|
|
232
|
+
expect(evaluate("read", "*", [rule]).origin).toBe(origin);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
171
235
|
});
|
|
172
236
|
|
|
173
237
|
describe("evaluateFirst", () => {
|
|
@@ -176,18 +240,21 @@ describe("evaluateFirst", () => {
|
|
|
176
240
|
pattern: "*",
|
|
177
241
|
action: "ask",
|
|
178
242
|
layer: "default",
|
|
243
|
+
origin: "builtin",
|
|
179
244
|
};
|
|
180
245
|
const allowBash: Rule = {
|
|
181
246
|
surface: "bash",
|
|
182
247
|
pattern: "git *",
|
|
183
248
|
action: "allow",
|
|
184
249
|
layer: "config",
|
|
250
|
+
origin: "global",
|
|
185
251
|
};
|
|
186
252
|
const denyMcp: Rule = {
|
|
187
253
|
surface: "mcp",
|
|
188
254
|
pattern: "exa_search",
|
|
189
255
|
action: "deny",
|
|
190
256
|
layer: "config",
|
|
257
|
+
origin: "global",
|
|
191
258
|
};
|
|
192
259
|
|
|
193
260
|
test("returns the first candidate that matches a non-default rule", () => {
|
|
@@ -220,6 +287,7 @@ describe("evaluateFirst", () => {
|
|
|
220
287
|
pattern: "mcp",
|
|
221
288
|
action: "allow",
|
|
222
289
|
layer: "config",
|
|
290
|
+
origin: "global",
|
|
223
291
|
};
|
|
224
292
|
const rules: Ruleset = [defaultRule, denyMcp, allowMcpCatchAll];
|
|
225
293
|
const result = evaluateFirst("mcp", ["exa_search", "mcp"], rules);
|
package/tests/runtime.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
mockGetActiveAgentName,
|
|
12
12
|
mockGetActiveAgentNameFromSystemPrompt,
|
|
13
13
|
mockBuildResolvedConfigLogEntry,
|
|
14
|
+
mockDiscoverGlobalNodeModulesRoot,
|
|
14
15
|
} = vi.hoisted(() => ({
|
|
15
16
|
mockLoggerDebug:
|
|
16
17
|
vi.fn<
|
|
@@ -27,6 +28,7 @@ const {
|
|
|
27
28
|
mockGetActiveAgentNameFromSystemPrompt:
|
|
28
29
|
vi.fn<(prompt?: string) => string | null>(),
|
|
29
30
|
mockBuildResolvedConfigLogEntry: vi.fn(),
|
|
31
|
+
mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
|
|
30
32
|
}));
|
|
31
33
|
|
|
32
34
|
vi.mock("../src/logging", () => ({
|
|
@@ -65,6 +67,10 @@ vi.mock("../src/subagent-context", () => ({
|
|
|
65
67
|
isSubagentExecutionContext: vi.fn().mockReturnValue(false),
|
|
66
68
|
}));
|
|
67
69
|
|
|
70
|
+
vi.mock("../src/external-directory", () => ({
|
|
71
|
+
discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
|
|
72
|
+
}));
|
|
73
|
+
|
|
68
74
|
vi.mock("../src/session-rules", () => ({
|
|
69
75
|
SessionRules: vi.fn(),
|
|
70
76
|
deriveApprovalPattern: vi.fn(),
|
|
@@ -99,6 +105,10 @@ describe("createExtensionRuntime", () => {
|
|
|
99
105
|
debug: mockLoggerDebug,
|
|
100
106
|
review: mockLoggerReview,
|
|
101
107
|
});
|
|
108
|
+
mockDiscoverGlobalNodeModulesRoot.mockReset();
|
|
109
|
+
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
|
|
110
|
+
"/mock/global/node_modules",
|
|
111
|
+
);
|
|
102
112
|
});
|
|
103
113
|
|
|
104
114
|
// ── Path derivation ──────────────────────────────────────────────────────
|
|
@@ -130,6 +140,41 @@ describe("createExtensionRuntime", () => {
|
|
|
130
140
|
expect(runtime.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
|
|
131
141
|
});
|
|
132
142
|
|
|
143
|
+
// ── piInfrastructureDirs ─────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
it("includes agentDir in piInfrastructureDirs", () => {
|
|
146
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
147
|
+
expect(runtime.piInfrastructureDirs).toContain("/test/agent");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("includes agentDir/git in piInfrastructureDirs", () => {
|
|
151
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
152
|
+
expect(runtime.piInfrastructureDirs).toContain("/test/agent/git");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("includes discovered global node_modules root in piInfrastructureDirs", () => {
|
|
156
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
157
|
+
expect(runtime.piInfrastructureDirs).toContain("/mock/global/node_modules");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("excludes null when discoverGlobalNodeModulesRoot returns null", () => {
|
|
161
|
+
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
|
|
162
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
163
|
+
for (const dir of runtime.piInfrastructureDirs) {
|
|
164
|
+
expect(dir).not.toBeNull();
|
|
165
|
+
expect(typeof dir).toBe("string");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
|
|
170
|
+
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
|
|
171
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
172
|
+
// Only agentDir and agentDir/git should be present.
|
|
173
|
+
expect(runtime.piInfrastructureDirs).toHaveLength(2);
|
|
174
|
+
expect(runtime.piInfrastructureDirs).toContain("/test/agent");
|
|
175
|
+
expect(runtime.piInfrastructureDirs).toContain("/test/agent/git");
|
|
176
|
+
});
|
|
177
|
+
|
|
133
178
|
// ── Default mutable state ────────────────────────────────────────────────
|
|
134
179
|
|
|
135
180
|
it("initializes config to DEFAULT_EXTENSION_CONFIG", () => {
|
|
@@ -21,6 +21,7 @@ describe("SessionRules", () => {
|
|
|
21
21
|
pattern: "/other/project/*",
|
|
22
22
|
action: "allow",
|
|
23
23
|
layer: "session",
|
|
24
|
+
origin: "session",
|
|
24
25
|
},
|
|
25
26
|
]);
|
|
26
27
|
});
|
|
@@ -29,7 +30,12 @@ describe("SessionRules", () => {
|
|
|
29
30
|
const rules = new SessionRules();
|
|
30
31
|
rules.approve("external_directory", "/other/project/*");
|
|
31
32
|
const copy = rules.getRuleset();
|
|
32
|
-
copy.push({
|
|
33
|
+
copy.push({
|
|
34
|
+
surface: "bash",
|
|
35
|
+
pattern: "*",
|
|
36
|
+
action: "deny",
|
|
37
|
+
origin: "session",
|
|
38
|
+
});
|
|
33
39
|
expect(rules.getRuleset()).toHaveLength(1);
|
|
34
40
|
});
|
|
35
41
|
|
|
@@ -23,7 +23,7 @@ function makeManager(
|
|
|
23
23
|
(_surface: string, input: { name?: string }): PermissionCheckResult => {
|
|
24
24
|
const name = input.name ?? "";
|
|
25
25
|
const state = overrides[name] ?? defaultState;
|
|
26
|
-
return { toolName: "skill", state, source: "tool" };
|
|
26
|
+
return { toolName: "skill", state, source: "tool", origin: "builtin" };
|
|
27
27
|
},
|
|
28
28
|
),
|
|
29
29
|
} as unknown as PermissionManager;
|