@gotgenes/pi-permission-system 3.0.0 → 3.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.
@@ -0,0 +1,131 @@
1
+ import type { SkillPromptEntry } from "./skill-prompt-sanitizer.js";
2
+ import { formatToolInputForPrompt } from "./tool-input-preview.js";
3
+ import type { PermissionCheckResult } from "./types.js";
4
+
5
+ export function formatMissingToolNameReason(): string {
6
+ return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
7
+ }
8
+
9
+ export function formatUnknownToolReason(
10
+ toolName: string,
11
+ availableToolNames: readonly string[],
12
+ ): string {
13
+ const preview = availableToolNames.slice(0, 10);
14
+ const suffix = availableToolNames.length > preview.length ? ", ..." : "";
15
+ const availableList =
16
+ preview.length > 0 ? `${preview.join(", ")}${suffix}` : "none";
17
+
18
+ const mcpHint =
19
+ toolName === "mcp"
20
+ ? ""
21
+ : ' If this was intended as an MCP server tool, call the registered \'mcp\' tool when available (for example: {"tool":"server:tool"}).';
22
+
23
+ return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
24
+ }
25
+
26
+ export function formatPermissionHardStopHint(
27
+ result: PermissionCheckResult,
28
+ ): string {
29
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
30
+ return "Hard stop: this MCP permission denial is policy-enforced. Do not retry this target, do not run discovery/investigation to bypass it, and report the block to the user.";
31
+ }
32
+
33
+ return "Hard stop: this permission denial is policy-enforced. Do not retry or investigate bypasses; report the block to the user.";
34
+ }
35
+
36
+ export function formatDenyReason(
37
+ result: PermissionCheckResult,
38
+ agentName?: string,
39
+ ): string {
40
+ const parts: string[] = [];
41
+
42
+ if (agentName) {
43
+ parts.push(`Agent '${agentName}'`);
44
+ }
45
+
46
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
47
+ parts.push(`is not permitted to run MCP target '${result.target}'`);
48
+ } else {
49
+ parts.push(`is not permitted to run '${result.toolName}'`);
50
+ }
51
+
52
+ if (result.command) {
53
+ parts.push(`command '${result.command}'`);
54
+ }
55
+
56
+ if (result.matchedPattern) {
57
+ parts.push(`(matched '${result.matchedPattern}')`);
58
+ }
59
+
60
+ return `${parts.join(" ")}. ${formatPermissionHardStopHint(result)}`;
61
+ }
62
+
63
+ export function formatUserDeniedReason(
64
+ result: PermissionCheckResult,
65
+ denialReason?: string,
66
+ ): string {
67
+ const base =
68
+ (result.source === "mcp" || result.toolName === "mcp") && result.target
69
+ ? `User denied MCP target '${result.target}'.`
70
+ : result.toolName === "bash" && result.command
71
+ ? `User denied bash command '${result.command}'.`
72
+ : `User denied tool '${result.toolName}'.`;
73
+ const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
74
+
75
+ return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
76
+ }
77
+
78
+ export function formatAskPrompt(
79
+ result: PermissionCheckResult,
80
+ agentName?: string,
81
+ input?: unknown,
82
+ ): string {
83
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
84
+
85
+ if (result.toolName === "bash") {
86
+ const patternInfo = result.matchedPattern
87
+ ? ` (matched '${result.matchedPattern}')`
88
+ : "";
89
+ return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
90
+ }
91
+
92
+ if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
93
+ const patternInfo = result.matchedPattern
94
+ ? ` (matched '${result.matchedPattern}')`
95
+ : "";
96
+ return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
97
+ }
98
+
99
+ const patternInfo = result.matchedPattern
100
+ ? ` (matched '${result.matchedPattern}')`
101
+ : "";
102
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
103
+ const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
104
+ return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
105
+ }
106
+
107
+ export function formatSkillAskPrompt(
108
+ skillName: string,
109
+ agentName?: string,
110
+ ): string {
111
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
112
+ return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
113
+ }
114
+
115
+ export function formatSkillPathAskPrompt(
116
+ skill: SkillPromptEntry,
117
+ readPath: string,
118
+ agentName?: string,
119
+ ): string {
120
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
121
+ return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
122
+ }
123
+
124
+ export function formatSkillPathDenyReason(
125
+ skill: SkillPromptEntry,
126
+ readPath: string,
127
+ agentName?: string,
128
+ ): string {
129
+ const subject = agentName ? `Agent '${agentName}'` : "Current agent";
130
+ return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
131
+ }
@@ -0,0 +1,52 @@
1
+ import { normalize } from "node:path";
2
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+
4
+ import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding.js";
5
+
6
+ export function normalizeFilesystemPath(pathValue: string): string {
7
+ const normalizedPath = normalize(pathValue);
8
+ return process.platform === "win32"
9
+ ? normalizedPath.toLowerCase()
10
+ : normalizedPath;
11
+ }
12
+
13
+ function isPathWithinDirectoryForSubagent(
14
+ pathValue: string,
15
+ directory: string,
16
+ ): boolean {
17
+ if (!pathValue || !directory) {
18
+ return false;
19
+ }
20
+
21
+ if (pathValue === directory) {
22
+ return true;
23
+ }
24
+
25
+ const sep = process.platform === "win32" ? "\\" : "/";
26
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
27
+ return pathValue.startsWith(prefix);
28
+ }
29
+
30
+ export function isSubagentExecutionContext(
31
+ ctx: ExtensionContext,
32
+ subagentSessionsDir: string,
33
+ ): boolean {
34
+ for (const key of SUBAGENT_ENV_HINT_KEYS) {
35
+ const value = process.env[key];
36
+ if (typeof value === "string" && value.trim()) {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ const sessionDir = ctx.sessionManager.getSessionDir();
42
+ if (!sessionDir) {
43
+ return false;
44
+ }
45
+
46
+ const normalizedSessionDir = normalizeFilesystemPath(sessionDir);
47
+ const normalizedSubagentRoot = normalizeFilesystemPath(subagentSessionsDir);
48
+ return isPathWithinDirectoryForSubagent(
49
+ normalizedSessionDir,
50
+ normalizedSubagentRoot,
51
+ );
52
+ }
@@ -0,0 +1,206 @@
1
+ import { getNonEmptyString, toRecord } from "./common.js";
2
+ import { safeJsonStringify } from "./logging.js";
3
+ import type { PermissionCheckResult } from "./types.js";
4
+
5
+ export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
6
+ export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
7
+ export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
8
+
9
+ export function truncateInlineText(value: string, maxLength: number): string {
10
+ return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
11
+ }
12
+
13
+ export function sanitizeInlineText(
14
+ value: string,
15
+ maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
16
+ ): string {
17
+ const normalized = value.replace(/\s+/g, " ").trim();
18
+ return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
19
+ }
20
+
21
+ export function countTextLines(value: string): number {
22
+ if (!value) {
23
+ return 0;
24
+ }
25
+
26
+ return value.split(/\r\n|\r|\n/).length;
27
+ }
28
+
29
+ export function formatCount(
30
+ value: number,
31
+ singular: string,
32
+ plural: string,
33
+ ): string {
34
+ return `${value} ${value === 1 ? singular : plural}`;
35
+ }
36
+
37
+ export function getPromptPath(input: Record<string, unknown>): string | null {
38
+ return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
39
+ }
40
+
41
+ export function formatEditInputForPrompt(
42
+ input: Record<string, unknown>,
43
+ ): string {
44
+ const path = getPromptPath(input);
45
+ const rawEdits = Array.isArray(input.edits)
46
+ ? input.edits
47
+ : typeof input.oldText === "string" && typeof input.newText === "string"
48
+ ? [{ oldText: input.oldText, newText: input.newText }]
49
+ : [];
50
+
51
+ const edits = rawEdits
52
+ .map((edit) => toRecord(edit))
53
+ .filter(
54
+ (edit) =>
55
+ typeof edit.oldText === "string" && typeof edit.newText === "string",
56
+ );
57
+
58
+ const pathPart = path ? `for '${path}'` : "";
59
+ if (edits.length === 0) {
60
+ return pathPart ? `${pathPart} with edit input` : "with edit input";
61
+ }
62
+
63
+ const firstEdit = edits[0];
64
+ const oldText = String(firstEdit.oldText);
65
+ const newText = String(firstEdit.newText);
66
+ const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
67
+ const extraEdits =
68
+ edits.length > 1
69
+ ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
70
+ : "";
71
+ const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
72
+ return pathPart ? `${pathPart} ${summary}` : summary;
73
+ }
74
+
75
+ export function formatWriteInputForPrompt(
76
+ input: Record<string, unknown>,
77
+ ): string {
78
+ const path = getPromptPath(input);
79
+ const content = typeof input.content === "string" ? input.content : "";
80
+ const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
81
+ return path ? `for '${path}' ${summary}` : summary;
82
+ }
83
+
84
+ export function formatReadInputForPrompt(
85
+ input: Record<string, unknown>,
86
+ ): string {
87
+ const path = getPromptPath(input);
88
+ const parts = path ? [`path '${path}'`] : [];
89
+ if (typeof input.offset === "number") {
90
+ parts.push(`offset ${input.offset}`);
91
+ }
92
+ if (typeof input.limit === "number") {
93
+ parts.push(`limit ${input.limit}`);
94
+ }
95
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
96
+ }
97
+
98
+ export function formatSearchInputForPrompt(
99
+ toolName: string,
100
+ input: Record<string, unknown>,
101
+ ): string {
102
+ const parts: string[] = [];
103
+ const path = getPromptPath(input);
104
+ const pattern = getNonEmptyString(input.pattern);
105
+ const glob = getNonEmptyString(input.glob);
106
+
107
+ if (pattern) {
108
+ parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
109
+ }
110
+ if (glob) {
111
+ parts.push(`glob '${sanitizeInlineText(glob)}'`);
112
+ }
113
+ if (path) {
114
+ parts.push(`path '${path}'`);
115
+ } else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
116
+ parts.push("current working directory");
117
+ }
118
+
119
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
120
+ }
121
+
122
+ export function serializeToolInputPreview(input: unknown): string {
123
+ const serialized = safeJsonStringify(input);
124
+ if (!serialized || serialized === "{}" || serialized === "null") {
125
+ return "";
126
+ }
127
+
128
+ return serialized.replace(/\s+/g, " ").trim();
129
+ }
130
+
131
+ export function formatJsonInputForPrompt(input: unknown): string {
132
+ const inline = serializeToolInputPreview(input);
133
+ return inline
134
+ ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
135
+ : "";
136
+ }
137
+
138
+ export function formatToolInputForPrompt(
139
+ toolName: string,
140
+ input: unknown,
141
+ ): string {
142
+ const inputRecord = toRecord(input);
143
+
144
+ switch (toolName) {
145
+ case "edit":
146
+ return formatEditInputForPrompt(inputRecord);
147
+ case "write":
148
+ return formatWriteInputForPrompt(inputRecord);
149
+ case "read":
150
+ return formatReadInputForPrompt(inputRecord);
151
+ case "find":
152
+ case "grep":
153
+ case "ls":
154
+ return formatSearchInputForPrompt(toolName, inputRecord);
155
+ default:
156
+ return formatJsonInputForPrompt(input);
157
+ }
158
+ }
159
+
160
+ export function formatGenericToolInputForLog(
161
+ input: unknown,
162
+ ): string | undefined {
163
+ const inline = serializeToolInputPreview(input);
164
+ return inline
165
+ ? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
166
+ : undefined;
167
+ }
168
+
169
+ export function getToolInputPreviewForLog(
170
+ result: PermissionCheckResult,
171
+ input: unknown,
172
+ pathBearingTools: ReadonlySet<string>,
173
+ ): string | undefined {
174
+ if (
175
+ result.toolName === "bash" ||
176
+ result.toolName === "mcp" ||
177
+ result.source === "mcp"
178
+ ) {
179
+ return undefined;
180
+ }
181
+
182
+ if (pathBearingTools.has(result.toolName)) {
183
+ const inputPreview = formatToolInputForPrompt(result.toolName, input);
184
+ return inputPreview
185
+ ? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
186
+ : undefined;
187
+ }
188
+
189
+ return formatGenericToolInputForLog(input);
190
+ }
191
+
192
+ export function getPermissionLogContext(
193
+ result: PermissionCheckResult,
194
+ input: unknown,
195
+ pathBearingTools: ReadonlySet<string>,
196
+ ): { command?: string; target?: string; toolInputPreview?: string } {
197
+ return {
198
+ command: result.command,
199
+ target: result.target,
200
+ toolInputPreview: getToolInputPreviewForLog(
201
+ result,
202
+ input,
203
+ pathBearingTools,
204
+ ),
205
+ };
206
+ }
@@ -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,142 @@
1
+ import { afterEach, beforeEach, 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 mockedCompilePatterns = vi.mocked(compileWildcardPatterns);
24
+ const mockedFindMatch = vi.mocked(findCompiledWildcardMatch);
25
+
26
+ beforeEach(() => {
27
+ mockedCompilePatterns.mockClear();
28
+ mockedFindMatch.mockReset();
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe("BashFilter.check", () => {
36
+ test("returns matched state when wildcard-matcher finds a pattern match", () => {
37
+ mockedFindMatch.mockReturnValue({
38
+ state: "allow",
39
+ matchedPattern: "git *",
40
+ matchedName: "git status",
41
+ });
42
+
43
+ const filter = new BashFilter({ "git *": "allow" }, "ask");
44
+ const result = filter.check("git status");
45
+
46
+ expect(result.state).toBe("allow");
47
+ expect(result.matchedPattern).toBe("git *");
48
+ expect(result.command).toBe("git status");
49
+ expect(mockedFindMatch).toHaveBeenCalledOnce();
50
+ });
51
+
52
+ test("returns default state when wildcard-matcher returns null", () => {
53
+ mockedFindMatch.mockReturnValue(null);
54
+
55
+ const filter = new BashFilter({ "git *": "allow" }, "ask");
56
+ const result = filter.check("npm install");
57
+
58
+ expect(result.state).toBe("ask");
59
+ expect(result.matchedPattern).toBeUndefined();
60
+ expect(result.command).toBe("npm install");
61
+ });
62
+
63
+ test("delegates pattern compilation to compileWildcardPatterns", () => {
64
+ mockedFindMatch.mockReturnValue(null);
65
+
66
+ const permissions: Record<string, PermissionState> = {
67
+ "git *": "allow",
68
+ "npm *": "deny",
69
+ };
70
+ new BashFilter(permissions, "ask");
71
+
72
+ expect(compileWildcardPatterns).toHaveBeenCalledWith(permissions);
73
+ });
74
+
75
+ test("default fallback is the configured defaultState", () => {
76
+ mockedFindMatch.mockReturnValue(null);
77
+
78
+ const denyFilter = new BashFilter({}, "deny");
79
+ expect(denyFilter.check("anything").state).toBe("deny");
80
+
81
+ const allowFilter = new BashFilter({}, "allow");
82
+ expect(allowFilter.check("anything").state).toBe("allow");
83
+ });
84
+
85
+ test("passes command string to findCompiledWildcardMatch", () => {
86
+ mockedFindMatch.mockReturnValue(null);
87
+
88
+ const filter = new BashFilter({}, "ask");
89
+ filter.check("echo hello");
90
+
91
+ expect(mockedFindMatch).toHaveBeenCalledWith(
92
+ expect.any(Array),
93
+ "echo hello",
94
+ );
95
+ });
96
+
97
+ test("empty command falls through to default state", () => {
98
+ mockedFindMatch.mockReturnValue(null);
99
+
100
+ const filter = new BashFilter({}, "ask");
101
+ const result = filter.check("");
102
+
103
+ expect(result.state).toBe("ask");
104
+ expect(result.command).toBe("");
105
+ });
106
+
107
+ test("accepts pre-compiled pattern list instead of permissions object", () => {
108
+ mockedFindMatch.mockReturnValue({
109
+ state: "deny",
110
+ matchedPattern: "rm *",
111
+ matchedName: "rm -rf /",
112
+ });
113
+
114
+ const compiledPatterns = [
115
+ {
116
+ pattern: "rm *",
117
+ state: "deny" as const,
118
+ regex: /^rm .*$/,
119
+ },
120
+ ];
121
+ const filter = new BashFilter(compiledPatterns, "ask");
122
+ const result = filter.check("rm -rf /");
123
+
124
+ // compileWildcardPatterns should NOT be called for a pre-compiled list
125
+ expect(compileWildcardPatterns).not.toHaveBeenCalled();
126
+ expect(result.state).toBe("deny");
127
+ });
128
+
129
+ test("last-match-wins: matched pattern state overrides default", () => {
130
+ mockedFindMatch.mockReturnValue({
131
+ state: "deny",
132
+ matchedPattern: "rm *",
133
+ matchedName: "rm -rf /",
134
+ });
135
+
136
+ const filter = new BashFilter({ "rm *": "deny" }, "allow");
137
+ const result = filter.check("rm -rf /");
138
+
139
+ expect(result.state).toBe("deny");
140
+ expect(result.matchedPattern).toBe("rm *");
141
+ });
142
+ });