@aliou/pi-guardrails 0.12.1 → 0.13.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/README.md +8 -6
- package/extensions/path-access/index.ts +12 -0
- package/extensions/permission-gate/index.ts +12 -0
- package/package.json +9 -7
- package/src/shared/events.ts +28 -0
- package/extensions/guardrails/rules.test.ts +0 -107
- package/extensions/guardrails/targets.test.ts +0 -44
- package/extensions/path-access/grants.test.ts +0 -47
- package/extensions/path-access/rules.test.ts +0 -46
- package/extensions/path-access/targets.test.ts +0 -40
- package/extensions/permission-gate/rules.test.ts +0 -132
- package/src/core/check.test.ts +0 -169
- package/src/core/commands/dangerous.test.ts +0 -468
- package/src/core/paths/access.test.ts +0 -150
- package/src/core/paths/path.test.ts +0 -293
- package/src/core/shell/command-args.test.ts +0 -142
- package/src/shared/matching.test.ts +0 -86
- package/src/shared/paths/bash-paths.test.ts +0 -171
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
# Guardrails
|
|
4
4
|
|
|
@@ -24,7 +24,7 @@ After installing, run the onboarding command to choose a starting setup:
|
|
|
24
24
|
/guardrails:onboarding
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/onboarding.mp4)
|
|
28
28
|
|
|
29
29
|
You can change everything later with:
|
|
30
30
|
|
|
@@ -40,7 +40,7 @@ The `guardrails` extension owns file protection policies and the user-facing com
|
|
|
40
40
|
|
|
41
41
|
Use it to protect files like `.env`, private keys, local credentials, generated logs, database dumps, or any project-specific path you do not want Pi to read or modify without clear intent.
|
|
42
42
|
|
|
43
|
-
](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/policies.mp4)
|
|
44
44
|
|
|
45
45
|
Useful commands:
|
|
46
46
|
|
|
@@ -56,7 +56,7 @@ The `path-access` extension checks tool calls that target paths outside the curr
|
|
|
56
56
|
|
|
57
57
|
It can allow, block, or ask before Pi accesses files elsewhere on your machine. In ask mode, you can allow one file or a directory once, for the session, or always.
|
|
58
58
|
|
|
59
|
-
](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.mp4)
|
|
60
60
|
|
|
61
61
|
### permission-gate
|
|
62
62
|
|
|
@@ -64,7 +64,7 @@ The `permission-gate` extension detects dangerous bash commands before they run.
|
|
|
64
64
|
|
|
65
65
|
It catches built-in risky patterns like recursive deletes, privileged commands, disk formatting, broad permission changes, and configured custom patterns. You can allow once, allow for the session, deny, or configure auto-deny rules.
|
|
66
66
|
|
|
67
|
-
](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/permission-gate.mp4)
|
|
68
68
|
|
|
69
69
|
## Configuration
|
|
70
70
|
|
|
@@ -89,7 +89,7 @@ Use the examples command to add common policy and command presets without replac
|
|
|
89
89
|
/guardrails:examples
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/examples.mp4)
|
|
93
93
|
|
|
94
94
|
The available presets live in [`extensions/guardrails/commands/settings/examples.ts`](extensions/guardrails/commands/settings/examples.ts).
|
|
95
95
|
|
|
@@ -97,6 +97,8 @@ The available presets live in [`extensions/guardrails/commands/settings/examples
|
|
|
97
97
|
|
|
98
98
|
Pi is designed to make agent safety extensible. Guardrails focuses on deterministic, configurable file policies, outside-workspace path access, and dangerous-command prompts. Other packages tend to fall into two useful groups.
|
|
99
99
|
|
|
100
|
+
See [pi.dev/packages](https://pi.dev/packages) for the full registry of Pi extensions.
|
|
101
|
+
|
|
100
102
|
### Make one yourself!
|
|
101
103
|
|
|
102
104
|
If Guardrails or the alternatives below do not fit your needs, you can also make your own. Start from the [Pi permission gate example](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/permission-gate.ts), then ask Pi to customize it for your workflow.
|
|
@@ -9,6 +9,7 @@ import { configLoader } from "../../src/shared/config";
|
|
|
9
9
|
import {
|
|
10
10
|
createFeatureRegisterPayload,
|
|
11
11
|
emitActionBlocked,
|
|
12
|
+
emitActionPrompted,
|
|
12
13
|
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
13
14
|
GUARDRAILS_FEATURE_REQUEST_EVENT,
|
|
14
15
|
} from "../../src/shared/events";
|
|
@@ -85,6 +86,17 @@ export default async function pathAccess(pi: ExtensionAPI) {
|
|
|
85
86
|
const parentDir = dirname(absolutePath);
|
|
86
87
|
const showFileOptions =
|
|
87
88
|
event.toolName !== "ls" && event.toolName !== "find";
|
|
89
|
+
emitActionPrompted(pi, {
|
|
90
|
+
feature: "pathAccess",
|
|
91
|
+
action: safety.action,
|
|
92
|
+
reason: safety.reason,
|
|
93
|
+
prompt: {
|
|
94
|
+
kind: "confirmation",
|
|
95
|
+
metadata: safety.metadata,
|
|
96
|
+
},
|
|
97
|
+
context: { toolName: event.toolName, input },
|
|
98
|
+
});
|
|
99
|
+
|
|
88
100
|
const result = await ctx.ui.custom<PromptResult>(
|
|
89
101
|
createPathAccessPromptComponent(
|
|
90
102
|
event.toolName,
|
|
@@ -7,6 +7,7 @@ import { configLoader } from "../../src/shared/config";
|
|
|
7
7
|
import {
|
|
8
8
|
createFeatureRegisterPayload,
|
|
9
9
|
emitActionBlocked,
|
|
10
|
+
emitActionPrompted,
|
|
10
11
|
emitRiskDetected,
|
|
11
12
|
GUARDRAILS_FEATURE_REGISTER_EVENT,
|
|
12
13
|
GUARDRAILS_FEATURE_REQUEST_EVENT,
|
|
@@ -89,6 +90,17 @@ export default async function permissionGate(pi: ExtensionAPI) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
type ConfirmResult = "allow" | "allow-session" | "deny";
|
|
93
|
+
emitActionPrompted(pi, {
|
|
94
|
+
feature: "permissionGate",
|
|
95
|
+
action: safety.action,
|
|
96
|
+
reason: safety.reason,
|
|
97
|
+
prompt: {
|
|
98
|
+
kind: "permission",
|
|
99
|
+
metadata: safety.metadata,
|
|
100
|
+
},
|
|
101
|
+
context: { toolName: "bash", input: event.input },
|
|
102
|
+
});
|
|
103
|
+
|
|
92
104
|
let result = await ctx.ui.custom<ConfirmResult>(
|
|
93
105
|
createPermissionGateConfirmComponent(command, safety.reason),
|
|
94
106
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-guardrails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"./extensions/guardrails/index.ts",
|
|
22
22
|
"./extensions/permission-gate/index.ts"
|
|
23
23
|
],
|
|
24
|
-
"video": "https://assets.aliou.me/
|
|
24
|
+
"video": "https://assets.aliou.me/github/aliou/pi-guardrails/demo.mp4"
|
|
25
25
|
},
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public"
|
|
@@ -30,19 +30,21 @@
|
|
|
30
30
|
"src",
|
|
31
31
|
"extensions",
|
|
32
32
|
"README.md",
|
|
33
|
-
"schema.json"
|
|
33
|
+
"schema.json",
|
|
34
|
+
"!src/**/*.test.ts",
|
|
35
|
+
"!extensions/**/*.test.ts"
|
|
34
36
|
],
|
|
35
37
|
"dependencies": {
|
|
36
38
|
"@aliou/pi-utils-settings": "^0.15.1",
|
|
37
39
|
"@aliou/sh": "^0.1.0"
|
|
38
40
|
},
|
|
39
41
|
"peerDependencies": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": "
|
|
41
|
-
"@earendil-works/pi-tui": "
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
44
|
-
"@aliou/biome-plugins": "^0.
|
|
45
|
-
"@biomejs/biome": "^2.
|
|
46
|
+
"@aliou/biome-plugins": "^0.8.1",
|
|
47
|
+
"@biomejs/biome": "^2.4.15",
|
|
46
48
|
"@changesets/cli": "^2.27.11",
|
|
47
49
|
"@earendil-works/pi-coding-agent": "0.74.0",
|
|
48
50
|
"@earendil-works/pi-tui": "0.74.0",
|
package/src/shared/events.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const GUARDRAILS_ACTION_BLOCKED_EVENT = "guardrails:action:blocked";
|
|
|
5
5
|
export const GUARDRAILS_RISK_DETECTED_EVENT = "guardrails:risk:detected";
|
|
6
6
|
export const GUARDRAILS_FEATURE_REQUEST_EVENT = "guardrails:feature:request";
|
|
7
7
|
export const GUARDRAILS_FEATURE_REGISTER_EVENT = "guardrails:feature:register";
|
|
8
|
+
export const GUARDRAILS_ACTION_PROMPTED_EVENT = "guardrails:action:prompted";
|
|
8
9
|
|
|
9
10
|
export type GuardrailsFeatureId = "policies" | "permissionGate" | "pathAccess";
|
|
10
11
|
|
|
@@ -47,6 +48,22 @@ export type GuardrailsActionBlockedPayload<TMeta = unknown> =
|
|
|
47
48
|
};
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
export type GuardrailsActionPromptedPayload<TMeta = unknown> =
|
|
52
|
+
GuardrailsEventBase & {
|
|
53
|
+
action: Action;
|
|
54
|
+
reason: string;
|
|
55
|
+
prompt: {
|
|
56
|
+
/** What kind of prompt was shown */
|
|
57
|
+
kind: "confirmation" | "permission";
|
|
58
|
+
/** The feature-specific metadata about the risk */
|
|
59
|
+
metadata?: TMeta;
|
|
60
|
+
};
|
|
61
|
+
context?: {
|
|
62
|
+
toolName?: string;
|
|
63
|
+
input?: Record<string, unknown>;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
50
67
|
export type GuardrailsRiskDetectedPayload<TMeta = unknown> =
|
|
51
68
|
GuardrailsEventBase & {
|
|
52
69
|
risk: Safety<TMeta> & { kind: "dangerous" };
|
|
@@ -98,3 +115,14 @@ export function emitRiskDetected<TMeta = unknown>(
|
|
|
98
115
|
...event,
|
|
99
116
|
});
|
|
100
117
|
}
|
|
118
|
+
|
|
119
|
+
export function emitActionPrompted<TMeta = unknown>(
|
|
120
|
+
pi: ExtensionAPI,
|
|
121
|
+
event: Omit<GuardrailsActionPromptedPayload<TMeta>, "source" | "timestamp">,
|
|
122
|
+
): void {
|
|
123
|
+
pi.events.emit(GUARDRAILS_ACTION_PROMPTED_EVENT, {
|
|
124
|
+
source: "guardrails",
|
|
125
|
+
timestamp: timestamp(),
|
|
126
|
+
...event,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { vol } from "memfs";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { compilePolicies, createPolicyRules, normalizeTarget } from "./rules";
|
|
5
|
-
|
|
6
|
-
function singleRule(
|
|
7
|
-
cwd: string,
|
|
8
|
-
policy: Parameters<typeof compilePolicies>[0][number],
|
|
9
|
-
) {
|
|
10
|
-
const [rule] = createPolicyRules(compilePolicies([policy]), cwd);
|
|
11
|
-
return rule;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("normalizeTarget", () => {
|
|
15
|
-
it("prefers cwd-relative paths for targets inside cwd", () => {
|
|
16
|
-
const cwd = "/repo";
|
|
17
|
-
expect(normalizeTarget("/repo/config/locked.json", cwd)).toBe(
|
|
18
|
-
"config/locked.json",
|
|
19
|
-
);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe("compilePolicies", () => {
|
|
24
|
-
it("skips disabled and empty rules", () => {
|
|
25
|
-
const policies = compilePolicies([
|
|
26
|
-
{
|
|
27
|
-
id: "disabled",
|
|
28
|
-
name: "Disabled",
|
|
29
|
-
enabled: false,
|
|
30
|
-
patterns: [{ pattern: "*.env" }],
|
|
31
|
-
protection: "noAccess",
|
|
32
|
-
},
|
|
33
|
-
{ id: "empty", name: "Empty", patterns: [], protection: "noAccess" },
|
|
34
|
-
{
|
|
35
|
-
id: "active",
|
|
36
|
-
name: "Active",
|
|
37
|
-
patterns: [{ pattern: "*.env" }],
|
|
38
|
-
protection: "readOnly",
|
|
39
|
-
},
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
expect(policies.map((policy) => policy.id)).toEqual(["active"]);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("createPolicyRules", () => {
|
|
47
|
-
const cwd = "/repo";
|
|
48
|
-
|
|
49
|
-
it("matches protected files and returns policy metadata", async () => {
|
|
50
|
-
vol.fromJSON({ "/repo/.env": "SECRET=1" });
|
|
51
|
-
const rule = singleRule(cwd, {
|
|
52
|
-
id: "secrets",
|
|
53
|
-
name: "Secrets",
|
|
54
|
-
patterns: [{ pattern: ".env" }],
|
|
55
|
-
protection: "noAccess",
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
await expect(
|
|
59
|
-
rule.check({ kind: "file", path: join(cwd, ".env") }),
|
|
60
|
-
).resolves.toMatchObject({
|
|
61
|
-
kind: "match",
|
|
62
|
-
metadata: { ruleId: "secrets", protection: "noAccess", path: ".env" },
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("passes allowed patterns", async () => {
|
|
67
|
-
vol.fromJSON({ "/repo/.env.example": "SECRET=" });
|
|
68
|
-
const rule = singleRule(cwd, {
|
|
69
|
-
id: "secrets",
|
|
70
|
-
name: "Secrets",
|
|
71
|
-
patterns: [{ pattern: ".env*" }],
|
|
72
|
-
allowedPatterns: [{ pattern: ".env.example" }],
|
|
73
|
-
protection: "noAccess",
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
await expect(
|
|
77
|
-
rule.check({ kind: "file", path: join(cwd, ".env.example") }),
|
|
78
|
-
).resolves.toEqual({ kind: "pass" });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("passes missing files when onlyIfExists is true", async () => {
|
|
82
|
-
const rule = singleRule(cwd, {
|
|
83
|
-
id: "secrets",
|
|
84
|
-
name: "Secrets",
|
|
85
|
-
patterns: [{ pattern: ".env" }],
|
|
86
|
-
protection: "noAccess",
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
await expect(
|
|
90
|
-
rule.check({ kind: "file", path: join(cwd, ".env") }),
|
|
91
|
-
).resolves.toEqual({ kind: "pass" });
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("matches missing files when onlyIfExists is false", async () => {
|
|
95
|
-
const rule = singleRule(cwd, {
|
|
96
|
-
id: "secrets",
|
|
97
|
-
name: "Secrets",
|
|
98
|
-
patterns: [{ pattern: ".env" }],
|
|
99
|
-
protection: "noAccess",
|
|
100
|
-
onlyIfExists: false,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
await expect(
|
|
104
|
-
rule.check({ kind: "file", path: join(cwd, ".env") }),
|
|
105
|
-
).resolves.toMatchObject({ kind: "match" });
|
|
106
|
-
});
|
|
107
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { vol } from "memfs";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { compilePolicies } from "./rules";
|
|
5
|
-
import { extractTargets } from "./targets";
|
|
6
|
-
|
|
7
|
-
describe("extractTargets", () => {
|
|
8
|
-
it("returns direct file tool targets", async () => {
|
|
9
|
-
await expect(
|
|
10
|
-
extractTargets(
|
|
11
|
-
{ toolName: "read", input: { path: "config/locked.json" } },
|
|
12
|
-
"/repo",
|
|
13
|
-
[],
|
|
14
|
-
),
|
|
15
|
-
).resolves.toEqual(["config/locked.json"]);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("extracts only bash targets matching configured policies", async () => {
|
|
19
|
-
const cwd = "/repo";
|
|
20
|
-
vol.fromJSON({
|
|
21
|
-
"/repo/config/locked.json": "{}",
|
|
22
|
-
"/repo/README.md": "hello",
|
|
23
|
-
});
|
|
24
|
-
const policies = compilePolicies([
|
|
25
|
-
{
|
|
26
|
-
id: "locked",
|
|
27
|
-
name: "Locked",
|
|
28
|
-
patterns: [{ pattern: "config/locked.json" }],
|
|
29
|
-
protection: "readOnly",
|
|
30
|
-
},
|
|
31
|
-
]);
|
|
32
|
-
|
|
33
|
-
await expect(
|
|
34
|
-
extractTargets(
|
|
35
|
-
{
|
|
36
|
-
toolName: "bash",
|
|
37
|
-
input: { command: "cat README.md config/locked.json" },
|
|
38
|
-
},
|
|
39
|
-
cwd,
|
|
40
|
-
policies,
|
|
41
|
-
),
|
|
42
|
-
).resolves.toEqual([join("config", "locked.json")]);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
createPendingGrant,
|
|
5
|
-
isGrantTooBroad,
|
|
6
|
-
pendingAllowedPaths,
|
|
7
|
-
resolveAllowedPaths,
|
|
8
|
-
} from "./grants";
|
|
9
|
-
|
|
10
|
-
describe("path access grants", () => {
|
|
11
|
-
it("resolves allowed paths relative to cwd", () => {
|
|
12
|
-
expect(resolveAllowedPaths(["../shared", "logs/"], "/repo/app")).toEqual([
|
|
13
|
-
"/repo/shared",
|
|
14
|
-
"/repo/app/logs/",
|
|
15
|
-
]);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("converts pending grants to absolute allowed paths", () => {
|
|
19
|
-
expect(
|
|
20
|
-
pendingAllowedPaths([
|
|
21
|
-
{
|
|
22
|
-
storagePath: "/tmp/file.txt",
|
|
23
|
-
absolutePath: "/tmp/file.txt",
|
|
24
|
-
scope: "memory",
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
storagePath: "/tmp/logs/",
|
|
28
|
-
absolutePath: "/tmp/logs",
|
|
29
|
-
scope: "local",
|
|
30
|
-
},
|
|
31
|
-
]),
|
|
32
|
-
).toEqual(["/tmp/file.txt", "/tmp/logs/"]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("rejects home grants as too broad", () => {
|
|
36
|
-
expect(isGrantTooBroad(`${homedir()}/`)).toBe(true);
|
|
37
|
-
expect(isGrantTooBroad(`${homedir()}/project`)).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("creates pending grants with storage form", () => {
|
|
41
|
-
expect(createPendingGrant("/tmp/logs", true, "local")).toEqual({
|
|
42
|
-
absolutePath: "/tmp/logs",
|
|
43
|
-
scope: "local",
|
|
44
|
-
storagePath: "/tmp/logs/",
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createPathAccessRule } from "./rules";
|
|
3
|
-
|
|
4
|
-
const cwd = "/repo";
|
|
5
|
-
const state = (allowedPaths: string[] = []) => ({
|
|
6
|
-
cwd,
|
|
7
|
-
mode: "block" as const,
|
|
8
|
-
allowedPaths,
|
|
9
|
-
hasUI: true,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
describe("createPathAccessRule", () => {
|
|
13
|
-
it("passes command actions", () => {
|
|
14
|
-
const rule = createPathAccessRule(state());
|
|
15
|
-
expect(rule.check({ kind: "command", command: "cat /tmp/a" })).toEqual({
|
|
16
|
-
kind: "pass",
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("passes files inside cwd", () => {
|
|
21
|
-
const rule = createPathAccessRule(state());
|
|
22
|
-
expect(rule.check({ kind: "file", path: "/repo/src/index.ts" })).toEqual({
|
|
23
|
-
kind: "pass",
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("matches outside files in block mode", () => {
|
|
28
|
-
const rule = createPathAccessRule(state());
|
|
29
|
-
expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toMatchObject(
|
|
30
|
-
{
|
|
31
|
-
kind: "match",
|
|
32
|
-
metadata: {
|
|
33
|
-
absolutePath: "/tmp/secret.txt",
|
|
34
|
-
displayPath: "/tmp/secret.txt",
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("passes explicitly allowed outside paths", () => {
|
|
41
|
-
const rule = createPathAccessRule(state(["/tmp/"]));
|
|
42
|
-
expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toEqual({
|
|
43
|
-
kind: "pass",
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { vol } from "memfs";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { targetsForTool } from "./targets";
|
|
5
|
-
|
|
6
|
-
describe("targetsForTool", () => {
|
|
7
|
-
it("resolves direct file tool targets from cwd", async () => {
|
|
8
|
-
await expect(
|
|
9
|
-
targetsForTool("read", { path: "README.md" }, "/repo"),
|
|
10
|
-
).resolves.toEqual(["/repo/README.md"]);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("extracts bash path candidates", async () => {
|
|
14
|
-
const cwd = "/repo";
|
|
15
|
-
vol.fromJSON({ "/repo/README.md": "hello" });
|
|
16
|
-
|
|
17
|
-
await expect(
|
|
18
|
-
targetsForTool("bash", { command: "cat ./README.md" }, cwd),
|
|
19
|
-
).resolves.toEqual([join(cwd, "README.md")]);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("does not treat awk regexes as paths", async () => {
|
|
23
|
-
const cwd = "/repo";
|
|
24
|
-
vol.fromJSON({ "/repo/test.txt": "aaa" });
|
|
25
|
-
|
|
26
|
-
await expect(
|
|
27
|
-
targetsForTool(
|
|
28
|
-
"bash",
|
|
29
|
-
{ command: "awk '/aaa/{flag=1} flag{print}' ./test.txt" },
|
|
30
|
-
cwd,
|
|
31
|
-
),
|
|
32
|
-
).resolves.toEqual([join(cwd, "test.txt")]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("ignores unrelated tools", async () => {
|
|
36
|
-
await expect(
|
|
37
|
-
targetsForTool("custom", { path: "README.md" }, "/repo"),
|
|
38
|
-
).resolves.toEqual([]);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
createPermissionGateRule,
|
|
4
|
-
formatAutoDenyReason,
|
|
5
|
-
matchCommandPattern,
|
|
6
|
-
matchesAnyCommandPattern,
|
|
7
|
-
} from "./rules";
|
|
8
|
-
|
|
9
|
-
describe("createPermissionGateRule", () => {
|
|
10
|
-
it("passes file actions", async () => {
|
|
11
|
-
const rule = createPermissionGateRule({
|
|
12
|
-
patterns: [{ pattern: "rm -rf", description: "recursive delete" }],
|
|
13
|
-
useBuiltinMatchers: false,
|
|
14
|
-
});
|
|
15
|
-
expect(rule.check({ kind: "file", path: "package.json" })).toEqual({
|
|
16
|
-
kind: "pass",
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("matches configured dangerous command patterns", async () => {
|
|
21
|
-
const rule = createPermissionGateRule({
|
|
22
|
-
patterns: [
|
|
23
|
-
{ pattern: "terraform destroy", description: "Destroy infra" },
|
|
24
|
-
],
|
|
25
|
-
useBuiltinMatchers: false,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
rule.check({
|
|
30
|
-
kind: "command",
|
|
31
|
-
command: "terraform destroy -auto-approve",
|
|
32
|
-
}),
|
|
33
|
-
).toEqual({
|
|
34
|
-
kind: "match",
|
|
35
|
-
reason: "Destroy infra",
|
|
36
|
-
metadata: {
|
|
37
|
-
command: "terraform destroy -auto-approve",
|
|
38
|
-
description: "Destroy infra",
|
|
39
|
-
pattern: "terraform destroy",
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("can use builtin dangerous command matchers", async () => {
|
|
45
|
-
const rule = createPermissionGateRule({
|
|
46
|
-
patterns: [],
|
|
47
|
-
useBuiltinMatchers: true,
|
|
48
|
-
});
|
|
49
|
-
expect(
|
|
50
|
-
rule.check({ kind: "command", command: "rm -rf dist" }),
|
|
51
|
-
).toMatchObject({ kind: "match" });
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("matchesAnyCommandPattern", () => {
|
|
56
|
-
it("matches substring and regex command patterns", () => {
|
|
57
|
-
expect(
|
|
58
|
-
matchesAnyCommandPattern("npm publish --dry-run", [
|
|
59
|
-
{ pattern: "npm publish" },
|
|
60
|
-
]),
|
|
61
|
-
).toBe(true);
|
|
62
|
-
expect(
|
|
63
|
-
matchesAnyCommandPattern("DROP TABLE users", [
|
|
64
|
-
{ pattern: "^DROP TABLE", regex: true },
|
|
65
|
-
]),
|
|
66
|
-
).toBe(true);
|
|
67
|
-
expect(
|
|
68
|
-
matchesAnyCommandPattern("npm test", [{ pattern: "npm publish" }]),
|
|
69
|
-
).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe("matchCommandPattern", () => {
|
|
74
|
-
it("returns the matched PatternConfig", () => {
|
|
75
|
-
const patterns = [{ pattern: "npm publish" }, { pattern: "rm -rf" }];
|
|
76
|
-
expect(matchCommandPattern("npm publish --dry-run", patterns)).toBe(
|
|
77
|
-
patterns[0],
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("returns the matching regex pattern", () => {
|
|
82
|
-
const patterns = [{ pattern: "^DROP TABLE", regex: true }];
|
|
83
|
-
expect(matchCommandPattern("DROP TABLE users", patterns)).toBe(patterns[0]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("returns null when no pattern matches", () => {
|
|
87
|
-
expect(
|
|
88
|
-
matchCommandPattern("npm test", [{ pattern: "npm publish" }]),
|
|
89
|
-
).toBeNull();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("preserves description on the returned pattern", () => {
|
|
93
|
-
const patterns = [
|
|
94
|
-
{
|
|
95
|
-
pattern: "python -m venv",
|
|
96
|
-
description: "Use the project .venv instead",
|
|
97
|
-
},
|
|
98
|
-
];
|
|
99
|
-
const result = matchCommandPattern("python -m venv .venv", patterns);
|
|
100
|
-
expect(result).not.toBeNull();
|
|
101
|
-
expect(result?.description).toBe("Use the project .venv instead");
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("formatAutoDenyReason", () => {
|
|
106
|
-
it("uses description when present", () => {
|
|
107
|
-
expect(
|
|
108
|
-
formatAutoDenyReason({
|
|
109
|
-
pattern: "python -m venv",
|
|
110
|
-
description: "Use the project .venv instead",
|
|
111
|
-
}),
|
|
112
|
-
).toBe("Command auto-denied: Use the project .venv instead");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("falls back to generic reason when description is missing", () => {
|
|
116
|
-
expect(formatAutoDenyReason({ pattern: "python -m venv" })).toBe(
|
|
117
|
-
"Command matched auto-deny pattern and was blocked automatically.",
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("falls back when description is empty string", () => {
|
|
122
|
-
expect(
|
|
123
|
-
formatAutoDenyReason({ pattern: "python -m venv", description: "" }),
|
|
124
|
-
).toBe("Command matched auto-deny pattern and was blocked automatically.");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("falls back when description is whitespace-only", () => {
|
|
128
|
-
expect(
|
|
129
|
-
formatAutoDenyReason({ pattern: "python -m venv", description: " " }),
|
|
130
|
-
).toBe("Command matched auto-deny pattern and was blocked automatically.");
|
|
131
|
-
});
|
|
132
|
-
});
|