@andares/pi-toolkits 0.8.0 → 0.9.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/README.md +66 -2
- package/README.zh-CN.md +65 -2
- 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 +45 -2
- package/src/features/autostart/autostart.ts +4 -2
- package/src/features/autostart/index.ts +100 -48
- 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,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
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — per-call decisions.
|
|
3
|
+
*
|
|
4
|
+
* The policy is evaluated per command unit: every touched path is canonicalized,
|
|
5
|
+
* classified as inside or outside the session roots, and answered by, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. the fixed secrets table (deny in every mode),
|
|
8
|
+
* 2. the fixed danger table (ask in every mode),
|
|
9
|
+
* 3. session grants (deny wins over allow),
|
|
10
|
+
* 4. the four modes.
|
|
11
|
+
*
|
|
12
|
+
* The strictest unit of a compound command wins (`deny > ask > allow`), so
|
|
13
|
+
* `cd safe && rm -rf /` cannot ride on the first segment's verdict.
|
|
14
|
+
*/
|
|
15
|
+
import type { PermissionModes } from "./constants.js";
|
|
16
|
+
import { dangerReason, secretReason, type Effect } from "./constants.js";
|
|
17
|
+
import { canonicalize, grantRoot, insideAnyRoot } from "./paths.js";
|
|
18
|
+
import type { SessionRules } from "./session-rules.js";
|
|
19
|
+
import type { CommandUnit } from "./units.js";
|
|
20
|
+
|
|
21
|
+
export type PolicyAction = "allow" | "ask" | "deny";
|
|
22
|
+
|
|
23
|
+
/** One resolved access a decision was made about. */
|
|
24
|
+
export interface PolicyTarget {
|
|
25
|
+
/** Canonical absolute path. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly effect: Effect;
|
|
28
|
+
readonly scope: "in" | "out";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PolicyDecision {
|
|
32
|
+
readonly action: PolicyAction;
|
|
33
|
+
readonly unit: CommandUnit;
|
|
34
|
+
readonly target?: PolicyTarget;
|
|
35
|
+
/** Human/model-facing explanation for ask and deny. */
|
|
36
|
+
readonly reason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PolicyContext {
|
|
40
|
+
readonly modes: PermissionModes;
|
|
41
|
+
/** Canonical session roots (working directory plus added directories). */
|
|
42
|
+
readonly roots: readonly string[];
|
|
43
|
+
readonly rules: SessionRules;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SEVERITY: Record<PolicyAction, number> = { allow: 0, ask: 1, deny: 2 };
|
|
47
|
+
|
|
48
|
+
/** What the session grants alone say about one access. */
|
|
49
|
+
function decideSession(
|
|
50
|
+
effect: Effect,
|
|
51
|
+
pathValue: string,
|
|
52
|
+
ctx: PolicyContext,
|
|
53
|
+
): "allow" | "deny" | undefined {
|
|
54
|
+
if (ctx.rules.denies(effect, pathValue)) return "deny";
|
|
55
|
+
if (ctx.rules.allows(effect, pathValue)) return "allow";
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function worse(a: PolicyDecision, b: PolicyDecision): PolicyDecision {
|
|
60
|
+
return SEVERITY[b.action] > SEVERITY[a.action] ? b : a;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function modeFor(
|
|
64
|
+
modes: PermissionModes,
|
|
65
|
+
effect: Effect,
|
|
66
|
+
scope: "in" | "out",
|
|
67
|
+
): "ask" | "allow" | "deny" {
|
|
68
|
+
if (effect === "read") return scope === "in" ? modes.inRead : modes.outRead;
|
|
69
|
+
return scope === "in" ? modes.inWrite : modes.outWrite;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Scope and session-grant answer for one already-canonical path. */
|
|
73
|
+
function decidePath(
|
|
74
|
+
unit: CommandUnit,
|
|
75
|
+
pathValue: string,
|
|
76
|
+
effect: Effect,
|
|
77
|
+
ctx: PolicyContext,
|
|
78
|
+
): PolicyDecision {
|
|
79
|
+
const scope = insideAnyRoot(ctx.roots, pathValue) ? "in" : "out";
|
|
80
|
+
const secret = secretReason(pathValue);
|
|
81
|
+
if (secret !== undefined) {
|
|
82
|
+
return {
|
|
83
|
+
action: "deny",
|
|
84
|
+
unit,
|
|
85
|
+
target: { path: pathValue, effect, scope },
|
|
86
|
+
reason: `${secret} — denied by the fixed safety table`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const target: PolicyTarget = { path: pathValue, effect, scope };
|
|
90
|
+
if (ctx.rules.denies(effect, pathValue)) {
|
|
91
|
+
return {
|
|
92
|
+
action: "deny",
|
|
93
|
+
unit,
|
|
94
|
+
target,
|
|
95
|
+
reason: `${effect} under ${pathValue} was denied for this session`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (ctx.rules.allows(effect, pathValue)) {
|
|
99
|
+
return { action: "allow", unit, target };
|
|
100
|
+
}
|
|
101
|
+
const mode = modeFor(ctx.modes, effect, scope);
|
|
102
|
+
if (mode === "allow") return { action: "allow", unit, target };
|
|
103
|
+
if (mode === "deny") {
|
|
104
|
+
return {
|
|
105
|
+
action: "deny",
|
|
106
|
+
unit,
|
|
107
|
+
target,
|
|
108
|
+
reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
action: "ask",
|
|
113
|
+
unit,
|
|
114
|
+
target,
|
|
115
|
+
reason:
|
|
116
|
+
scope === "out"
|
|
117
|
+
? `outside-project ${effect} requires confirmation`
|
|
118
|
+
: `in-project ${effect} requires confirmation`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Decide one command unit. */
|
|
123
|
+
export function decideUnit(
|
|
124
|
+
unit: CommandUnit,
|
|
125
|
+
ctx: PolicyContext,
|
|
126
|
+
): PolicyDecision {
|
|
127
|
+
if (unit.opaque) {
|
|
128
|
+
// Session grants for an opaque program are keyed on the whole command
|
|
129
|
+
// text and checked in {@link decideProgram}; a single unit has no path
|
|
130
|
+
// to grant.
|
|
131
|
+
return {
|
|
132
|
+
action: "ask",
|
|
133
|
+
unit,
|
|
134
|
+
reason: unit.note ?? "this command could not be analysed",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const resolvedTargets = unit.targets.map((target) => ({
|
|
139
|
+
...target,
|
|
140
|
+
path: canonicalize(target.path),
|
|
141
|
+
}));
|
|
142
|
+
const canonicalBase = unit.base === null ? null : canonicalize(unit.base);
|
|
143
|
+
|
|
144
|
+
// The secrets table is absolute: it outranks both the danger table and the
|
|
145
|
+
// modes, so `sudo cat ~/.ssh/id_rsa` denies instead of asking.
|
|
146
|
+
const secret = resolvedTargets
|
|
147
|
+
.map((target) => ({ target, reason: secretReason(target.path) }))
|
|
148
|
+
.find((entry) => entry.reason !== undefined);
|
|
149
|
+
if (secret?.reason !== undefined) {
|
|
150
|
+
return {
|
|
151
|
+
action: "deny",
|
|
152
|
+
unit,
|
|
153
|
+
target: {
|
|
154
|
+
path: secret.target.path,
|
|
155
|
+
effect: secret.target.effect,
|
|
156
|
+
scope: insideAnyRoot(ctx.roots, secret.target.path) ? "in" : "out",
|
|
157
|
+
},
|
|
158
|
+
reason: `${secret.reason} — denied by the fixed safety table`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (resolvedTargets.length === 0 && canonicalBase !== null) {
|
|
162
|
+
const baseSecret = secretReason(canonicalBase);
|
|
163
|
+
if (baseSecret !== undefined) {
|
|
164
|
+
return {
|
|
165
|
+
action: "deny",
|
|
166
|
+
unit,
|
|
167
|
+
target: {
|
|
168
|
+
path: canonicalBase,
|
|
169
|
+
effect: unit.effect,
|
|
170
|
+
scope: insideAnyRoot(ctx.roots, canonicalBase) ? "in" : "out",
|
|
171
|
+
},
|
|
172
|
+
reason: `${baseSecret} — denied by the fixed safety table`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Session grants are the operator's explicit decision and outrank the danger
|
|
178
|
+
// table: once an operation is allowed for the session, repeating it must not
|
|
179
|
+
// ask again. A deny likewise turns a dangerous command into a denial instead
|
|
180
|
+
// of a prompt.
|
|
181
|
+
let sessionVerdict: "allow" | "deny" | undefined;
|
|
182
|
+
if (resolvedTargets.length === 0) {
|
|
183
|
+
sessionVerdict =
|
|
184
|
+
canonicalBase === null
|
|
185
|
+
? undefined
|
|
186
|
+
: decideSession(unit.effect, canonicalBase, ctx);
|
|
187
|
+
} else {
|
|
188
|
+
const verdicts = resolvedTargets.map((target) =>
|
|
189
|
+
decideSession(target.effect, target.path, ctx),
|
|
190
|
+
);
|
|
191
|
+
if (verdicts.includes("deny")) {
|
|
192
|
+
const denied = resolvedTargets[verdicts.indexOf("deny")];
|
|
193
|
+
return decidePath(unit, denied.path, denied.effect, ctx);
|
|
194
|
+
}
|
|
195
|
+
sessionVerdict = verdicts.every((verdict) => verdict === "allow")
|
|
196
|
+
? "allow"
|
|
197
|
+
: undefined;
|
|
198
|
+
}
|
|
199
|
+
if (sessionVerdict === "deny" && canonicalBase !== null) {
|
|
200
|
+
return decidePath(unit, canonicalBase, unit.effect, ctx);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The fixed danger table asks regardless of mode: `rm -rf .` inside the
|
|
204
|
+
// project is still destructive, and scope alone cannot express that.
|
|
205
|
+
const danger = dangerReason(
|
|
206
|
+
unit.command,
|
|
207
|
+
unit.args,
|
|
208
|
+
resolvedTargets.map((target) => target.path),
|
|
209
|
+
canonicalBase,
|
|
210
|
+
);
|
|
211
|
+
const privileged = unit.headWord === "sudo" || unit.headWord === "doas";
|
|
212
|
+
if (sessionVerdict !== "allow" && (danger !== undefined || privileged)) {
|
|
213
|
+
const first = resolvedTargets[0];
|
|
214
|
+
let target: PolicyTarget | undefined;
|
|
215
|
+
if (first !== undefined) {
|
|
216
|
+
target = {
|
|
217
|
+
path: first.path,
|
|
218
|
+
effect: first.effect,
|
|
219
|
+
scope: insideAnyRoot(ctx.roots, first.path) ? "in" : "out",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
action: "ask",
|
|
224
|
+
unit,
|
|
225
|
+
target,
|
|
226
|
+
reason: danger ?? "privileged command",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (resolvedTargets.length === 0) {
|
|
231
|
+
if (canonicalBase === null) {
|
|
232
|
+
return {
|
|
233
|
+
action: "ask",
|
|
234
|
+
unit,
|
|
235
|
+
reason: "the working directory could not be resolved",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return decidePath(unit, canonicalBase, unit.effect, ctx);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let decision = decidePath(
|
|
242
|
+
unit,
|
|
243
|
+
resolvedTargets[0].path,
|
|
244
|
+
resolvedTargets[0].effect,
|
|
245
|
+
ctx,
|
|
246
|
+
);
|
|
247
|
+
for (const target of resolvedTargets.slice(1)) {
|
|
248
|
+
decision = worse(decision, decidePath(unit, target.path, target.effect, ctx));
|
|
249
|
+
}
|
|
250
|
+
return decision;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Decide a whole bash program (or the single unit of a path-bearing tool). */
|
|
254
|
+
/** Stable key for a session grant on an opaque program. */
|
|
255
|
+
export function programKey(units: readonly CommandUnit[]): string {
|
|
256
|
+
return units.map((unit) => unit.raw).join(" ; ");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function decideProgram(
|
|
260
|
+
units: readonly CommandUnit[],
|
|
261
|
+
ctx: PolicyContext,
|
|
262
|
+
): PolicyDecision {
|
|
263
|
+
if (units.length === 0) {
|
|
264
|
+
return {
|
|
265
|
+
action: "allow",
|
|
266
|
+
unit: {
|
|
267
|
+
raw: "",
|
|
268
|
+
headWord: "",
|
|
269
|
+
command: "",
|
|
270
|
+
args: [],
|
|
271
|
+
effect: "read",
|
|
272
|
+
opaque: false,
|
|
273
|
+
base: null,
|
|
274
|
+
targets: [],
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// A program the parser could not fully resolve has no path-level grant;
|
|
279
|
+
// the operator's "allow this session" covers the exact command text, so a
|
|
280
|
+
// repeat of the same opaque program does not ask again.
|
|
281
|
+
const opaqueUnit = units.find((unit) => unit.opaque);
|
|
282
|
+
if (opaqueUnit) {
|
|
283
|
+
const key = programKey(units);
|
|
284
|
+
if (ctx.rules.deniesCommand(key)) {
|
|
285
|
+
return {
|
|
286
|
+
action: "deny",
|
|
287
|
+
unit: opaqueUnit,
|
|
288
|
+
reason: "this command was denied for this session",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (ctx.rules.allowsCommand(key)) {
|
|
292
|
+
return { action: "allow", unit: opaqueUnit };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let decision = decideUnit(units[0], ctx);
|
|
297
|
+
for (const unit of units.slice(1)) {
|
|
298
|
+
decision = worse(decision, decideUnit(unit, ctx));
|
|
299
|
+
}
|
|
300
|
+
return decision;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Root a session grant for this decision should cover. */
|
|
304
|
+
export function decisionGrantRoot(
|
|
305
|
+
decision: PolicyDecision,
|
|
306
|
+
getCwd: () => string,
|
|
307
|
+
): string {
|
|
308
|
+
if (decision.target) return grantRoot(decision.target.path);
|
|
309
|
+
if (decision.unit.base !== null) return grantRoot(canonicalize(decision.unit.base));
|
|
310
|
+
return grantRoot(canonicalize(getCwd()));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Effect a session grant for this decision should cover. */
|
|
314
|
+
export function decisionEffect(decision: PolicyDecision): Effect {
|
|
315
|
+
return decision.target?.effect ?? decision.unit.effect;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Model-facing denial. The model receives this as the tool result, so it names
|
|
320
|
+
* the offending segment, the resolved target, and the policy that denied it —
|
|
321
|
+
* and tells the model not to retry an equivalent command.
|
|
322
|
+
*/
|
|
323
|
+
export function formatDenial(decision: PolicyDecision): string {
|
|
324
|
+
const lines = [`Permission denied: ${decision.unit.raw || "(empty command)"}`];
|
|
325
|
+
if (decision.target) {
|
|
326
|
+
const scope = decision.target.scope === "out" ? "outside project" : "inside project";
|
|
327
|
+
lines.push(
|
|
328
|
+
`Target: ${decision.target.path} (${scope}, ${decision.target.effect})`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (decision.reason) lines.push(`Reason: ${decision.reason}`);
|
|
332
|
+
lines.push(
|
|
333
|
+
decision.reason?.includes("fixed safety table") === true
|
|
334
|
+
? "This is a fixed safety rule; /ask-perm cannot override it. Do not retry an equivalent command."
|
|
335
|
+
: "The operator controls this with /ask-perm. Do not retry the same operation through an equivalent command.",
|
|
336
|
+
);
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
}
|