@gotgenes/pi-permission-system 10.0.0 → 10.2.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 +33 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +53 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +83 -27
- package/src/permissions-service.ts +53 -0
- package/src/runtime.ts +1 -37
- package/src/service-lifecycle.ts +49 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +83 -114
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +54 -157
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +151 -186
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +212 -17
- package/test/helpers/handler-fixtures.ts +226 -29
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +211 -105
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +2 -86
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -411
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSkillInputRequestId,
|
|
5
|
+
formatSkillDenyNotice,
|
|
6
|
+
SkillInputGatePipeline,
|
|
7
|
+
} from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
makeGateRunner,
|
|
11
|
+
makeNotifier,
|
|
12
|
+
makeSkillInputInputs,
|
|
13
|
+
} from "#test/helpers/gate-fixtures";
|
|
14
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
15
|
+
|
|
16
|
+
// ── createSkillInputRequestId ─────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("createSkillInputRequestId", () => {
|
|
19
|
+
it("starts with 'skill-input-'", () => {
|
|
20
|
+
expect(createSkillInputRequestId().startsWith("skill-input-")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns a unique id on each call", () => {
|
|
24
|
+
const id1 = createSkillInputRequestId();
|
|
25
|
+
const id2 = createSkillInputRequestId();
|
|
26
|
+
expect(id1).not.toBe(id2);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── formatSkillDenyNotice ─────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("formatSkillDenyNotice", () => {
|
|
33
|
+
it("includes the skill name in the message (no agent)", () => {
|
|
34
|
+
const msg = formatSkillDenyNotice("librarian", null);
|
|
35
|
+
expect(msg).toContain("librarian");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("includes the skill name and agent name when agent is present", () => {
|
|
39
|
+
const msg = formatSkillDenyNotice("librarian", "code-agent");
|
|
40
|
+
expect(msg).toContain("librarian");
|
|
41
|
+
expect(msg).toContain("code-agent");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── SkillInputGatePipeline.evaluate ───────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("SkillInputGatePipeline.evaluate", () => {
|
|
48
|
+
// ── notifier behaviour ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
it("calls notifier.warn when the skill is denied", async () => {
|
|
51
|
+
const inputs = makeSkillInputInputs({
|
|
52
|
+
checkPermission: () => makeCheckResult({ state: "deny" }),
|
|
53
|
+
});
|
|
54
|
+
const notifier = makeNotifier();
|
|
55
|
+
const { runner } = makeGateRunner();
|
|
56
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
57
|
+
|
|
58
|
+
await pipeline.evaluate("librarian", null, notifier, runner);
|
|
59
|
+
|
|
60
|
+
expect(notifier.warn).toHaveBeenCalledOnce();
|
|
61
|
+
expect(notifier.warn).toHaveBeenCalledWith(
|
|
62
|
+
expect.stringContaining("librarian"),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not call notifier.warn when the skill is allowed", async () => {
|
|
67
|
+
const inputs = makeSkillInputInputs({
|
|
68
|
+
checkPermission: () => makeCheckResult({ state: "allow" }),
|
|
69
|
+
});
|
|
70
|
+
const notifier = makeNotifier();
|
|
71
|
+
const { runner } = makeGateRunner();
|
|
72
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
73
|
+
|
|
74
|
+
await pipeline.evaluate("librarian", null, notifier, runner);
|
|
75
|
+
|
|
76
|
+
expect(notifier.warn).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not call notifier.warn when the skill requires approval (ask)", async () => {
|
|
80
|
+
const inputs = makeSkillInputInputs({
|
|
81
|
+
checkPermission: () => makeCheckResult({ state: "ask" }),
|
|
82
|
+
});
|
|
83
|
+
const notifier = makeNotifier();
|
|
84
|
+
const { runner } = makeGateRunner();
|
|
85
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
86
|
+
|
|
87
|
+
await pipeline.evaluate("librarian", null, notifier, runner);
|
|
88
|
+
|
|
89
|
+
expect(notifier.warn).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("includes agent name in the deny notice when agent is present", async () => {
|
|
93
|
+
const inputs = makeSkillInputInputs({
|
|
94
|
+
checkPermission: () => makeCheckResult({ state: "deny" }),
|
|
95
|
+
});
|
|
96
|
+
const notifier = makeNotifier();
|
|
97
|
+
const { runner } = makeGateRunner();
|
|
98
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
99
|
+
|
|
100
|
+
await pipeline.evaluate("librarian", "code-agent", notifier, runner);
|
|
101
|
+
|
|
102
|
+
expect(notifier.warn).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringContaining("code-agent"),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── outcome mapping ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
it("returns allow when the gate passes", async () => {
|
|
110
|
+
const inputs = makeSkillInputInputs({
|
|
111
|
+
checkPermission: () => makeCheckResult({ state: "allow" }),
|
|
112
|
+
});
|
|
113
|
+
const { runner } = makeGateRunner();
|
|
114
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
115
|
+
|
|
116
|
+
const result = await pipeline.evaluate(
|
|
117
|
+
"librarian",
|
|
118
|
+
null,
|
|
119
|
+
makeNotifier(),
|
|
120
|
+
runner,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result).toEqual({ action: "allow" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns block when the gate denies", async () => {
|
|
127
|
+
const inputs = makeSkillInputInputs({
|
|
128
|
+
checkPermission: () =>
|
|
129
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
130
|
+
});
|
|
131
|
+
const { runner } = makeGateRunner();
|
|
132
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
133
|
+
|
|
134
|
+
const result = await pipeline.evaluate(
|
|
135
|
+
"librarian",
|
|
136
|
+
null,
|
|
137
|
+
makeNotifier(),
|
|
138
|
+
runner,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
action: "block",
|
|
143
|
+
reason: expect.stringContaining("librarian"),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── checkPermission call ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
it("calls checkPermission with the skill surface, skill name, and agent name", async () => {
|
|
150
|
+
const inputs = makeSkillInputInputs();
|
|
151
|
+
const { runner } = makeGateRunner();
|
|
152
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
153
|
+
|
|
154
|
+
await pipeline.evaluate("explorer", "code-agent", makeNotifier(), runner);
|
|
155
|
+
|
|
156
|
+
expect(inputs.checkPermission).toHaveBeenCalledWith(
|
|
157
|
+
"skill",
|
|
158
|
+
{ name: "explorer" },
|
|
159
|
+
"code-agent",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("calls checkPermission with undefined agentName when agentName is null", async () => {
|
|
164
|
+
const inputs = makeSkillInputInputs();
|
|
165
|
+
const { runner } = makeGateRunner();
|
|
166
|
+
const pipeline = new SkillInputGatePipeline(inputs);
|
|
167
|
+
|
|
168
|
+
await pipeline.evaluate("explorer", null, makeNotifier(), runner);
|
|
169
|
+
|
|
170
|
+
expect(inputs.checkPermission).toHaveBeenCalledWith(
|
|
171
|
+
"skill",
|
|
172
|
+
{ name: "explorer" },
|
|
173
|
+
undefined,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { describeSkillInputGate } from "#src/handlers/gates/skill-input";
|
|
4
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
5
|
+
|
|
6
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function makeSkillCheck(state: "allow" | "deny" | "ask") {
|
|
9
|
+
return makeCheckResult({
|
|
10
|
+
state,
|
|
11
|
+
toolName: "skill",
|
|
12
|
+
source: "skill",
|
|
13
|
+
origin: "global",
|
|
14
|
+
matchedPattern: "*",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── describeSkillInputGate ─────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe("describeSkillInputGate", () => {
|
|
21
|
+
it("sets surface to 'skill'", () => {
|
|
22
|
+
const descriptor = describeSkillInputGate(
|
|
23
|
+
"librarian",
|
|
24
|
+
null,
|
|
25
|
+
makeSkillCheck("allow"),
|
|
26
|
+
);
|
|
27
|
+
expect(descriptor.surface).toBe("skill");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("sets input.name to the skill name", () => {
|
|
31
|
+
const descriptor = describeSkillInputGate(
|
|
32
|
+
"librarian",
|
|
33
|
+
null,
|
|
34
|
+
makeSkillCheck("allow"),
|
|
35
|
+
);
|
|
36
|
+
expect(descriptor.input).toEqual({ name: "librarian" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes preCheck through verbatim", () => {
|
|
40
|
+
const check = makeSkillCheck("deny");
|
|
41
|
+
const descriptor = describeSkillInputGate("librarian", null, check);
|
|
42
|
+
expect(descriptor.preCheck).toBe(check);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("sets denialContext with kind skill_input and skill name", () => {
|
|
46
|
+
const descriptor = describeSkillInputGate(
|
|
47
|
+
"librarian",
|
|
48
|
+
null,
|
|
49
|
+
makeSkillCheck("allow"),
|
|
50
|
+
);
|
|
51
|
+
expect(descriptor.denialContext).toEqual({
|
|
52
|
+
kind: "skill_input",
|
|
53
|
+
skillName: "librarian",
|
|
54
|
+
agentName: undefined,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("includes agentName in denialContext when provided", () => {
|
|
59
|
+
const descriptor = describeSkillInputGate(
|
|
60
|
+
"librarian",
|
|
61
|
+
"code-agent",
|
|
62
|
+
makeSkillCheck("allow"),
|
|
63
|
+
);
|
|
64
|
+
expect(descriptor.denialContext).toEqual({
|
|
65
|
+
kind: "skill_input",
|
|
66
|
+
skillName: "librarian",
|
|
67
|
+
agentName: "code-agent",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("sets promptDetails source to 'skill_input' with skill name and agent", () => {
|
|
72
|
+
const descriptor = describeSkillInputGate(
|
|
73
|
+
"librarian",
|
|
74
|
+
"code-agent",
|
|
75
|
+
makeSkillCheck("ask"),
|
|
76
|
+
);
|
|
77
|
+
expect(descriptor.promptDetails).toMatchObject({
|
|
78
|
+
source: "skill_input",
|
|
79
|
+
agentName: "code-agent",
|
|
80
|
+
skillName: "librarian",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("includes a non-empty message in promptDetails", () => {
|
|
85
|
+
const descriptor = describeSkillInputGate(
|
|
86
|
+
"librarian",
|
|
87
|
+
null,
|
|
88
|
+
makeSkillCheck("ask"),
|
|
89
|
+
);
|
|
90
|
+
expect(typeof descriptor.promptDetails.message).toBe("string");
|
|
91
|
+
expect(descriptor.promptDetails.message.length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sets logContext source to 'skill_input' with skill name and agent", () => {
|
|
95
|
+
const descriptor = describeSkillInputGate(
|
|
96
|
+
"librarian",
|
|
97
|
+
"code-agent",
|
|
98
|
+
makeSkillCheck("allow"),
|
|
99
|
+
);
|
|
100
|
+
expect(descriptor.logContext).toMatchObject({
|
|
101
|
+
source: "skill_input",
|
|
102
|
+
skillName: "librarian",
|
|
103
|
+
agentName: "code-agent",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("sets decision surface to 'skill' and value to the skill name", () => {
|
|
108
|
+
const descriptor = describeSkillInputGate(
|
|
109
|
+
"my-skill",
|
|
110
|
+
null,
|
|
111
|
+
makeSkillCheck("allow"),
|
|
112
|
+
);
|
|
113
|
+
expect(descriptor.decision).toEqual({
|
|
114
|
+
surface: "skill",
|
|
115
|
+
value: "my-skill",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not set preResolved or sessionApproval", () => {
|
|
120
|
+
const descriptor = describeSkillInputGate(
|
|
121
|
+
"librarian",
|
|
122
|
+
null,
|
|
123
|
+
makeSkillCheck("allow"),
|
|
124
|
+
);
|
|
125
|
+
expect(descriptor.preResolved).toBeUndefined();
|
|
126
|
+
expect(descriptor.sessionApproval).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
makeGateInputs,
|
|
7
|
+
makeGateRunner,
|
|
8
|
+
makeResolver,
|
|
9
|
+
makeTcc,
|
|
10
|
+
} from "#test/helpers/gate-fixtures";
|
|
11
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
12
|
+
|
|
13
|
+
// ── BashProgram.parse mock ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const { mockBashProgramParse } = vi.hoisted(() => ({
|
|
16
|
+
mockBashProgramParse: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("#src/handlers/gates/bash-program", () => ({
|
|
20
|
+
BashProgram: { parse: mockBashProgramParse },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
function makeMockBashProgram() {
|
|
24
|
+
return {
|
|
25
|
+
commands: vi.fn<() => []>(() => []),
|
|
26
|
+
pathTokens: vi.fn<() => []>(() => []),
|
|
27
|
+
externalPaths: vi.fn<() => []>(() => []),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── ToolCallGatePipeline ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe("ToolCallGatePipeline", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mockBashProgramParse.mockReset();
|
|
36
|
+
mockBashProgramParse.mockResolvedValue(makeMockBashProgram());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── non-bash tools ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("evaluate — non-bash tool", () => {
|
|
42
|
+
it("returns allow when all gates pass", async () => {
|
|
43
|
+
const inputs = makeGateInputs();
|
|
44
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
45
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
46
|
+
|
|
47
|
+
const result = await pipeline.evaluate(
|
|
48
|
+
makeTcc({ toolName: "read", input: {} }),
|
|
49
|
+
runner,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ action: "allow" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns block when the tool gate denies", async () => {
|
|
56
|
+
const { resolve } = makeResolver(
|
|
57
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
58
|
+
);
|
|
59
|
+
const inputs = makeGateInputs({ resolve });
|
|
60
|
+
const { runner } = makeGateRunner({ resolve });
|
|
61
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
62
|
+
|
|
63
|
+
const result = await pipeline.evaluate(
|
|
64
|
+
makeTcc({ toolName: "read", input: {} }),
|
|
65
|
+
runner,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result).toMatchObject({ action: "block" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("short-circuits after the first blocking gate without evaluating later ones", async () => {
|
|
72
|
+
const inputs = makeGateInputs();
|
|
73
|
+
const { runner } = makeGateRunner();
|
|
74
|
+
const runSpy = vi
|
|
75
|
+
.spyOn(runner, "run")
|
|
76
|
+
.mockResolvedValue({ action: "block", reason: "first gate blocked" });
|
|
77
|
+
|
|
78
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
79
|
+
const result = await pipeline.evaluate(
|
|
80
|
+
makeTcc({ toolName: "read", input: {} }),
|
|
81
|
+
runner,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({ action: "block", reason: "first gate blocked" });
|
|
85
|
+
// Pipeline looped to the first gate, got block, and stopped — not all 6 gates.
|
|
86
|
+
expect(runSpy).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("calls getToolPreviewLimits() during evaluate", async () => {
|
|
90
|
+
const getToolPreviewLimits = vi.fn(() => ({
|
|
91
|
+
toolInputPreviewMaxLength: 500,
|
|
92
|
+
toolTextSummaryMaxLength: 100,
|
|
93
|
+
toolInputLogPreviewMaxLength: 200,
|
|
94
|
+
}));
|
|
95
|
+
const inputs = makeGateInputs({ getToolPreviewLimits });
|
|
96
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
97
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
98
|
+
|
|
99
|
+
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
|
|
100
|
+
|
|
101
|
+
expect(getToolPreviewLimits).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("calls getInfrastructureReadDirs() during evaluate", async () => {
|
|
105
|
+
const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
|
|
106
|
+
const inputs = makeGateInputs({ getInfrastructureReadDirs });
|
|
107
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
108
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
109
|
+
|
|
110
|
+
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
|
|
111
|
+
|
|
112
|
+
expect(getInfrastructureReadDirs).toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("calls getActiveSkillEntries() during evaluate", async () => {
|
|
116
|
+
const getActiveSkillEntries = vi.fn<() => []>(() => []);
|
|
117
|
+
const inputs = makeGateInputs({ getActiveSkillEntries });
|
|
118
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
119
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
120
|
+
|
|
121
|
+
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
|
|
122
|
+
|
|
123
|
+
expect(getActiveSkillEntries).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not call BashProgram.parse for non-bash tools", async () => {
|
|
127
|
+
const inputs = makeGateInputs();
|
|
128
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
129
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
130
|
+
|
|
131
|
+
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
|
|
132
|
+
|
|
133
|
+
expect(mockBashProgramParse).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── bash tool ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("evaluate — bash tool", () => {
|
|
140
|
+
it("returns allow when the bash command is permitted", async () => {
|
|
141
|
+
const inputs = makeGateInputs();
|
|
142
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
143
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
144
|
+
|
|
145
|
+
const result = await pipeline.evaluate(
|
|
146
|
+
makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
|
|
147
|
+
runner,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(result).toEqual({ action: "allow" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("parses BashProgram exactly once per evaluate for bash tools with a command", async () => {
|
|
154
|
+
const inputs = makeGateInputs();
|
|
155
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
156
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
157
|
+
|
|
158
|
+
await pipeline.evaluate(
|
|
159
|
+
makeTcc({ toolName: "bash", input: { command: "echo hello" } }),
|
|
160
|
+
runner,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(mockBashProgramParse).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(mockBashProgramParse).toHaveBeenCalledWith("echo hello");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not parse BashProgram when the bash command is empty", async () => {
|
|
168
|
+
const inputs = makeGateInputs();
|
|
169
|
+
const { runner } = makeGateRunner({ resolve: inputs.resolve });
|
|
170
|
+
const pipeline = new ToolCallGatePipeline(inputs);
|
|
171
|
+
|
|
172
|
+
await pipeline.evaluate(
|
|
173
|
+
makeTcc({ toolName: "bash", input: { command: "" } }),
|
|
174
|
+
runner,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(mockBashProgramParse).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -172,8 +172,7 @@ describe("handleInput", () => {
|
|
|
172
172
|
},
|
|
173
173
|
});
|
|
174
174
|
await handler.handleInput(makeInputEvent("/skill:librarian"), makeCtx());
|
|
175
|
-
expect(session.
|
|
176
|
-
expect.anything(),
|
|
175
|
+
expect(session.promptPermission).toHaveBeenCalledWith(
|
|
177
176
|
expect.objectContaining({
|
|
178
177
|
agentName: "code-agent",
|
|
179
178
|
skillName: "librarian",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
5
|
+
import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
|
|
5
6
|
|
|
6
7
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
7
8
|
|
|
@@ -15,39 +16,54 @@ vi.mock("../../src/status", () => ({
|
|
|
15
16
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
17
18
|
function makeSession(
|
|
18
|
-
overrides: Partial<
|
|
19
|
-
):
|
|
19
|
+
overrides: Partial<SessionLifecycleSession> = {},
|
|
20
|
+
): SessionLifecycleSession {
|
|
20
21
|
return {
|
|
21
|
-
logger:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
logger: overrides.logger ?? {
|
|
23
|
+
debug: vi.fn<SessionLifecycleSession["logger"]["debug"]>(),
|
|
24
|
+
review: vi.fn<SessionLifecycleSession["logger"]["review"]>(),
|
|
25
|
+
warn: vi.fn<SessionLifecycleSession["logger"]["warn"]>(),
|
|
26
|
+
},
|
|
27
|
+
refreshConfig:
|
|
28
|
+
overrides.refreshConfig ??
|
|
29
|
+
vi.fn<SessionLifecycleSession["refreshConfig"]>(),
|
|
30
|
+
resetForNewSession:
|
|
31
|
+
overrides.resetForNewSession ??
|
|
32
|
+
vi.fn<SessionLifecycleSession["resetForNewSession"]>(),
|
|
33
|
+
logResolvedConfigPaths:
|
|
34
|
+
overrides.logResolvedConfigPaths ??
|
|
35
|
+
vi.fn<SessionLifecycleSession["logResolvedConfigPaths"]>(),
|
|
36
|
+
resolveAgentName:
|
|
37
|
+
overrides.resolveAgentName ??
|
|
38
|
+
vi
|
|
39
|
+
.fn<SessionLifecycleSession["resolveAgentName"]>()
|
|
40
|
+
.mockReturnValue(null),
|
|
41
|
+
getConfigIssues:
|
|
42
|
+
overrides.getConfigIssues ??
|
|
43
|
+
vi.fn<SessionLifecycleSession["getConfigIssues"]>().mockReturnValue([]),
|
|
44
|
+
reload: overrides.reload ?? vi.fn<SessionLifecycleSession["reload"]>(),
|
|
45
|
+
getRuntimeContext:
|
|
46
|
+
overrides.getRuntimeContext ??
|
|
47
|
+
vi
|
|
48
|
+
.fn<SessionLifecycleSession["getRuntimeContext"]>()
|
|
49
|
+
.mockReturnValue(null),
|
|
50
|
+
shutdown:
|
|
51
|
+
overrides.shutdown ?? vi.fn<SessionLifecycleSession["shutdown"]>(),
|
|
52
|
+
};
|
|
32
53
|
}
|
|
33
54
|
|
|
34
|
-
function makeHandler(
|
|
35
|
-
overrides?: Partial<Record<keyof PermissionSession, unknown>>,
|
|
36
|
-
): {
|
|
55
|
+
function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
|
|
37
56
|
handler: SessionLifecycleHandler;
|
|
38
|
-
session:
|
|
39
|
-
|
|
40
|
-
cleanupRpc: ReturnType<typeof vi.fn>;
|
|
57
|
+
session: SessionLifecycleSession;
|
|
58
|
+
serviceLifecycle: ServiceLifecycle;
|
|
41
59
|
} {
|
|
42
60
|
const session = makeSession(overrides);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
);
|
|
50
|
-
return { handler, session, activateService, cleanupRpc };
|
|
61
|
+
const serviceLifecycle: ServiceLifecycle = {
|
|
62
|
+
activate: vi.fn<ServiceLifecycle["activate"]>(),
|
|
63
|
+
teardown: vi.fn<ServiceLifecycle["teardown"]>(),
|
|
64
|
+
};
|
|
65
|
+
const handler = new SessionLifecycleHandler(session, serviceLifecycle);
|
|
66
|
+
return { handler, session, serviceLifecycle };
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
// ── handleSessionStart ─────────────────────────────────────────────────────
|
|
@@ -114,9 +130,9 @@ describe("handleSessionStart", () => {
|
|
|
114
130
|
|
|
115
131
|
it("activates the service for the session with ctx", async () => {
|
|
116
132
|
const ctx = makeCtx();
|
|
117
|
-
const { handler,
|
|
133
|
+
const { handler, serviceLifecycle } = makeHandler();
|
|
118
134
|
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
119
|
-
expect(
|
|
135
|
+
expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
it("calls refreshConfig before resetForNewSession", async () => {
|
|
@@ -195,9 +211,9 @@ describe("handleSessionShutdown", () => {
|
|
|
195
211
|
expect(session.shutdown).toHaveBeenCalledOnce();
|
|
196
212
|
});
|
|
197
213
|
|
|
198
|
-
it("calls
|
|
199
|
-
const { handler,
|
|
214
|
+
it("calls serviceLifecycle.teardown", async () => {
|
|
215
|
+
const { handler, serviceLifecycle } = makeHandler();
|
|
200
216
|
await handler.handleSessionShutdown();
|
|
201
|
-
expect(
|
|
217
|
+
expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
|
|
202
218
|
});
|
|
203
219
|
});
|
|
@@ -209,7 +209,7 @@ describe("handleToolCall decision events — infrastructure_auto_allowed", () =>
|
|
|
209
209
|
const { handler, events } = makeHandler({
|
|
210
210
|
session: {
|
|
211
211
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
|
|
212
|
-
|
|
212
|
+
getInfrastructureReadDirs: vi.fn().mockReturnValue([infraDir]),
|
|
213
213
|
},
|
|
214
214
|
});
|
|
215
215
|
|