@andares/pi-toolkits 0.8.0 → 0.9.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 +72 -5
- package/README.zh-CN.md +71 -5
- package/package.json +7 -3
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/autostart/autostart.test.ts +113 -2
- package/src/features/autostart/autostart.ts +28 -2
- package/src/features/autostart/index.ts +138 -49
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path canonicalization, containment, grant scoping, and add-dir state.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
canonicalize,
|
|
10
|
+
expandHome,
|
|
11
|
+
grantRoot,
|
|
12
|
+
insideAnyRoot,
|
|
13
|
+
isWithin,
|
|
14
|
+
readAddedDirectories,
|
|
15
|
+
sessionRoots,
|
|
16
|
+
} from "./paths.js";
|
|
17
|
+
|
|
18
|
+
function tempProject(): string {
|
|
19
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-paths-")));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("expandHome", () => {
|
|
23
|
+
it("expands only a leading ~", () => {
|
|
24
|
+
expect(expandHome("~")).toBe(homedir());
|
|
25
|
+
expect(expandHome("~/x")).toBe(join(homedir(), "x"));
|
|
26
|
+
expect(expandHome("~user/x")).toBe("~user/x");
|
|
27
|
+
expect(expandHome("/abs/~/x")).toBe("/abs/~/x");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("containment", () => {
|
|
32
|
+
it("matches the root and its contents but not a sibling prefix", () => {
|
|
33
|
+
expect(isWithin("/a/b", "/a/b")).toBe(true);
|
|
34
|
+
expect(isWithin("/a/b", "/a/b/c")).toBe(true);
|
|
35
|
+
expect(isWithin("/a/b", "/a/bc")).toBe(false);
|
|
36
|
+
expect(isWithin("/a/b", "/a")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves missing suffixes through the nearest existing ancestor", () => {
|
|
40
|
+
const root = tempProject();
|
|
41
|
+
expect(canonicalize(join(root, "missing", "deep", "file"))).toBe(
|
|
42
|
+
join(realpathSync(root), "missing", "deep", "file"),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("resolves symlinks, so a link cannot walk out of the root", () => {
|
|
47
|
+
const root = tempProject();
|
|
48
|
+
const outside = tempProject();
|
|
49
|
+
writeFileSync(join(outside, "secret"), "x");
|
|
50
|
+
symlinkSync(outside, join(root, "link"));
|
|
51
|
+
const resolved = canonicalize(join(root, "link", "secret"));
|
|
52
|
+
expect(isWithin(root, resolved)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("deduplicates and canonicalizes the session roots", () => {
|
|
56
|
+
const root = tempProject();
|
|
57
|
+
const outside = tempProject();
|
|
58
|
+
const roots = sessionRoots(root, [root, outside, outside]);
|
|
59
|
+
expect(roots).toHaveLength(2);
|
|
60
|
+
expect(insideAnyRoot(roots, join(outside, "x"))).toBe(true);
|
|
61
|
+
expect(insideAnyRoot(roots, "/definitely/not/here")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("grantRoot", () => {
|
|
66
|
+
it("uses the directory itself for a directory", () => {
|
|
67
|
+
const root = tempProject();
|
|
68
|
+
const dir = join(root, "build");
|
|
69
|
+
mkdirSync(dir);
|
|
70
|
+
expect(grantRoot(dir)).toBe(dir);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("uses the parent for an existing file", () => {
|
|
74
|
+
const root = tempProject();
|
|
75
|
+
const file = join(root, "notes.txt");
|
|
76
|
+
writeFileSync(file, "x");
|
|
77
|
+
expect(grantRoot(file)).toBe(root);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses the parent for a path that does not exist yet", () => {
|
|
81
|
+
const root = tempProject();
|
|
82
|
+
expect(grantRoot(join(root, "build"))).toBe(root);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readAddedDirectories", () => {
|
|
87
|
+
it("takes the last add-dir:state entry and ignores other entries", () => {
|
|
88
|
+
const entries = [
|
|
89
|
+
{ type: "message" },
|
|
90
|
+
{ type: "custom", customType: "other", data: { dirs: [{ absolutePath: "/no" }] } },
|
|
91
|
+
{
|
|
92
|
+
type: "custom",
|
|
93
|
+
customType: "add-dir:state",
|
|
94
|
+
data: { dirs: [{ absolutePath: "/a" }, { absolutePath: "/b" }] },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "custom",
|
|
98
|
+
customType: "add-dir:state",
|
|
99
|
+
data: { dirs: [{ absolutePath: "/c" }] },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
expect(readAddedDirectories(entries)).toEqual(["/c"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns nothing without pi-add-dir state or with a malformed payload", () => {
|
|
106
|
+
expect(readAddedDirectories([{ type: "message" }])).toEqual([]);
|
|
107
|
+
expect(
|
|
108
|
+
readAddedDirectories([{ type: "custom", customType: "add-dir:state", data: {} }]),
|
|
109
|
+
).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — path resolution and containment.
|
|
3
|
+
*
|
|
4
|
+
* "Inside the project" means inside the session working directory or inside a
|
|
5
|
+
* directory added to the session (pi-add-dir stores those in a session entry).
|
|
6
|
+
* Containment is decided on canonical paths, so `../` and symlinks cannot walk
|
|
7
|
+
* the check out of a root; a path that does not exist yet is resolved through
|
|
8
|
+
* its nearest existing ancestor.
|
|
9
|
+
*/
|
|
10
|
+
import { realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Expand a leading `~` (and only a leading `~/` or bare `~`). */
|
|
15
|
+
export function expandHome(pathValue: string): string {
|
|
16
|
+
if (pathValue === "~") return homedir();
|
|
17
|
+
if (pathValue.startsWith(`~${sep}`) || pathValue.startsWith("~/")) {
|
|
18
|
+
return join(homedir(), pathValue.slice(2));
|
|
19
|
+
}
|
|
20
|
+
return pathValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Absolute, symlink-resolved form of `pathValue`.
|
|
25
|
+
*
|
|
26
|
+
* The path itself may not exist (a write target, `mkdir` argument, …), so the
|
|
27
|
+
* nearest existing ancestor is resolved and the missing suffix re-appended.
|
|
28
|
+
*/
|
|
29
|
+
export function canonicalize(pathValue: string): string {
|
|
30
|
+
const absolute = resolve(pathValue);
|
|
31
|
+
const suffix: string[] = [];
|
|
32
|
+
let current = absolute;
|
|
33
|
+
for (;;) {
|
|
34
|
+
try {
|
|
35
|
+
const real = realpathSync.native(current);
|
|
36
|
+
return suffix.length === 0
|
|
37
|
+
? real
|
|
38
|
+
: join(real, ...suffix.reverse());
|
|
39
|
+
} catch {
|
|
40
|
+
const parent = dirname(current);
|
|
41
|
+
if (parent === current) return absolute;
|
|
42
|
+
suffix.push(basename(current));
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fold(value: string): string {
|
|
49
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether `pathValue` is `root` or under it. Both must be canonical. */
|
|
53
|
+
export function isWithin(root: string, pathValue: string): boolean {
|
|
54
|
+
const a = fold(root);
|
|
55
|
+
const b = fold(pathValue);
|
|
56
|
+
return b === a || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The directory a session grant on `pathValue` should cover. */
|
|
60
|
+
export function grantRoot(pathValue: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(pathValue).isDirectory() ? pathValue : dirname(pathValue);
|
|
63
|
+
} catch {
|
|
64
|
+
// A path that does not exist (a write target, a `mkdir` argument) is
|
|
65
|
+
// granted at its enclosing directory: the operator is approving an area,
|
|
66
|
+
// and a per-file grant would ask again for the next sibling.
|
|
67
|
+
return dirname(pathValue);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Structural shape of a session entry, so no host type is imported here. */
|
|
72
|
+
interface SessionEntryLike {
|
|
73
|
+
readonly type: string;
|
|
74
|
+
readonly customType?: string;
|
|
75
|
+
readonly data?: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface AddDirState {
|
|
79
|
+
readonly dirs?: ReadonlyArray<{ readonly absolutePath?: unknown }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Directories added to the session by pi-add-dir.
|
|
84
|
+
*
|
|
85
|
+
* pi exposes no directory-list API; pi-add-dir records its state as a session
|
|
86
|
+
* custom entry (`add-dir:state`, appended on every change). The last such
|
|
87
|
+
* entry on the branch wins. Absence of pi-add-dir is a normal, silent miss —
|
|
88
|
+
* only the working directory is then in scope.
|
|
89
|
+
*/
|
|
90
|
+
export function readAddedDirectories(
|
|
91
|
+
entries: readonly SessionEntryLike[],
|
|
92
|
+
): string[] {
|
|
93
|
+
let dirs: string[] = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.type !== "custom" || entry.customType !== "add-dir:state") {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const state = entry.data as AddDirState | undefined;
|
|
99
|
+
if (!Array.isArray(state?.dirs)) continue;
|
|
100
|
+
dirs = state.dirs
|
|
101
|
+
.map((dir) =>
|
|
102
|
+
typeof dir?.absolutePath === "string" ? dir.absolutePath : "",
|
|
103
|
+
)
|
|
104
|
+
.filter((dir) => dir !== "");
|
|
105
|
+
}
|
|
106
|
+
return dirs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Canonical root list for one session: working directory plus added dirs. */
|
|
110
|
+
export function sessionRoots(cwd: string, added: readonly string[]): string[] {
|
|
111
|
+
const roots = new Set<string>();
|
|
112
|
+
for (const value of [cwd, ...added]) {
|
|
113
|
+
if (!value) continue;
|
|
114
|
+
roots.add(canonicalize(expandHome(value)));
|
|
115
|
+
}
|
|
116
|
+
return [...roots];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `true` when `pathValue` is inside at least one root. */
|
|
120
|
+
export function insideAnyRoot(
|
|
121
|
+
roots: readonly string[],
|
|
122
|
+
pathValue: string,
|
|
123
|
+
): boolean {
|
|
124
|
+
return roots.some((root) => isWithin(root, pathValue));
|
|
125
|
+
}
|
|
126
|
+
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy decisions: defaults, fixed safety tables, session grants, severity
|
|
3
|
+
* folding, and model-facing denial text.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { DEFAULT_MODES, type PermissionModes } from "./constants.js";
|
|
11
|
+
import { canonicalize } from "./paths.js";
|
|
12
|
+
import {
|
|
13
|
+
decideProgram,
|
|
14
|
+
decisionEffect,
|
|
15
|
+
decisionGrantRoot,
|
|
16
|
+
formatDenial,
|
|
17
|
+
type PolicyContext,
|
|
18
|
+
} from "./policy.js";
|
|
19
|
+
import { SessionRules } from "./session-rules.js";
|
|
20
|
+
import { parseCommandUnits } from "./units.js";
|
|
21
|
+
|
|
22
|
+
function project(): string {
|
|
23
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-policy-")));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeContext(
|
|
27
|
+
cwd: string,
|
|
28
|
+
modes: PermissionModes = DEFAULT_MODES,
|
|
29
|
+
rules = new SessionRules(),
|
|
30
|
+
): PolicyContext {
|
|
31
|
+
return { modes, roots: [canonicalize(cwd)], rules };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decide(command: string, ctx: PolicyContext) {
|
|
35
|
+
return decideProgram(parseCommandUnits(command, ctx.roots[0] ?? "/"), ctx);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("four-mode policy", () => {
|
|
39
|
+
it("allows in-project reads and writes by default", () => {
|
|
40
|
+
const cwd = project();
|
|
41
|
+
const ctx = makeContext(cwd);
|
|
42
|
+
expect(decide(`rm -rf ${cwd}/build`, ctx).action).toBe("allow");
|
|
43
|
+
expect(decide(`cat ${cwd}/notes.txt`, ctx).action).toBe("allow");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("asks for an outside write and allows an outside read by default", () => {
|
|
47
|
+
const cwd = project();
|
|
48
|
+
const ctx = makeContext(cwd);
|
|
49
|
+
expect(decide("rm -rf /tmp/outside", ctx).action).toBe("ask");
|
|
50
|
+
expect(decide("cat /etc/hostname", ctx).action).toBe("allow");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("honours a deny mode for outside writes", () => {
|
|
54
|
+
const cwd = project();
|
|
55
|
+
const ctx = makeContext(cwd, { ...DEFAULT_MODES, outWrite: "deny" });
|
|
56
|
+
const decision = decide("rm -rf /tmp/outside", ctx);
|
|
57
|
+
expect(decision.action).toBe("deny");
|
|
58
|
+
expect(decision.reason).toContain("outside-project write");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("honours ask for in-project writes", () => {
|
|
62
|
+
const cwd = project();
|
|
63
|
+
const ctx = makeContext(cwd, { ...DEFAULT_MODES, inWrite: "ask" });
|
|
64
|
+
expect(decide(`rm ${cwd}/x`, ctx).action).toBe("ask");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("fixed safety table", () => {
|
|
69
|
+
it("denies credential paths in every mode, even when reads are allowed", () => {
|
|
70
|
+
const cwd = project();
|
|
71
|
+
const ctx = makeContext(cwd);
|
|
72
|
+
const ssh = decide(`cat ${homedir()}/.ssh/id_rsa`, ctx);
|
|
73
|
+
expect(ssh.action).toBe("deny");
|
|
74
|
+
expect(ssh.reason).toContain("credentials");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("denies .env files but not their templates", () => {
|
|
78
|
+
const cwd = project();
|
|
79
|
+
writeFileSync(join(cwd, ".env"), "SECRET=1");
|
|
80
|
+
const ctx = makeContext(cwd);
|
|
81
|
+
expect(decide(`cat ${cwd}/.env`, ctx).action).toBe("deny");
|
|
82
|
+
expect(decide(`cat ${cwd}/.env.example`, ctx).action).toBe("allow");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not let a session allow override the secrets table", () => {
|
|
86
|
+
const cwd = project();
|
|
87
|
+
const rules = new SessionRules();
|
|
88
|
+
rules.allow({ effect: "read", root: join(homedir(), ".ssh") });
|
|
89
|
+
const ctx = makeContext(cwd, DEFAULT_MODES, rules);
|
|
90
|
+
expect(decide(`cat ${homedir()}/.ssh/id_rsa`, ctx).action).toBe("deny");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("denies a privileged read of a credential instead of asking", () => {
|
|
94
|
+
const cwd = project();
|
|
95
|
+
expect(decide(`sudo cat ${homedir()}/.ssh/id_rsa`, makeContext(cwd)).action).toBe(
|
|
96
|
+
"deny",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("asks for destructive commands even inside the project", () => {
|
|
101
|
+
const cwd = project();
|
|
102
|
+
const ctx = makeContext(cwd);
|
|
103
|
+
const decision = decide("rm -rf .", ctx);
|
|
104
|
+
expect(decision.action).toBe("ask");
|
|
105
|
+
expect(decision.reason).toContain("recursive delete");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("asks for a forced push and a privileged command", () => {
|
|
109
|
+
const cwd = project();
|
|
110
|
+
const ctx = makeContext(cwd);
|
|
111
|
+
expect(decide("git push --force", ctx).action).toBe("ask");
|
|
112
|
+
expect(decide("sudo rm x", ctx).action).toBe("ask");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("session grants", () => {
|
|
117
|
+
it("allows a write under a granted directory", () => {
|
|
118
|
+
const cwd = project();
|
|
119
|
+
const rules = new SessionRules();
|
|
120
|
+
rules.allow({ effect: "write", root: "/tmp/granted" });
|
|
121
|
+
expect(
|
|
122
|
+
decide("rm -rf /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
|
|
123
|
+
).toBe("allow");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not let a read grant cover a write", () => {
|
|
127
|
+
const cwd = project();
|
|
128
|
+
const rules = new SessionRules();
|
|
129
|
+
rules.allow({ effect: "read", root: "/tmp/granted" });
|
|
130
|
+
expect(
|
|
131
|
+
decide("rm -rf /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules)).action,
|
|
132
|
+
).toBe("ask");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("denies before allowing, even when the mode allows", () => {
|
|
136
|
+
const cwd = project();
|
|
137
|
+
const rules = new SessionRules();
|
|
138
|
+
rules.deny({ effect: "read", root: "/tmp/granted" });
|
|
139
|
+
const decision = decide("cat /tmp/granted/x", makeContext(cwd, DEFAULT_MODES, rules));
|
|
140
|
+
expect(decision.action).toBe("deny");
|
|
141
|
+
expect(decision.reason).toContain("denied for this session");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("clears grants", () => {
|
|
145
|
+
const rules = new SessionRules();
|
|
146
|
+
rules.allow({ effect: "write", root: "/tmp/x" });
|
|
147
|
+
rules.deny({ effect: "read", root: "/tmp/y" });
|
|
148
|
+
expect(rules.counts()).toEqual({ allow: 1, deny: 1 });
|
|
149
|
+
rules.clear();
|
|
150
|
+
expect(rules.counts()).toEqual({ allow: 0, deny: 0 });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("severity folding", () => {
|
|
155
|
+
it("takes the strictest unit of a compound command", () => {
|
|
156
|
+
const cwd = project();
|
|
157
|
+
const ctx = makeContext(cwd, { ...DEFAULT_MODES, outWrite: "deny" });
|
|
158
|
+
const decision = decide(`cd ${cwd} && rm -rf /tmp/outside`, ctx);
|
|
159
|
+
expect(decision.action).toBe("deny");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not let an allowed first segment mask a later ask", () => {
|
|
163
|
+
const cwd = project();
|
|
164
|
+
const decision = decide("echo hi && rm -rf /tmp/outside", makeContext(cwd));
|
|
165
|
+
expect(decision.action).toBe("ask");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("fail closed", () => {
|
|
170
|
+
it("asks for an opaque unit regardless of mode", () => {
|
|
171
|
+
const cwd = project();
|
|
172
|
+
const ctx = makeContext(cwd, {
|
|
173
|
+
inRead: "allow",
|
|
174
|
+
inWrite: "allow",
|
|
175
|
+
outRead: "allow",
|
|
176
|
+
outWrite: "allow",
|
|
177
|
+
});
|
|
178
|
+
const decision = decide('cd "$DIR" && rm x', ctx);
|
|
179
|
+
expect(decision.action).toBe("ask");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("decision helpers", () => {
|
|
184
|
+
it("derives the grant root and effect", () => {
|
|
185
|
+
const cwd = project();
|
|
186
|
+
writeFileSync(join(cwd, "notes.txt"), "x");
|
|
187
|
+
const decision = decide(`rm ${cwd}/notes.txt`, makeContext(cwd));
|
|
188
|
+
expect(decisionGrantRoot(decision, () => cwd)).toBe(cwd);
|
|
189
|
+
expect(decisionEffect(decision)).toBe("write");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("formats a denial for the model", () => {
|
|
193
|
+
const cwd = project();
|
|
194
|
+
const decision = decide(
|
|
195
|
+
"cat ~/.ssh/id_rsa",
|
|
196
|
+
makeContext(cwd),
|
|
197
|
+
);
|
|
198
|
+
const text = formatDenial(decision);
|
|
199
|
+
expect(text).toContain("Permission denied");
|
|
200
|
+
expect(text).toContain("/ask-perm");
|
|
201
|
+
expect(text).toContain("Do not retry");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("session grants versus the danger table", () => {
|
|
206
|
+
it("lets a session allow cover a destructive command", () => {
|
|
207
|
+
const cwd = project();
|
|
208
|
+
const rules = new SessionRules();
|
|
209
|
+
expect(decide("rm -rf .", makeContext(cwd, DEFAULT_MODES, rules)).action).toBe(
|
|
210
|
+
"ask",
|
|
211
|
+
);
|
|
212
|
+
rules.allow({ effect: "write", root: canonicalize(cwd) });
|
|
213
|
+
expect(decide("rm -rf .", makeContext(cwd, DEFAULT_MODES, rules)).action).toBe(
|
|
214
|
+
"allow",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("turns a session deny on a dangerous command into a denial", () => {
|
|
219
|
+
const cwd = project();
|
|
220
|
+
const rules = new SessionRules();
|
|
221
|
+
rules.deny({ effect: "write", root: canonicalize(cwd) });
|
|
222
|
+
expect(decide("git push --force", makeContext(cwd, DEFAULT_MODES, rules)).action).toBe(
|
|
223
|
+
"deny",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("covers a repeat of the exact opaque command", () => {
|
|
228
|
+
const cwd = project();
|
|
229
|
+
const rules = new SessionRules();
|
|
230
|
+
const units = parseCommandUnits('cd "$DIR" && rm x', cwd);
|
|
231
|
+
const key = units.map((unit) => unit.raw).join(" ; ");
|
|
232
|
+
rules.allowCommand(key);
|
|
233
|
+
expect(decideProgram(units, makeContext(cwd, DEFAULT_MODES, rules)).action).toBe(
|
|
234
|
+
"allow",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|