@gotgenes/pi-permission-system 3.5.0 → 3.7.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 +38 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +112 -0
- package/src/handlers/index.ts +16 -0
- package/src/handlers/input.ts +97 -0
- package/src/handlers/lifecycle.ts +80 -0
- package/src/handlers/tool-call.ts +400 -0
- package/src/handlers/types.ts +95 -0
- package/src/index.ts +101 -701
- package/src/permission-manager.ts +69 -37
- package/src/rule.ts +58 -0
- package/src/wildcard-matcher.ts +9 -0
- package/tests/handlers/before-agent-start.test.ts +274 -0
- package/tests/handlers/input.test.ts +271 -0
- package/tests/handlers/lifecycle.test.ts +331 -0
- package/tests/handlers/tool-call.test.ts +418 -0
- package/tests/rule.test.ts +158 -0
- package/tests/wildcard-matcher.test.ts +39 -0
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
} from "./common";
|
|
13
13
|
import { loadUnifiedConfig, stripJsonComments } from "./config-loader";
|
|
14
14
|
import { getGlobalConfigPath } from "./config-paths";
|
|
15
|
+
import type { Rule, Ruleset } from "./rule";
|
|
16
|
+
import { evaluate } from "./rule";
|
|
15
17
|
import type {
|
|
16
18
|
AgentPermissions,
|
|
17
19
|
BashPermissions,
|
|
@@ -375,6 +377,8 @@ type ResolvedPermissions = {
|
|
|
375
377
|
globalConfig: GlobalPermissionConfig;
|
|
376
378
|
agentConfig: AgentPermissions;
|
|
377
379
|
merged: GlobalPermissionConfig;
|
|
380
|
+
compiledBash: CompiledPermissionPatterns;
|
|
381
|
+
bashDefault: PermissionState;
|
|
378
382
|
compiledSpecial: CompiledPermissionPatterns;
|
|
379
383
|
compiledSkills: CompiledPermissionPatterns;
|
|
380
384
|
compiledMcp: CompiledPermissionPatterns;
|
|
@@ -403,6 +407,21 @@ function compilePermissionPatternsFromSources(
|
|
|
403
407
|
return compileWildcardPatternEntries(entries);
|
|
404
408
|
}
|
|
405
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Convert compiled wildcard patterns into a Ruleset for use with evaluate().
|
|
412
|
+
* The returned Rule objects are the same references as the input; evaluate()
|
|
413
|
+
* uses reference equality to distinguish an explicit match from the synthetic
|
|
414
|
+
* default it returns when nothing matches.
|
|
415
|
+
*/
|
|
416
|
+
function compiledToRuleset(
|
|
417
|
+
surface: string,
|
|
418
|
+
patterns: CompiledPermissionPatterns,
|
|
419
|
+
): Ruleset {
|
|
420
|
+
return patterns.map(
|
|
421
|
+
(p): Rule => ({ surface, pattern: p.pattern, action: p.state }),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
406
425
|
function findCompiledPermissionMatch(
|
|
407
426
|
patterns: CompiledPermissionPatterns,
|
|
408
427
|
name: string,
|
|
@@ -701,10 +720,18 @@ export class PermissionManager {
|
|
|
701
720
|
projectConfig.tools?.bash ||
|
|
702
721
|
merged.tools?.bash ||
|
|
703
722
|
merged.defaultPolicy.bash;
|
|
723
|
+
const compiledBash = compilePermissionPatternsFromSources(
|
|
724
|
+
globalConfig.bash,
|
|
725
|
+
projectConfig.bash,
|
|
726
|
+
agentConfig.bash,
|
|
727
|
+
projectAgentConfig.bash,
|
|
728
|
+
);
|
|
704
729
|
const value: ResolvedPermissions = {
|
|
705
730
|
globalConfig,
|
|
706
731
|
agentConfig,
|
|
707
732
|
merged,
|
|
733
|
+
compiledBash,
|
|
734
|
+
bashDefault,
|
|
708
735
|
compiledSpecial: compilePermissionPatternsFromSources(
|
|
709
736
|
globalConfig.special,
|
|
710
737
|
projectConfig.special,
|
|
@@ -723,15 +750,7 @@ export class PermissionManager {
|
|
|
723
750
|
agentConfig.mcp,
|
|
724
751
|
projectAgentConfig.mcp,
|
|
725
752
|
),
|
|
726
|
-
bashFilter: new BashFilter(
|
|
727
|
-
compilePermissionPatternsFromSources(
|
|
728
|
-
globalConfig.bash,
|
|
729
|
-
projectConfig.bash,
|
|
730
|
-
agentConfig.bash,
|
|
731
|
-
projectAgentConfig.bash,
|
|
732
|
-
),
|
|
733
|
-
bashDefault,
|
|
734
|
-
),
|
|
753
|
+
bashFilter: new BashFilter(compiledBash, bashDefault),
|
|
735
754
|
};
|
|
736
755
|
|
|
737
756
|
this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
|
|
@@ -804,22 +823,23 @@ export class PermissionManager {
|
|
|
804
823
|
const {
|
|
805
824
|
agentConfig: _agentConfig,
|
|
806
825
|
merged,
|
|
826
|
+
compiledBash,
|
|
827
|
+
bashDefault,
|
|
807
828
|
compiledSpecial,
|
|
808
829
|
compiledSkills,
|
|
809
830
|
compiledMcp,
|
|
810
|
-
bashFilter,
|
|
831
|
+
bashFilter: _bashFilter,
|
|
811
832
|
} = this.resolvePermissions(agentName);
|
|
812
833
|
const normalizedToolName = toolName.trim();
|
|
813
834
|
|
|
814
835
|
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) {
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
);
|
|
836
|
+
const specialRuleset = compiledToRuleset("special", compiledSpecial);
|
|
837
|
+
const rule = evaluate("special", normalizedToolName, specialRuleset);
|
|
838
|
+
const explicit = specialRuleset.includes(rule);
|
|
819
839
|
return {
|
|
820
840
|
toolName,
|
|
821
|
-
state:
|
|
822
|
-
matchedPattern:
|
|
841
|
+
state: explicit ? rule.action : merged.defaultPolicy.special,
|
|
842
|
+
matchedPattern: explicit ? rule.pattern : undefined,
|
|
823
843
|
source: "special",
|
|
824
844
|
};
|
|
825
845
|
}
|
|
@@ -827,15 +847,16 @@ export class PermissionManager {
|
|
|
827
847
|
if (normalizedToolName === "skill") {
|
|
828
848
|
const skillName = toRecord(input).name;
|
|
829
849
|
if (typeof skillName === "string") {
|
|
830
|
-
const
|
|
850
|
+
const skillRuleset = compiledToRuleset("skill", compiledSkills);
|
|
851
|
+
const rule = evaluate("skill", skillName, skillRuleset);
|
|
852
|
+
const explicit = skillRuleset.includes(rule);
|
|
831
853
|
return {
|
|
832
854
|
toolName,
|
|
833
|
-
state:
|
|
834
|
-
matchedPattern:
|
|
855
|
+
state: explicit ? rule.action : merged.defaultPolicy.skills,
|
|
856
|
+
matchedPattern: explicit ? rule.pattern : undefined,
|
|
835
857
|
source: "skill",
|
|
836
858
|
};
|
|
837
859
|
}
|
|
838
|
-
|
|
839
860
|
return {
|
|
840
861
|
toolName,
|
|
841
862
|
state: merged.defaultPolicy.skills,
|
|
@@ -846,13 +867,14 @@ export class PermissionManager {
|
|
|
846
867
|
if (normalizedToolName === "bash") {
|
|
847
868
|
const record = toRecord(input);
|
|
848
869
|
const command = typeof record.command === "string" ? record.command : "";
|
|
849
|
-
const
|
|
850
|
-
|
|
870
|
+
const bashRuleset = compiledToRuleset("bash", compiledBash);
|
|
871
|
+
const rule = evaluate("bash", command, bashRuleset);
|
|
872
|
+
const explicit = bashRuleset.includes(rule);
|
|
851
873
|
return {
|
|
852
874
|
toolName,
|
|
853
|
-
state:
|
|
854
|
-
command
|
|
855
|
-
matchedPattern:
|
|
875
|
+
state: explicit ? rule.action : bashDefault,
|
|
876
|
+
command,
|
|
877
|
+
matchedPattern: explicit ? rule.pattern : undefined,
|
|
856
878
|
source: "bash",
|
|
857
879
|
};
|
|
858
880
|
}
|
|
@@ -868,16 +890,21 @@ export class PermissionManager {
|
|
|
868
890
|
const fallbackTarget = mcpTargets[0] || "mcp";
|
|
869
891
|
const toolLevelMcpState = merged.tools?.mcp;
|
|
870
892
|
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
893
|
+
const mcpRuleset = compiledToRuleset("mcp", compiledMcp);
|
|
894
|
+
let mcpExplicitMatch: { target: string; rule: Rule } | null = null;
|
|
895
|
+
for (const target of mcpTargets) {
|
|
896
|
+
const rule = evaluate("mcp", target, mcpRuleset);
|
|
897
|
+
if (mcpRuleset.includes(rule)) {
|
|
898
|
+
mcpExplicitMatch = { target, rule };
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (mcpExplicitMatch) {
|
|
876
903
|
return {
|
|
877
904
|
toolName,
|
|
878
|
-
state:
|
|
879
|
-
matchedPattern:
|
|
880
|
-
target:
|
|
905
|
+
state: mcpExplicitMatch.rule.action,
|
|
906
|
+
matchedPattern: mcpExplicitMatch.rule.pattern,
|
|
907
|
+
target: mcpExplicitMatch.target,
|
|
881
908
|
source: "mcp",
|
|
882
909
|
};
|
|
883
910
|
}
|
|
@@ -916,19 +943,24 @@ export class PermissionManager {
|
|
|
916
943
|
};
|
|
917
944
|
}
|
|
918
945
|
|
|
946
|
+
const toolRuleset: Ruleset = Object.entries(merged.tools ?? {}).map(
|
|
947
|
+
([name, action]) => ({ surface: "tool", pattern: name, action }),
|
|
948
|
+
);
|
|
949
|
+
const toolRule = evaluate("tool", normalizedToolName, toolRuleset);
|
|
950
|
+
const explicitTool = toolRuleset.includes(toolRule);
|
|
951
|
+
|
|
919
952
|
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(normalizedToolName)) {
|
|
920
953
|
return {
|
|
921
954
|
toolName,
|
|
922
|
-
state:
|
|
955
|
+
state: explicitTool ? toolRule.action : merged.defaultPolicy.tools,
|
|
923
956
|
source: "tool",
|
|
924
957
|
};
|
|
925
958
|
}
|
|
926
959
|
|
|
927
|
-
|
|
928
|
-
if (explicitToolPermission) {
|
|
960
|
+
if (explicitTool) {
|
|
929
961
|
return {
|
|
930
962
|
toolName,
|
|
931
|
-
state:
|
|
963
|
+
state: toolRule.action,
|
|
932
964
|
source: "tool",
|
|
933
965
|
};
|
|
934
966
|
}
|
package/src/rule.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PermissionState } from "./types";
|
|
2
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
3
|
+
|
|
4
|
+
/** A single permission rule — the atomic unit of policy. */
|
|
5
|
+
export interface Rule {
|
|
6
|
+
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
7
|
+
surface: string;
|
|
8
|
+
/** The match pattern: a command glob, tool name, skill name, or "*". */
|
|
9
|
+
pattern: string;
|
|
10
|
+
/** The permission decision. */
|
|
11
|
+
action: PermissionState;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** An ordered list of rules. Later rules take priority (last-match-wins). */
|
|
15
|
+
export type Ruleset = Rule[];
|
|
16
|
+
|
|
17
|
+
const SURFACE_DEFAULTS: Record<string, PermissionState> = {
|
|
18
|
+
tools: "ask",
|
|
19
|
+
bash: "ask",
|
|
20
|
+
mcp: "ask",
|
|
21
|
+
skill: "ask",
|
|
22
|
+
special: "ask",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the default action for a surface when no rules match.
|
|
27
|
+
* Defaults to "ask" for unknown surfaces (least privilege).
|
|
28
|
+
*/
|
|
29
|
+
export function getDefaultAction(surface: string): PermissionState {
|
|
30
|
+
return SURFACE_DEFAULTS[surface] ?? "ask";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure permission evaluation.
|
|
35
|
+
*
|
|
36
|
+
* Flattens all provided rulesets and returns the last rule whose surface and
|
|
37
|
+
* pattern both wildcard-match the supplied values (last-match-wins, so later
|
|
38
|
+
* rulesets / later entries have higher priority).
|
|
39
|
+
*
|
|
40
|
+
* When no rule matches, returns a synthetic rule using getDefaultAction().
|
|
41
|
+
*/
|
|
42
|
+
export function evaluate(
|
|
43
|
+
surface: string,
|
|
44
|
+
pattern: string,
|
|
45
|
+
...rulesets: Ruleset[]
|
|
46
|
+
): Rule {
|
|
47
|
+
const rules = rulesets.flat();
|
|
48
|
+
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
|
49
|
+
const rule = rules[i];
|
|
50
|
+
if (
|
|
51
|
+
wildcardMatch(rule.surface, surface) &&
|
|
52
|
+
wildcardMatch(rule.pattern, pattern)
|
|
53
|
+
) {
|
|
54
|
+
return rule;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { surface, pattern, action: getDefaultAction(surface) };
|
|
58
|
+
}
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -62,6 +62,15 @@ export function findCompiledWildcardMatch<TState>(
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Test whether `value` matches `pattern` using wildcard rules.
|
|
67
|
+
* `*` in the pattern matches any sequence of characters (including empty).
|
|
68
|
+
* Used by evaluate() for rule matching.
|
|
69
|
+
*/
|
|
70
|
+
export function wildcardMatch(pattern: string, value: string): boolean {
|
|
71
|
+
return compileWildcardPattern(pattern, null).regex.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
export function findCompiledWildcardMatchForNames<TState>(
|
|
66
75
|
patterns: readonly CompiledWildcardPattern<TState>[],
|
|
67
76
|
names: readonly string[],
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
handleBeforeAgentStart,
|
|
6
|
+
shouldExposeTool,
|
|
7
|
+
} from "../../src/handlers/before-agent-start";
|
|
8
|
+
import type { HandlerDeps } from "../../src/handlers/types";
|
|
9
|
+
import type { PermissionManager } from "../../src/permission-manager";
|
|
10
|
+
import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
|
|
11
|
+
|
|
12
|
+
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
|
+
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
|
14
|
+
const original =
|
|
15
|
+
await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
|
|
16
|
+
return {
|
|
17
|
+
...original,
|
|
18
|
+
isToolCallEventType: vi.fn().mockReturnValue(false),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
25
|
+
return {
|
|
26
|
+
cwd: "/test/project",
|
|
27
|
+
hasUI: true,
|
|
28
|
+
ui: {
|
|
29
|
+
setStatus: vi.fn(),
|
|
30
|
+
notify: vi.fn(),
|
|
31
|
+
select: vi.fn(),
|
|
32
|
+
input: vi.fn(),
|
|
33
|
+
},
|
|
34
|
+
sessionManager: {
|
|
35
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
36
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
37
|
+
addEntry: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
...overrides,
|
|
40
|
+
} as unknown as ExtensionContext;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeEvent(systemPrompt = "You are an assistant.") {
|
|
44
|
+
return { systemPrompt };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal PermissionManager stub for shouldExposeTool / policy-cache tests. */
|
|
48
|
+
function makePm(
|
|
49
|
+
toolPermission: "allow" | "deny" | "ask" = "allow",
|
|
50
|
+
): PermissionManager {
|
|
51
|
+
return {
|
|
52
|
+
getToolPermission: vi.fn().mockReturnValue(toolPermission),
|
|
53
|
+
getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
|
|
54
|
+
getConfigIssues: vi.fn().mockReturnValue([]),
|
|
55
|
+
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
56
|
+
} as unknown as PermissionManager;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
60
|
+
const pm = makePm();
|
|
61
|
+
return {
|
|
62
|
+
getPermissionManager: vi.fn().mockReturnValue(pm),
|
|
63
|
+
setPermissionManager: vi.fn(),
|
|
64
|
+
getRuntimeContext: vi.fn().mockReturnValue(null),
|
|
65
|
+
setRuntimeContext: vi.fn(),
|
|
66
|
+
getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
|
|
67
|
+
setActiveSkillEntries: vi.fn(),
|
|
68
|
+
getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
|
|
69
|
+
setLastKnownActiveAgentName: vi.fn(),
|
|
70
|
+
getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
|
|
71
|
+
setLastActiveToolsCacheKey: vi.fn(),
|
|
72
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
|
|
73
|
+
setLastPromptStateCacheKey: vi.fn(),
|
|
74
|
+
sessionApprovalCache: {
|
|
75
|
+
approve: vi.fn(),
|
|
76
|
+
has: vi.fn(),
|
|
77
|
+
findMatchingPrefix: vi.fn(),
|
|
78
|
+
clear: vi.fn(),
|
|
79
|
+
} as unknown as HandlerDeps["sessionApprovalCache"],
|
|
80
|
+
createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
|
|
81
|
+
refreshExtensionConfig: vi.fn(),
|
|
82
|
+
notifyWarning: vi.fn(),
|
|
83
|
+
logResolvedConfigPaths: vi.fn(),
|
|
84
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
85
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
86
|
+
promptPermission: vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
89
|
+
createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
|
|
90
|
+
startForwardedPermissionPolling: vi.fn(),
|
|
91
|
+
stopForwardedPermissionPolling: vi.fn(),
|
|
92
|
+
writeReviewLog: vi.fn(),
|
|
93
|
+
writeDebugLog: vi.fn(),
|
|
94
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
95
|
+
setActiveTools: vi.fn(),
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("shouldExposeTool", () => {
|
|
103
|
+
it("returns true when tool permission is allow", () => {
|
|
104
|
+
const pm = makePm("allow");
|
|
105
|
+
expect(shouldExposeTool("read", null, pm)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns true when tool permission is ask", () => {
|
|
109
|
+
const pm = makePm("ask");
|
|
110
|
+
expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns false when tool permission is deny", () => {
|
|
114
|
+
const pm = makePm("deny");
|
|
115
|
+
expect(shouldExposeTool("write", null, pm)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("passes agentName through to getToolPermission", () => {
|
|
119
|
+
const pm = makePm("allow");
|
|
120
|
+
shouldExposeTool("read", "my-agent", pm);
|
|
121
|
+
expect(pm.getToolPermission).toHaveBeenCalledWith("read", "my-agent");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("converts null agentName to undefined for getToolPermission", () => {
|
|
125
|
+
const pm = makePm("allow");
|
|
126
|
+
shouldExposeTool("read", null, pm);
|
|
127
|
+
expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── handleBeforeAgentStart ─────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe("handleBeforeAgentStart", () => {
|
|
134
|
+
it("refreshes extension config with ctx", async () => {
|
|
135
|
+
const ctx = makeCtx();
|
|
136
|
+
const deps = makeDeps();
|
|
137
|
+
await handleBeforeAgentStart(deps, makeEvent(), ctx);
|
|
138
|
+
expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("starts forwarded permission polling", async () => {
|
|
142
|
+
const ctx = makeCtx();
|
|
143
|
+
const deps = makeDeps();
|
|
144
|
+
await handleBeforeAgentStart(deps, makeEvent(), ctx);
|
|
145
|
+
expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("resolves agent name using systemPrompt", async () => {
|
|
149
|
+
const ctx = makeCtx();
|
|
150
|
+
const deps = makeDeps();
|
|
151
|
+
await handleBeforeAgentStart(
|
|
152
|
+
deps,
|
|
153
|
+
makeEvent("<active_agent name='x'>"),
|
|
154
|
+
ctx,
|
|
155
|
+
);
|
|
156
|
+
expect(deps.resolveAgentName).toHaveBeenCalledWith(
|
|
157
|
+
ctx,
|
|
158
|
+
"<active_agent name='x'>",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("filters out denied tools from allowed list", async () => {
|
|
163
|
+
const pm = makePm("deny");
|
|
164
|
+
const deps = makeDeps({
|
|
165
|
+
getPermissionManager: vi.fn().mockReturnValue(pm),
|
|
166
|
+
getAllTools: vi
|
|
167
|
+
.fn()
|
|
168
|
+
.mockReturnValue([{ name: "write" }, { name: "read" }]),
|
|
169
|
+
});
|
|
170
|
+
// write is deny, read is deny (same pm stub — both denied)
|
|
171
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
172
|
+
expect(deps.setActiveTools).toHaveBeenCalledWith([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("includes allowed and ask tools in the active list", async () => {
|
|
176
|
+
const pm = makePm("allow");
|
|
177
|
+
const deps = makeDeps({
|
|
178
|
+
getPermissionManager: vi.fn().mockReturnValue(pm),
|
|
179
|
+
getAllTools: vi
|
|
180
|
+
.fn()
|
|
181
|
+
.mockReturnValue([{ name: "read" }, { name: "write" }]),
|
|
182
|
+
});
|
|
183
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
184
|
+
expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("updates the active-tools cache key after applying", async () => {
|
|
188
|
+
const deps = makeDeps({
|
|
189
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
190
|
+
getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
|
|
191
|
+
});
|
|
192
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
193
|
+
expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledOnce();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("skips setActiveTools when cache key is unchanged", async () => {
|
|
197
|
+
// Pre-populate the cache key to match what would be computed for ["read"]
|
|
198
|
+
const { createActiveToolsCacheKey } = await import(
|
|
199
|
+
"../../src/before-agent-start-cache"
|
|
200
|
+
);
|
|
201
|
+
const key = createActiveToolsCacheKey(["read"]);
|
|
202
|
+
const deps = makeDeps({
|
|
203
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
204
|
+
getLastActiveToolsCacheKey: vi.fn().mockReturnValue(key),
|
|
205
|
+
});
|
|
206
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
207
|
+
expect(deps.setActiveTools).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("updates the prompt-state cache key and returns modified systemPrompt", async () => {
|
|
211
|
+
// Provide a systemPrompt that sanitizeAvailableToolsSection will modify:
|
|
212
|
+
// it strips denied tools from the "Available tools:" section.
|
|
213
|
+
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
|
214
|
+
const deps = makeDeps({
|
|
215
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
216
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
|
|
217
|
+
});
|
|
218
|
+
const result = await handleBeforeAgentStart(
|
|
219
|
+
deps,
|
|
220
|
+
makeEvent(systemPrompt),
|
|
221
|
+
makeCtx(),
|
|
222
|
+
);
|
|
223
|
+
// The prompt was modified, so systemPrompt should be returned
|
|
224
|
+
expect(result).toHaveProperty("systemPrompt");
|
|
225
|
+
expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledOnce();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns empty object when systemPrompt is unchanged", async () => {
|
|
229
|
+
const prompt = "No tools section here.";
|
|
230
|
+
const deps = makeDeps({
|
|
231
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
232
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
|
|
233
|
+
});
|
|
234
|
+
const result = await handleBeforeAgentStart(
|
|
235
|
+
deps,
|
|
236
|
+
makeEvent(prompt),
|
|
237
|
+
makeCtx(),
|
|
238
|
+
);
|
|
239
|
+
expect(result).toEqual({});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("stores resolved skill entries on deps", async () => {
|
|
243
|
+
const deps = makeDeps({
|
|
244
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
245
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
|
|
246
|
+
});
|
|
247
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
248
|
+
expect(deps.setActiveSkillEntries).toHaveBeenCalledOnce();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
|
|
252
|
+
const { createBeforeAgentStartPromptStateKey } = await import(
|
|
253
|
+
"../../src/before-agent-start-cache"
|
|
254
|
+
);
|
|
255
|
+
const pm = makePm("allow");
|
|
256
|
+
const ctx = makeCtx({ cwd: "/proj" });
|
|
257
|
+
const allowedTools: string[] = ["read"];
|
|
258
|
+
const key = createBeforeAgentStartPromptStateKey({
|
|
259
|
+
agentName: null,
|
|
260
|
+
cwd: "/proj",
|
|
261
|
+
permissionStamp: "stamp-1",
|
|
262
|
+
systemPrompt: "hello",
|
|
263
|
+
allowedToolNames: allowedTools,
|
|
264
|
+
});
|
|
265
|
+
const deps = makeDeps({
|
|
266
|
+
getPermissionManager: vi.fn().mockReturnValue(pm),
|
|
267
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
268
|
+
getLastPromptStateCacheKey: vi.fn().mockReturnValue(key),
|
|
269
|
+
});
|
|
270
|
+
const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
|
|
271
|
+
expect(result).toEqual({});
|
|
272
|
+
expect(deps.setActiveSkillEntries).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
});
|