@gotgenes/pi-permission-system 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -35
  3. package/config/config.example.json +6 -0
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +114 -16
  6. package/src/active-agent.ts +58 -0
  7. package/src/config-loader.ts +398 -0
  8. package/src/config-paths.ts +34 -0
  9. package/src/config-reporter.ts +16 -8
  10. package/src/external-directory.ts +113 -0
  11. package/src/forwarded-permissions/io.ts +328 -0
  12. package/src/forwarded-permissions/polling.ts +334 -0
  13. package/src/index.ts +153 -1095
  14. package/src/permission-manager.ts +25 -111
  15. package/src/permission-prompts.ts +131 -0
  16. package/src/subagent-context.ts +52 -0
  17. package/src/tool-input-preview.ts +206 -0
  18. package/tests/active-agent.test.ts +160 -0
  19. package/tests/bash-filter.test.ts +137 -0
  20. package/tests/common.test.ts +189 -0
  21. package/tests/config-loader.test.ts +364 -0
  22. package/tests/config-paths.test.ts +78 -0
  23. package/tests/config-reporter.test.ts +42 -33
  24. package/tests/extension-config.test.ts +51 -0
  25. package/tests/external-directory.test.ts +250 -0
  26. package/tests/permission-prompts.test.ts +301 -0
  27. package/tests/permission-system.test.ts +9 -26
  28. package/tests/session-start.test.ts +8 -33
  29. package/tests/skill-prompt-sanitizer.test.ts +244 -0
  30. package/tests/subagent-context.test.ts +124 -0
  31. package/tests/system-prompt-sanitizer.test.ts +186 -0
  32. package/tests/tool-input-preview.test.ts +452 -0
  33. package/tests/tool-registry.test.ts +155 -0
  34. package/tests/wildcard-matcher.test.ts +180 -0
  35. package/tests/yolo-mode.test.ts +110 -0
@@ -0,0 +1,160 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import {
4
+ ACTIVE_AGENT_TAG_REGEX,
5
+ getActiveAgentName,
6
+ getActiveAgentNameFromSystemPrompt,
7
+ normalizeAgentName,
8
+ } from "../src/active-agent.js";
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ type SessionEntry = {
15
+ type: string;
16
+ customType?: string;
17
+ data?: unknown;
18
+ };
19
+
20
+ function makeCtx(entries: SessionEntry[]): ExtensionContext {
21
+ return {
22
+ sessionManager: {
23
+ getEntries: vi.fn(() => entries),
24
+ },
25
+ } as unknown as ExtensionContext;
26
+ }
27
+
28
+ describe("ACTIVE_AGENT_TAG_REGEX", () => {
29
+ test("matches double-quoted name attribute", () => {
30
+ const match = '<active_agent name="my-agent">'.match(
31
+ ACTIVE_AGENT_TAG_REGEX,
32
+ );
33
+ expect(match?.[1]).toBe("my-agent");
34
+ });
35
+
36
+ test("matches single-quoted name attribute", () => {
37
+ const match = "<active_agent name='my-agent'>".match(
38
+ ACTIVE_AGENT_TAG_REGEX,
39
+ );
40
+ expect(match?.[1]).toBe("my-agent");
41
+ });
42
+
43
+ test("is case-insensitive", () => {
44
+ const match = '<ACTIVE_AGENT name="bot">'.match(ACTIVE_AGENT_TAG_REGEX);
45
+ expect(match?.[1]).toBe("bot");
46
+ });
47
+
48
+ test("does not match when tag is absent", () => {
49
+ expect("no tag here".match(ACTIVE_AGENT_TAG_REGEX)).toBeNull();
50
+ });
51
+ });
52
+
53
+ describe("normalizeAgentName", () => {
54
+ test("returns trimmed string for valid input", () => {
55
+ expect(normalizeAgentName(" my-agent ")).toBe("my-agent");
56
+ });
57
+
58
+ test("returns null for empty string", () => {
59
+ expect(normalizeAgentName("")).toBeNull();
60
+ });
61
+
62
+ test("returns null for whitespace-only string", () => {
63
+ expect(normalizeAgentName(" ")).toBeNull();
64
+ });
65
+
66
+ test("returns null for non-string values", () => {
67
+ expect(normalizeAgentName(null)).toBeNull();
68
+ expect(normalizeAgentName(undefined)).toBeNull();
69
+ expect(normalizeAgentName(42)).toBeNull();
70
+ expect(normalizeAgentName({})).toBeNull();
71
+ });
72
+ });
73
+
74
+ describe("getActiveAgentName", () => {
75
+ test("returns null when session has no entries", () => {
76
+ expect(getActiveAgentName(makeCtx([]))).toBeNull();
77
+ });
78
+
79
+ test("returns null when no active_agent custom entry exists", () => {
80
+ const ctx = makeCtx([{ type: "message", data: { name: "agent" } }]);
81
+ expect(getActiveAgentName(ctx)).toBeNull();
82
+ });
83
+
84
+ test("returns agent name from active_agent entry", () => {
85
+ const ctx = makeCtx([
86
+ { type: "custom", customType: "active_agent", data: { name: "bot" } },
87
+ ]);
88
+ expect(getActiveAgentName(ctx)).toBe("bot");
89
+ });
90
+
91
+ test("last-entry-wins: returns name from the last matching entry", () => {
92
+ const ctx = makeCtx([
93
+ { type: "custom", customType: "active_agent", data: { name: "first" } },
94
+ { type: "custom", customType: "active_agent", data: { name: "last" } },
95
+ ]);
96
+ expect(getActiveAgentName(ctx)).toBe("last");
97
+ });
98
+
99
+ test("entry with name: null resets agent name to null", () => {
100
+ const ctx = makeCtx([
101
+ { type: "custom", customType: "active_agent", data: { name: "bot" } },
102
+ { type: "custom", customType: "active_agent", data: { name: null } },
103
+ ]);
104
+ expect(getActiveAgentName(ctx)).toBeNull();
105
+ });
106
+
107
+ test("skips entries with whitespace-only name and continues scanning", () => {
108
+ const ctx = makeCtx([
109
+ { type: "custom", customType: "active_agent", data: { name: "first" } },
110
+ { type: "custom", customType: "active_agent", data: { name: " " } },
111
+ ]);
112
+ // " " normalizes to null — not a sentinel reset, keeps scanning backwards
113
+ expect(getActiveAgentName(ctx)).toBe("first");
114
+ });
115
+
116
+ test("ignores entries with wrong customType", () => {
117
+ const ctx = makeCtx([
118
+ { type: "custom", customType: "something_else", data: { name: "bot" } },
119
+ ]);
120
+ expect(getActiveAgentName(ctx)).toBeNull();
121
+ });
122
+
123
+ test("ignores entries with wrong type", () => {
124
+ const ctx = makeCtx([
125
+ { type: "tool_call", customType: "active_agent", data: { name: "bot" } },
126
+ ]);
127
+ expect(getActiveAgentName(ctx)).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe("getActiveAgentNameFromSystemPrompt", () => {
132
+ test("returns null for undefined system prompt", () => {
133
+ expect(getActiveAgentNameFromSystemPrompt(undefined)).toBeNull();
134
+ });
135
+
136
+ test("returns null for empty system prompt", () => {
137
+ expect(getActiveAgentNameFromSystemPrompt("")).toBeNull();
138
+ });
139
+
140
+ test("returns null when tag is absent", () => {
141
+ expect(
142
+ getActiveAgentNameFromSystemPrompt("You are a helpful assistant."),
143
+ ).toBeNull();
144
+ });
145
+
146
+ test("extracts agent name from tag in system prompt", () => {
147
+ const prompt = 'You are helpful.\n<active_agent name="my-bot">\nDo work.';
148
+ expect(getActiveAgentNameFromSystemPrompt(prompt)).toBe("my-bot");
149
+ });
150
+
151
+ test("returns null when tag name is empty", () => {
152
+ const prompt = '<active_agent name="">';
153
+ expect(getActiveAgentNameFromSystemPrompt(prompt)).toBeNull();
154
+ });
155
+
156
+ test("trims whitespace from extracted name", () => {
157
+ const prompt = '<active_agent name=" trimmed ">';
158
+ expect(getActiveAgentNameFromSystemPrompt(prompt)).toBe("trimmed");
159
+ });
160
+ });
@@ -0,0 +1,137 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ import type { PermissionState } from "../src/types.js";
4
+
5
+ // Mock wildcard-matcher before importing the module under test.
6
+ vi.mock("../src/wildcard-matcher.js", () => ({
7
+ compileWildcardPatterns: vi.fn((patterns: Record<string, PermissionState>) =>
8
+ Object.entries(patterns).map(([pattern, state]) => ({
9
+ pattern,
10
+ state,
11
+ regex: new RegExp(`^${pattern.replace(/\*/g, ".*")}$`),
12
+ })),
13
+ ),
14
+ findCompiledWildcardMatch: vi.fn(),
15
+ }));
16
+
17
+ import { BashFilter } from "../src/bash-filter.js";
18
+ import {
19
+ compileWildcardPatterns,
20
+ findCompiledWildcardMatch,
21
+ } from "../src/wildcard-matcher.js";
22
+
23
+ const mockedFindMatch = vi.mocked(findCompiledWildcardMatch);
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ describe("BashFilter.check", () => {
31
+ test("returns matched state when wildcard-matcher finds a pattern match", () => {
32
+ mockedFindMatch.mockReturnValue({
33
+ state: "allow",
34
+ matchedPattern: "git *",
35
+ matchedName: "git status",
36
+ });
37
+
38
+ const filter = new BashFilter({ "git *": "allow" }, "ask");
39
+ const result = filter.check("git status");
40
+
41
+ expect(result.state).toBe("allow");
42
+ expect(result.matchedPattern).toBe("git *");
43
+ expect(result.command).toBe("git status");
44
+ expect(mockedFindMatch).toHaveBeenCalledOnce();
45
+ });
46
+
47
+ test("returns default state when wildcard-matcher returns null", () => {
48
+ mockedFindMatch.mockReturnValue(null);
49
+
50
+ const filter = new BashFilter({ "git *": "allow" }, "ask");
51
+ const result = filter.check("npm install");
52
+
53
+ expect(result.state).toBe("ask");
54
+ expect(result.matchedPattern).toBeUndefined();
55
+ expect(result.command).toBe("npm install");
56
+ });
57
+
58
+ test("delegates pattern compilation to compileWildcardPatterns", () => {
59
+ mockedFindMatch.mockReturnValue(null);
60
+
61
+ const permissions: Record<string, PermissionState> = {
62
+ "git *": "allow",
63
+ "npm *": "deny",
64
+ };
65
+ new BashFilter(permissions, "ask");
66
+
67
+ expect(compileWildcardPatterns).toHaveBeenCalledWith(permissions);
68
+ });
69
+
70
+ test("default fallback is the configured defaultState", () => {
71
+ mockedFindMatch.mockReturnValue(null);
72
+
73
+ const denyFilter = new BashFilter({}, "deny");
74
+ expect(denyFilter.check("anything").state).toBe("deny");
75
+
76
+ const allowFilter = new BashFilter({}, "allow");
77
+ expect(allowFilter.check("anything").state).toBe("allow");
78
+ });
79
+
80
+ test("passes command string to findCompiledWildcardMatch", () => {
81
+ mockedFindMatch.mockReturnValue(null);
82
+
83
+ const filter = new BashFilter({}, "ask");
84
+ filter.check("echo hello");
85
+
86
+ expect(mockedFindMatch).toHaveBeenCalledWith(
87
+ expect.any(Array),
88
+ "echo hello",
89
+ );
90
+ });
91
+
92
+ test("empty command falls through to default state", () => {
93
+ mockedFindMatch.mockReturnValue(null);
94
+
95
+ const filter = new BashFilter({}, "ask");
96
+ const result = filter.check("");
97
+
98
+ expect(result.state).toBe("ask");
99
+ expect(result.command).toBe("");
100
+ });
101
+
102
+ test("accepts pre-compiled pattern list instead of permissions object", () => {
103
+ mockedFindMatch.mockReturnValue({
104
+ state: "deny",
105
+ matchedPattern: "rm *",
106
+ matchedName: "rm -rf /",
107
+ });
108
+
109
+ const compiledPatterns = [
110
+ {
111
+ pattern: "rm *",
112
+ state: "deny" as const,
113
+ regex: /^rm .*$/,
114
+ },
115
+ ];
116
+ const filter = new BashFilter(compiledPatterns, "ask");
117
+ const result = filter.check("rm -rf /");
118
+
119
+ // compileWildcardPatterns should NOT be called for a pre-compiled list
120
+ expect(compileWildcardPatterns).not.toHaveBeenCalled();
121
+ expect(result.state).toBe("deny");
122
+ });
123
+
124
+ test("last-match-wins: matched pattern state overrides default", () => {
125
+ mockedFindMatch.mockReturnValue({
126
+ state: "deny",
127
+ matchedPattern: "rm *",
128
+ matchedName: "rm -rf /",
129
+ });
130
+
131
+ const filter = new BashFilter({ "rm *": "deny" }, "allow");
132
+ const result = filter.check("rm -rf /");
133
+
134
+ expect(result.state).toBe("deny");
135
+ expect(result.matchedPattern).toBe("rm *");
136
+ });
137
+ });
@@ -0,0 +1,189 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ import {
4
+ extractFrontmatter,
5
+ getNonEmptyString,
6
+ isPermissionState,
7
+ parseSimpleYamlMap,
8
+ toRecord,
9
+ } from "../src/common.js";
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe("toRecord", () => {
16
+ test("returns empty object for null", () => {
17
+ expect(toRecord(null)).toEqual({});
18
+ });
19
+
20
+ test("returns empty object for undefined", () => {
21
+ expect(toRecord(undefined)).toEqual({});
22
+ });
23
+
24
+ test("returns empty object for a string", () => {
25
+ expect(toRecord("hello")).toEqual({});
26
+ });
27
+
28
+ test("returns empty object for a number", () => {
29
+ expect(toRecord(42)).toEqual({});
30
+ });
31
+
32
+ test("returns empty object for an array", () => {
33
+ expect(toRecord(["a", "b"])).toEqual({});
34
+ });
35
+
36
+ test("returns the object itself for a plain object", () => {
37
+ const input = { a: 1, b: "two" };
38
+ expect(toRecord(input)).toBe(input);
39
+ });
40
+
41
+ test("returns the object for a nested object", () => {
42
+ const input = { x: { y: 3 } };
43
+ expect(toRecord(input)).toBe(input);
44
+ });
45
+ });
46
+
47
+ describe("getNonEmptyString", () => {
48
+ test("returns null for non-string values", () => {
49
+ expect(getNonEmptyString(null)).toBeNull();
50
+ expect(getNonEmptyString(undefined)).toBeNull();
51
+ expect(getNonEmptyString(42)).toBeNull();
52
+ expect(getNonEmptyString({})).toBeNull();
53
+ expect(getNonEmptyString([])).toBeNull();
54
+ });
55
+
56
+ test("returns null for empty string", () => {
57
+ expect(getNonEmptyString("")).toBeNull();
58
+ });
59
+
60
+ test("returns null for whitespace-only string", () => {
61
+ expect(getNonEmptyString(" ")).toBeNull();
62
+ expect(getNonEmptyString("\t\n")).toBeNull();
63
+ });
64
+
65
+ test("returns trimmed string for valid string", () => {
66
+ expect(getNonEmptyString("hello")).toBe("hello");
67
+ expect(getNonEmptyString(" hello ")).toBe("hello");
68
+ });
69
+
70
+ test("returns single non-whitespace character", () => {
71
+ expect(getNonEmptyString("a")).toBe("a");
72
+ });
73
+ });
74
+
75
+ describe("isPermissionState", () => {
76
+ test("returns true for 'allow'", () => {
77
+ expect(isPermissionState("allow")).toBe(true);
78
+ });
79
+
80
+ test("returns true for 'deny'", () => {
81
+ expect(isPermissionState("deny")).toBe(true);
82
+ });
83
+
84
+ test("returns true for 'ask'", () => {
85
+ expect(isPermissionState("ask")).toBe(true);
86
+ });
87
+
88
+ test("returns false for unrecognized strings", () => {
89
+ expect(isPermissionState("ALLOW")).toBe(false);
90
+ expect(isPermissionState("permit")).toBe(false);
91
+ expect(isPermissionState("")).toBe(false);
92
+ expect(isPermissionState("block")).toBe(false);
93
+ });
94
+
95
+ test("returns false for non-string types", () => {
96
+ expect(isPermissionState(null)).toBe(false);
97
+ expect(isPermissionState(undefined)).toBe(false);
98
+ expect(isPermissionState(1)).toBe(false);
99
+ expect(isPermissionState({})).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("extractFrontmatter", () => {
104
+ test("returns empty string when no frontmatter delimiter", () => {
105
+ expect(extractFrontmatter("# Hello\nSome content")).toBe("");
106
+ });
107
+
108
+ test("returns empty string when only opening delimiter with no closing", () => {
109
+ expect(extractFrontmatter("---\nkey: value")).toBe("");
110
+ });
111
+
112
+ test("returns frontmatter body between delimiters", () => {
113
+ const markdown = "---\nissue: 1\ntitle: Test\n---\n# Content";
114
+ expect(extractFrontmatter(markdown)).toBe("issue: 1\ntitle: Test");
115
+ });
116
+
117
+ test("returns empty string when file does not start with ---", () => {
118
+ expect(extractFrontmatter("content\n---\nkey: val\n---")).toBe("");
119
+ });
120
+
121
+ test("handles CRLF line endings", () => {
122
+ const markdown = "---\r\nissue: 5\r\n---\r\n# Content";
123
+ expect(extractFrontmatter(markdown)).toBe("issue: 5");
124
+ });
125
+
126
+ test("returns empty string for empty string input", () => {
127
+ expect(extractFrontmatter("")).toBe("");
128
+ });
129
+
130
+ test("returns empty frontmatter for --- \\n--- with nothing between", () => {
131
+ const markdown = "---\n---\n# Content";
132
+ expect(extractFrontmatter(markdown)).toBe("");
133
+ });
134
+ });
135
+
136
+ describe("parseSimpleYamlMap", () => {
137
+ test("returns empty object for empty string", () => {
138
+ expect(parseSimpleYamlMap("")).toEqual({});
139
+ });
140
+
141
+ test("parses simple key-value pairs", () => {
142
+ const yaml = "issue: 21\ntitle: Test";
143
+ expect(parseSimpleYamlMap(yaml)).toEqual({ issue: "21", title: "Test" });
144
+ });
145
+
146
+ test("strips surrounding quotes from values", () => {
147
+ const yaml = 'title: "My Title"';
148
+ expect(parseSimpleYamlMap(yaml)).toEqual({ title: "My Title" });
149
+
150
+ const yaml2 = "title: 'My Title'";
151
+ expect(parseSimpleYamlMap(yaml2)).toEqual({ title: "My Title" });
152
+ });
153
+
154
+ test("skips lines without colon or with colon at position 0", () => {
155
+ const yaml = "no separator here\n:starts-with-colon: val\nkey: val";
156
+ const result = parseSimpleYamlMap(yaml);
157
+ expect(result.key).toBe("val");
158
+ expect(result["no separator here"]).toBeUndefined();
159
+ });
160
+
161
+ test("skips comment lines", () => {
162
+ const yaml = "# This is a comment\nkey: value";
163
+ expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
164
+ });
165
+
166
+ test("skips blank lines", () => {
167
+ const yaml = "\n\nkey: value\n\n";
168
+ expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
169
+ });
170
+
171
+ test("parses nested map (child indented under parent)", () => {
172
+ const yaml = "parent:\n child: nested_value";
173
+ const result = parseSimpleYamlMap(yaml);
174
+ expect(result.parent).toEqual({ child: "nested_value" });
175
+ });
176
+
177
+ test("handles multi-line values correctly (second line is new key)", () => {
178
+ const yaml = "key1: val1\nkey2: val2";
179
+ const result = parseSimpleYamlMap(yaml);
180
+ expect(result.key1).toBe("val1");
181
+ expect(result.key2).toBe("val2");
182
+ });
183
+
184
+ test("strips quotes from keys", () => {
185
+ const yaml = '"quoted-key": value';
186
+ const result = parseSimpleYamlMap(yaml);
187
+ expect(result["quoted-key"]).toBe("value");
188
+ });
189
+ });