auto-permission-system 3.6.0 → 3.8.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 +91 -5
- package/extensions/guard.ts +17 -35
- package/extensions/permission-mode.ts +9 -5
- package/extensions/sandbox.ts +5 -18
- package/package.json +29 -6
- package/sandbox.cloud.json +62 -0
- package/sandbox.example.json +10 -0
- package/skills/auto-permission-system/SKILL.md +25 -9
- package/src/core/grep-filter.ts +12 -12
- package/src/core/index.ts +4 -1
- package/src/core/policy/classify.ts +22 -10
- package/src/core/policy/commands.ts +17 -15
- package/src/core/policy/default-file.ts +8 -9
- package/src/core/policy/defaults.ts +9 -0
- package/src/core/policy/domains.ts +5 -5
- package/src/core/policy/fence.ts +44 -37
- package/src/core/policy/merge.ts +12 -12
- package/src/core/policy/mode.ts +17 -8
- package/src/core/policy/overrides.ts +56 -0
- package/src/core/policy/paths.ts +16 -0
- package/src/core/policy/patterns.ts +87 -51
- package/src/core/policy/{files.ts → policy-file.ts} +11 -6
- package/src/core/policy/subagent.ts +11 -0
- package/src/core/trust.ts +155 -92
- package/src/core/user-named.ts +36 -32
- package/src/detect/filename-gate/classify.ts +0 -9
- package/src/detect/mcp-gate/constants.ts +2 -0
- package/src/detect/mcp-gate/evaluate.ts +36 -22
- package/src/detect/mcp-gate/index.ts +2 -0
- package/src/detect/mcp-gate/signals.ts +2 -13
- package/src/detect/mcp-gate/verbs.ts +31 -9
- package/src/detect/sensitive-file-scanner/assignment.ts +3 -6
- package/src/detect/sensitive-file-scanner/content-scan.ts +0 -4
- package/src/detect/sensitive-file-scanner/file-scan.ts +0 -10
- package/src/detect/sensitive-file-scanner/jwt.ts +4 -5
- package/src/detect/sensitive-file-scanner/redaction.ts +0 -6
- package/src/detect/sensitive-file-scanner/value-analysis.ts +4 -15
- package/src/detect/tool-output-scanner/evaluate.ts +0 -1
- package/src/detect/tool-output-scanner/scan.ts +0 -2
- package/src/l1-sandbox/bash-ops.ts +192 -103
- package/src/l1-sandbox/config.ts +42 -68
- package/src/l1-sandbox/manager.ts +69 -37
- package/src/l1-sandbox/network-ask.ts +61 -52
- package/src/l2-guard/matching.ts +66 -94
- package/src/l2-guard/policy.ts +36 -40
- package/src/l2-guard/prompts.ts +61 -65
- package/src/l2-guard/subagent.ts +11 -11
- package/src/shared/audit.ts +0 -1
- package/src/shared/canonicalize.ts +29 -0
- package/src/shared/yolo.ts +0 -1
- package/src/ui/ask-flow.ts +160 -82
- package/src/ui/ask-selector.ts +28 -10
- package/src/ui/ask.ts +157 -126
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: auto-permission-system
|
|
3
|
-
description: Use this skill when the user asks about
|
|
3
|
+
description: Use this skill when the user asks about auto-permission-system, security for Pi, permission system for Pi, how to secure Pi, Cloud auto mode for Pi, security policy configuration, why a tool call was blocked, how to add an allow-list entry, or how to configure sandbox behavior. Covers the two-layer sandbox (OS-level bash sandbox and in-process tool guard), policy file locations and fields, Cloud auto mode preset (sandbox.cloud.json), ask-tier prompt options, absolute-deny paths, escape hatches (--yolo, --no-sandbox, /permission-mode → YOLO), the audit log, and common tasks like allowing a new domain or write path.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Pi
|
|
6
|
+
# Security for Pi (Pi Permission System) — Skill
|
|
7
7
|
|
|
8
|
-
Use this skill when the user asks about the auto-permission-system extension, security
|
|
8
|
+
Use this skill when the user asks about the auto-permission-system extension, security for Pi, permission system for Pi, how to secure Pi, or configuring Cloud auto mode for Pi.
|
|
9
9
|
|
|
10
10
|
## What this extension does
|
|
11
11
|
|
|
@@ -19,15 +19,16 @@ Wraps the `bash` tool with `sandbox-exec` (macOS) or `bubblewrap` (Linux). Block
|
|
|
19
19
|
**Layer 2 — In-process tool guard**
|
|
20
20
|
Hooks `tool_call` for `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. Applies the same policy file as Layer 1. For `grep`, `find` and `ls` the search root is checked like a `read` path. A `tool_result` hook also removes `grep` output lines from denied files beneath an allowed root (for example a `.env` deep in the tree); approving the root does not approve those files. When a call is blocked, shows an interactive prompt with persistence options.
|
|
21
21
|
|
|
22
|
-
**Layer 3 — Subagent posture**
|
|
23
|
-
When `ctx.hasUI === false` (subagents, `-p`, JSON mode), applies the `subagent.network` policy: `allow` (default) | `deny` | `research-only`.
|
|
22
|
+
**Layer 3 — Subagent posture & Cloud Auto Mode**
|
|
23
|
+
When `ctx.hasUI === false` (subagents, `-p`, JSON mode, CI/CD runs), applies the `subagent.network` policy: `allow` (default) | `deny` | `research-only`. In Cloud Auto Mode, unapproved prompts fail closed.
|
|
24
24
|
|
|
25
25
|
## Policy files
|
|
26
26
|
|
|
27
27
|
Merged in order (later wins):
|
|
28
28
|
1. `sandbox.default.json` — shipped baseline (`outsideProject.read: "ask"`, full `denyRead`/`denyWrite`, `modelDenyRead`)
|
|
29
|
-
2.
|
|
30
|
-
3.
|
|
29
|
+
2. `sandbox.cloud.json` — shipped Cloud Auto Mode preset for headless, Docker, and CI/CD runs (`strictAllowlist: true`, `outsideProject.read: "deny"`)
|
|
30
|
+
3. `~/.pi/agent/extensions/sandbox.json` — global user policy
|
|
31
|
+
4. `<cwd>/.pi/sandbox.json` — project-local overrides (also written by ask-tier prompts)
|
|
31
32
|
|
|
32
33
|
A higher layer only states what it changes; absent keys keep the layer below. A **trusted** project may loosen the baseline; an **untrusted** project may only tighten it (ADR-013). You do not need a global file for good defaults (ADR-016).
|
|
33
34
|
|
|
@@ -50,6 +51,14 @@ A higher layer only states what it changes; absent keys keep the layer below. A
|
|
|
50
51
|
"denyWrite": [".env", "*.pem"] // Layer 1 + 2: always blocked
|
|
51
52
|
},
|
|
52
53
|
"subagent": { "network": "allow" },
|
|
54
|
+
"mcp": { // Advanced Secure MCP gate tuning (ADR-032)
|
|
55
|
+
"allowTools": ["mcp__diffusion_studio__context"], // exact names that never ask
|
|
56
|
+
"allowPrefixes": ["mcp__ticktick__get_"], // name prefixes that never ask
|
|
57
|
+
"askTools": [], // exact names that always ask
|
|
58
|
+
"trustAnnotations": false, // true = a server's readOnlyHint allows without asking
|
|
59
|
+
"allowSimpleUpdates": true, // narrow field updates skip the ask
|
|
60
|
+
"askThreshold": 30 // risk score at which a call asks (lower = stricter)
|
|
61
|
+
},
|
|
53
62
|
"commands": {
|
|
54
63
|
"ask": ["printenv", "env", "/proc/*/environ"] // bash: ask before these (they can print env tokens)
|
|
55
64
|
},
|
|
@@ -88,7 +97,7 @@ A **network** (domain) ask keeps the same rows, but the *unanswered* default fli
|
|
|
88
97
|
|
|
89
98
|
For a Layer 1 bash **write** blocked after the command already ran, `yes — this once` re-runs the command once with the offending path's parent folder allowed for that invocation only (never saved); the same folder is not offered again within that invocation, and `denyWrite` still wins over the grant. The block is recognized from the error output either way it is reported — `EPERM`/`Operation not permitted` on macOS, `EROFS`/`Read-only file system` on Linux (ADR-022) — and a refused write is never offered as a read grant. If the output never names the operand, there is nothing to attribute and no prompt, so the command is simply refused.
|
|
90
99
|
|
|
91
|
-
Every prompt waits **15 seconds** by default and shows it live as `Default: … (Ns)` under the body. For file reads/writes no answer means the safe default: **block / deny**, and Esc blocks. For a network ask, an unanswered countdown means **allow once** (Esc still denies) — ADR-024. In the TUI the `Default:` line disappears as soon as you press ↑/↓ and the timer restarts, so the hint never competes with an active choice (ADR-031).
|
|
100
|
+
Every prompt waits **15 seconds** by default and shows it live as `Default: … (Ns)` under the body. For file reads/writes no answer means the safe default: **block / deny**, and Esc blocks. For a network ask, an unanswered countdown means **allow once** (Esc still denies) — ADR-024. In the TUI the `Default:` line disappears as soon as you press ↑/↓ and the timer restarts, so the hint never competes with an active choice (ADR-031). The prompt is keyboard-only (ADR-036): ↑/↓ move the highlight, Enter selects, Esc cancels — a mouse click or wheel over the prompt does nothing.
|
|
92
101
|
|
|
93
102
|
The same options apply to a bash command whose network request reaches a host outside `allowedDomains` (ADR-023), with the countdown default and live footer from ADR-024/ADR-031: `No` / `Yes, just this once` / `Yes, all in group for this session` / `Yes, always…`, and an untouched countdown counts as allow-once. Because the sandbox proxy asks per connection, `Allow (default)` covers that bash command rather than one connection — redirects and parallel connections to the same host do not re-prompt. A host in `deniedDomains`, or `network.strictAllowlist: true`, is blocked outright: the prompt never appears. A remembered host is written to `overrides.allowDomains` and takes effect immediately, so the waiting request completes without a retry. Prompts from both layers are serialized through one queue (`src/ui/ask-flow.ts`), so two unknown hosts cannot replace each other's selector and hang the waiting connection.
|
|
94
103
|
|
|
@@ -103,7 +112,7 @@ The same options apply to a bash command whose network request reaches a host ou
|
|
|
103
112
|
- **`default`** (shipped) — rules only. No content inspection.
|
|
104
113
|
- **`advanced-secure`** (recommended) — adds secret/credential detection, and **only adds asks or blocks** (never loosens):
|
|
105
114
|
- file reads whose name looks like a credential store ask before the read;
|
|
106
|
-
- risky `mcp__<server>__<tool>` calls ask before they run;
|
|
115
|
+
- risky `mcp__<server>__<tool>` calls ask before they run. The gate ignores the `mcp__<server>__` namespace, takes the operation from the head verb of the name (or the first verb of the description), and asks for destructive names, mutating names (`update_*`, `create_*`, `run_*`, `evaluate_*`), and destructive arguments (SQL `DELETE`/`UPDATE` without `WHERE`, `rm -rf`, `*` targets, HTTP `DELETE`, production targets). A documented tool with an unclassified name (`context`, `capture`, `logs`) does not ask; a tool with **no description** counts as opaque and does. Tune it under `mcp` in `sandbox.json` (ADR-032): `allowTools` / `allowPrefixes` skip asks, `askTools` forces them, `trustAnnotations` lets a server's `readOnlyHint` allow the call, `askThreshold` moves the score line (default 30). An untrusted project file may only tighten it (add `askTools`, lower `askThreshold`, turn `allowSimpleUpdates`/`trustAnnotations` off).
|
|
107
116
|
- before any tool/command/file output reaches the model, a secret-like hit shows the file and each detected line as `lineNo: text`, with two choices — **No, keep private** or **Yes, allow**. No, keep private withholds the output and tells the model it was withheld because it may contain sensitive information (not a failure, not an empty result).
|
|
108
117
|
|
|
109
118
|
Switch with `/permission-mode`; all three modes are saved to the global `sandbox.json` and reflected in the footer's sandbox chip (`Sandbox: 🧠 N domains, M paths` for advanced-secure, `Sandbox: 🔒 …` for default). An untrusted project may move the mode **up** (toward stricter), never down: it cannot turn on YOLO or turn off Advanced Secure. The third choice, **YOLO**, disables every layer; the chip reads `⚠️ YOLO — all security layers disabled` until you pick another mode (ADR-020).
|
|
@@ -143,6 +152,13 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
|
|
|
143
152
|
|
|
144
153
|
**Allow writes to a new path** — same pattern with `allowWrite`.
|
|
145
154
|
|
|
155
|
+
**Allow an MCP tool** — in Advanced Secure mode, a `mcp__<server>__<tool>` call that keeps asking can be allowed without weakening anything else:
|
|
156
|
+
```json
|
|
157
|
+
// ~/.pi/agent/extensions/sandbox.json (global)
|
|
158
|
+
{ "mcp": { "allowPrefixes": ["mcp__ticktick__get_"], "allowTools": ["mcp__diffusion_studio__export"] } }
|
|
159
|
+
```
|
|
160
|
+
`allowTools` matches exact names, `allowPrefixes` matches the start (case-insensitive). Detected destructive calls still ask unless an exact `allowTools` entry names them. `"trustAnnotations": true` lets servers that mark a tool `readOnlyHint` skip the ask, and a project's `mcp` file may only tighten (add `askTools`, lower `askThreshold`, turn `allowSimpleUpdates`/`trustAnnotations` off) until you trust it.
|
|
161
|
+
|
|
146
162
|
**View recent blocks** — run `/security` and read the audit section, or:
|
|
147
163
|
```bash
|
|
148
164
|
tail -20 ~/.pi/agent/audit.log | python3 -m json.tool
|
package/src/core/grep-filter.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `path-N- text` for a context line. The separator is the same on both sides
|
|
4
4
|
* of the line number, which keeps paths containing `-` or `:` parseable.
|
|
5
5
|
*/
|
|
6
|
-
const
|
|
6
|
+
const GREP_LINE_RE = /^(.+?)([:-])(\d+)\2 /;
|
|
7
7
|
|
|
8
8
|
export interface GrepFilterResult {
|
|
9
9
|
text: string;
|
|
@@ -20,26 +20,26 @@ export interface GrepFilterResult {
|
|
|
20
20
|
* per path.
|
|
21
21
|
*/
|
|
22
22
|
export function filterGrepOutput(text: string, isDenied: (printedPath: string) => boolean): GrepFilterResult {
|
|
23
|
-
const
|
|
23
|
+
const deniedByPath = new Map<string, boolean>();
|
|
24
24
|
const removedFiles = new Set<string>();
|
|
25
25
|
let removedLines = 0;
|
|
26
|
-
const
|
|
26
|
+
const keptLines: string[] = [];
|
|
27
27
|
for (const line of text.split("\n")) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
const
|
|
31
|
-
let denied =
|
|
28
|
+
const match = GREP_LINE_RE.exec(line);
|
|
29
|
+
if (match) {
|
|
30
|
+
const printedPath = match[1];
|
|
31
|
+
let denied = deniedByPath.get(printedPath);
|
|
32
32
|
if (denied === undefined) {
|
|
33
|
-
denied = isDenied(
|
|
34
|
-
|
|
33
|
+
denied = isDenied(printedPath);
|
|
34
|
+
deniedByPath.set(printedPath, denied);
|
|
35
35
|
}
|
|
36
36
|
if (denied) {
|
|
37
37
|
removedLines++;
|
|
38
|
-
removedFiles.add(
|
|
38
|
+
removedFiles.add(printedPath);
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
keptLines.push(line);
|
|
43
43
|
}
|
|
44
|
-
return { text:
|
|
44
|
+
return { text: keptLines.join("\n"), removedLines, removedFiles: [...removedFiles] };
|
|
45
45
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
export * from "./policy/defaults";
|
|
6
6
|
export * from "./policy/domains";
|
|
7
7
|
export * from "./policy/default-file";
|
|
8
|
-
export * from "./policy/
|
|
8
|
+
export * from "./policy/policy-file";
|
|
9
9
|
export * from "./policy/merge";
|
|
10
10
|
export * from "./policy/mode";
|
|
11
|
+
export * from "./policy/overrides";
|
|
12
|
+
export * from "./policy/paths";
|
|
11
13
|
export * from "./policy/patterns";
|
|
12
14
|
export * from "./policy/classify";
|
|
13
15
|
export * from "./policy/commands";
|
|
14
16
|
export * from "./policy/fence";
|
|
17
|
+
export * from "./policy/subagent";
|
|
15
18
|
export * from "./grep-filter";
|
|
16
19
|
export * from "./trust";
|
|
17
20
|
export * from "./user-named";
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem slice both layers read, plus the outside-project boundary
|
|
3
|
+
* (ADR-012) it is enforced through. Pure: no pi, no OS side effects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import { matchesPolicyPattern } from "./patterns";
|
|
2
7
|
|
|
3
|
-
/**
|
|
8
|
+
/** Erasable enum for `filesystem.outsideProject.read`. */
|
|
9
|
+
export const ReadPosture = {
|
|
10
|
+
Allow: "allow",
|
|
11
|
+
Ask: "ask",
|
|
12
|
+
Deny: "deny",
|
|
13
|
+
} as const;
|
|
14
|
+
export type ReadPosture = (typeof ReadPosture)[keyof typeof ReadPosture];
|
|
15
|
+
|
|
4
16
|
export interface FilesystemPolicy {
|
|
5
17
|
denyRead: readonly string[];
|
|
6
18
|
allowWrite: readonly string[];
|
|
@@ -8,23 +20,23 @@ export interface FilesystemPolicy {
|
|
|
8
20
|
allowRead?: readonly string[];
|
|
9
21
|
/** Paths that prompt on read instead of being hard-denied (ADR-019). */
|
|
10
22
|
askRead?: readonly string[];
|
|
11
|
-
/** Reads outside the project:
|
|
12
|
-
outsideProject?: { read?:
|
|
23
|
+
/** Reads outside the project: `allow` (default), `ask` or `deny` (ADR-012). */
|
|
24
|
+
outsideProject?: { read?: ReadPosture; allowRead?: string[] };
|
|
13
25
|
}
|
|
14
26
|
|
|
15
|
-
export function outsideProjectMode(
|
|
16
|
-
return
|
|
27
|
+
export function outsideProjectMode(filesystem: FilesystemPolicy): ReadPosture {
|
|
28
|
+
return filesystem.outsideProject?.read ?? ReadPosture.Allow;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Whether `absPath` is an outside-the-project read the policy wants to gate:
|
|
21
33
|
* outside cwd, not under an allowWrite / allowRead / outsideProject.allowRead
|
|
22
|
-
* root, and mode !==
|
|
34
|
+
* root, and mode !== `allow`. Pure; mirrors Layer 2's outsideProjectReason for
|
|
23
35
|
* callers (and tests) that cannot import security-guard.ts.
|
|
24
36
|
*/
|
|
25
|
-
export function outsideProjectReadDenied(absPath: string, cwd: string, home: string,
|
|
26
|
-
if (outsideProjectMode(
|
|
37
|
+
export function outsideProjectReadDenied(absPath: string, cwd: string, home: string, filesystem: FilesystemPolicy): boolean {
|
|
38
|
+
if (outsideProjectMode(filesystem) === ReadPosture.Allow) return false;
|
|
27
39
|
if (absPath === cwd || absPath.startsWith(`${cwd}/`)) return false;
|
|
28
|
-
const allowed = [...
|
|
29
|
-
return !allowed.some((
|
|
40
|
+
const allowed = [...filesystem.allowWrite, ...(filesystem.allowRead ?? []), ...(filesystem.outsideProject?.allowRead ?? [])];
|
|
41
|
+
return !allowed.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home));
|
|
30
42
|
}
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Commands/paths that can print secrets and must ask first (ADR-017). Pure.
|
|
3
|
+
*/
|
|
2
4
|
|
|
3
5
|
import { resolve } from "node:path";
|
|
4
6
|
import { shellSegments, shellTokens } from "./fence";
|
|
5
7
|
import { matchesPolicyPattern, normalizeAbs } from "./patterns";
|
|
6
8
|
|
|
7
9
|
/** Expand a leading `~`, then resolve relative to cwd. Returns null for non-paths. */
|
|
8
|
-
function
|
|
9
|
-
const
|
|
10
|
-
if (!
|
|
11
|
-
let
|
|
12
|
-
if (
|
|
13
|
-
else if (
|
|
14
|
-
else if (!
|
|
15
|
-
const
|
|
16
|
-
return normalizeAbs(
|
|
10
|
+
function asAbsolutePath(rawToken: string, cwd: string, home: string): string | null {
|
|
11
|
+
const token = rawToken.trim();
|
|
12
|
+
if (!token || token.startsWith("-")) return null;
|
|
13
|
+
let path = token;
|
|
14
|
+
if (path === "~") path = home;
|
|
15
|
+
else if (path.startsWith("~/")) path = `${home}/${path.slice(2)}`;
|
|
16
|
+
else if (!path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !path.includes("/")) return null;
|
|
17
|
+
const absolute = path.startsWith("/") ? path : resolve(cwd, path);
|
|
18
|
+
return normalizeAbs(absolute);
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -22,18 +24,18 @@ function asAbsolute(token: string, cwd: string, home: string): string | null {
|
|
|
22
24
|
* path token in the command, so a read of the per-process environ file is
|
|
23
25
|
* caught whatever command performs it.
|
|
24
26
|
*/
|
|
25
|
-
export function matchedAskCommands(command: string,
|
|
27
|
+
export function matchedAskCommands(command: string, askEntries: readonly string[], cwd: string, home: string): string[] {
|
|
26
28
|
const found = new Set<string>();
|
|
27
|
-
if (!
|
|
29
|
+
if (!askEntries.length) return [];
|
|
28
30
|
for (const segment of shellSegments(command)) {
|
|
29
31
|
const tokens = shellTokens(segment);
|
|
30
32
|
const head = (tokens[0] ?? "").split("/").pop() ?? "";
|
|
31
|
-
for (const entry of
|
|
33
|
+
for (const entry of askEntries) {
|
|
32
34
|
if (!entry) continue;
|
|
33
35
|
if (entry.includes("/") || entry.startsWith("~")) {
|
|
34
36
|
for (const raw of tokens.slice(1)) {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
+
const absolute = asAbsolutePath(raw, cwd, home);
|
|
38
|
+
if (absolute && matchesPolicyPattern(absolute, entry, cwd, home)) found.add(`${head} ${raw}`.trim());
|
|
37
39
|
}
|
|
38
40
|
} else if (head === entry) {
|
|
39
41
|
found.add(entry);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* global/project files tune it on top.
|
|
5
5
|
*
|
|
6
6
|
* Robust by design: if the file is missing or unparseable the caller falls back
|
|
7
|
-
* to the constants in `defaults.ts`, so a broken package layout never leaves
|
|
8
|
-
* sandbox wide open.
|
|
7
|
+
* to the constants in `defaults.ts`, so a broken package layout never leaves
|
|
8
|
+
* the sandbox wide open.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -14,13 +14,13 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
|
|
15
15
|
/** Walk up from this module looking for the file the package ships. */
|
|
16
16
|
function findDefaultFile(): string | null {
|
|
17
|
-
let
|
|
18
|
-
for (let
|
|
19
|
-
const candidate = join(
|
|
17
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
19
|
+
const candidate = join(directory, "sandbox.default.json");
|
|
20
20
|
if (existsSync(candidate)) return candidate;
|
|
21
|
-
const parent = dirname(
|
|
22
|
-
if (parent ===
|
|
23
|
-
|
|
21
|
+
const parent = dirname(directory);
|
|
22
|
+
if (parent === directory) break;
|
|
23
|
+
directory = parent;
|
|
24
24
|
}
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
@@ -37,7 +37,6 @@ export function loadDefaultPolicy(): Record<string, unknown> | null {
|
|
|
37
37
|
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
38
38
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) resolved = parsed;
|
|
39
39
|
} catch {
|
|
40
|
-
/* fall back to the caller's constants */
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
cached = resolved;
|
|
@@ -8,3 +8,12 @@
|
|
|
8
8
|
export const DEFAULT_DENY_READ = ["~/.ssh", "~/.aws", "~/.gnupg", "~/.pi/agent"];
|
|
9
9
|
export const DEFAULT_ALLOW_WRITE = [".", "/tmp"];
|
|
10
10
|
export const DEFAULT_DENY_WRITE = [".env", ".env.*", "*.pem", "*.key"];
|
|
11
|
+
|
|
12
|
+
/** Domains every install may reach without a prompt; the shipped baseline adds more. */
|
|
13
|
+
export const BUILTIN_NETWORK_ALLOWED = [
|
|
14
|
+
"npmjs.org", "*.npmjs.org",
|
|
15
|
+
"registry.npmjs.org", "registry.yarnpkg.com",
|
|
16
|
+
"pypi.org", "*.pypi.org",
|
|
17
|
+
"github.com", "*.github.com",
|
|
18
|
+
"api.github.com", "raw.githubusercontent.com",
|
|
19
|
+
];
|
|
@@ -17,10 +17,10 @@ export function hostnameOf(url: string): string | null {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function domainMatches(host: string, pattern: string): boolean {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
const suffix =
|
|
23
|
-
return host ===
|
|
20
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
21
|
+
if (normalizedPattern.startsWith("*.")) {
|
|
22
|
+
const suffix = normalizedPattern.slice(1);
|
|
23
|
+
return host === normalizedPattern.slice(2) || host.endsWith(suffix);
|
|
24
24
|
}
|
|
25
|
-
return host ===
|
|
25
|
+
return host === normalizedPattern;
|
|
26
26
|
}
|
package/src/core/policy/fence.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1's pre-flight path scan (ADR-015) and the `filesystem` block handed
|
|
3
|
+
* to sandbox-runtime. Pure: no pi, no OS side effects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import { existsSync } from "node:fs";
|
|
2
7
|
import { dirname } from "node:path";
|
|
3
8
|
import { matchesPolicyPattern, normalizeAbs, toSandboxPatterns } from "./patterns";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
// ---------- Layer 1 pre-flight ask (ADR-015) ----------
|
|
9
|
+
import { ReadPosture, outsideProjectMode, outsideProjectReadDenied, type FilesystemPolicy } from "./classify";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Commands whose positional arguments are read targets. Layer 1's pre-flight
|
|
@@ -19,16 +22,16 @@ const READ_COMMANDS = new Set([
|
|
|
19
22
|
|
|
20
23
|
/** Split a shell command on the operators that start a new simple command. */
|
|
21
24
|
export function shellSegments(command: string): string[] {
|
|
22
|
-
return command.split(/\s*(?:;|&&|\|\||\||\n)\s*/).map((
|
|
25
|
+
return command.split(/\s*(?:;|&&|\|\||\||\n)\s*/).map((segment) => segment.trim()).filter(Boolean);
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/** Tokenize one segment, dropping the quotes around fully quoted words. */
|
|
26
29
|
export function shellTokens(segment: string): string[] {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
let
|
|
30
|
-
while ((
|
|
31
|
-
return
|
|
30
|
+
const tokens: string[] = [];
|
|
31
|
+
const tokenPattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
32
|
+
let match: RegExpExecArray | null;
|
|
33
|
+
while ((match = tokenPattern.exec(segment))) tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
34
|
+
return tokens;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/** Expand a leading `~`; other tokens are returned as-is. */
|
|
@@ -40,9 +43,9 @@ function expandHomeToken(token: string, home: string): string {
|
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* Absolute paths named by read-like segments of a bash command. Best-effort:
|
|
43
|
-
* only read-like heads, only tokens that exist on disk. Bare names count
|
|
44
|
-
* has no slash); the caller's pattern match decides. Obfuscated reads
|
|
45
|
-
* returned — the OS fence covers those.
|
|
46
|
+
* only read-like heads, only tokens that exist on disk. Bare names count
|
|
47
|
+
* (`.env` has no slash); the caller's pattern match decides. Obfuscated reads
|
|
48
|
+
* are not returned — the OS fence covers those.
|
|
46
49
|
*/
|
|
47
50
|
function readPathCandidates(
|
|
48
51
|
command: string,
|
|
@@ -56,21 +59,21 @@ function readPathCandidates(
|
|
|
56
59
|
const head = (tokens[0] ?? "").split("/").pop() ?? "";
|
|
57
60
|
if (!READ_COMMANDS.has(head)) continue;
|
|
58
61
|
for (const raw of tokens.slice(1)) {
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
61
|
-
const expanded = expandHomeToken(
|
|
62
|
-
const
|
|
62
|
+
const token = raw.trim();
|
|
63
|
+
if (!token || token.startsWith("-") || /^[a-z][a-z0-9+.-]*:\/\//i.test(token)) continue;
|
|
64
|
+
const expanded = expandHomeToken(token, home);
|
|
65
|
+
const absolute = expanded.startsWith("/")
|
|
63
66
|
? normalizeAbs(expanded)
|
|
64
67
|
: normalizeAbs(`${cwd.replace(/\/+$/, "")}/${expanded}`);
|
|
65
|
-
if (exists(
|
|
68
|
+
if (exists(absolute)) found.add(absolute);
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
return [...found];
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** Never a hard `denyRead` match, whatever the caller asks about. */
|
|
72
|
-
function notDenied(
|
|
73
|
-
return !
|
|
75
|
+
function notDenied(absPath: string, cwd: string, home: string, filesystem: FilesystemPolicy): boolean {
|
|
76
|
+
return !filesystem.denyRead.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home));
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
@@ -81,11 +84,13 @@ export function outsideProjectReadCandidates(
|
|
|
81
84
|
command: string,
|
|
82
85
|
cwd: string,
|
|
83
86
|
home: string,
|
|
84
|
-
|
|
87
|
+
filesystem: FilesystemPolicy,
|
|
85
88
|
exists: (path: string) => boolean = existsSync,
|
|
89
|
+
resolvePath: (absPath: string) => string = (absPath) => absPath,
|
|
86
90
|
): string[] {
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
const candidates = new Set(readPathCandidates(command, cwd, home, exists).map(resolvePath));
|
|
92
|
+
return [...candidates].filter(
|
|
93
|
+
(absPath) => notDenied(absPath, cwd, home, filesystem) && outsideProjectReadDenied(absPath, cwd, home, filesystem),
|
|
89
94
|
);
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -94,13 +99,15 @@ export function askReadCandidates(
|
|
|
94
99
|
command: string,
|
|
95
100
|
cwd: string,
|
|
96
101
|
home: string,
|
|
97
|
-
|
|
102
|
+
filesystem: FilesystemPolicy,
|
|
98
103
|
exists: (path: string) => boolean = existsSync,
|
|
104
|
+
resolvePath: (absPath: string) => string = (absPath) => absPath,
|
|
99
105
|
): string[] {
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
const askReadPatterns = filesystem.askRead ?? [];
|
|
107
|
+
if (!askReadPatterns.length) return [];
|
|
108
|
+
const candidates = new Set(readPathCandidates(command, cwd, home, exists).map(resolvePath));
|
|
109
|
+
return [...candidates].filter(
|
|
110
|
+
(absPath) => notDenied(absPath, cwd, home, filesystem) && askReadPatterns.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home)),
|
|
104
111
|
);
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -117,20 +124,20 @@ export function askReadCandidates(
|
|
|
117
124
|
* `outsideProject` never reached Layer 1 at all and `bash` read anything.
|
|
118
125
|
*/
|
|
119
126
|
export function sandboxFilesystem(
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
filesystem: FilesystemPolicy,
|
|
128
|
+
options: { cwd: string; home: string },
|
|
122
129
|
): { denyRead: string[]; allowRead: string[]; allowWrite: string[]; denyWrite: string[] } {
|
|
123
|
-
const denyRead = toSandboxPatterns(
|
|
124
|
-
const allowRead = toSandboxPatterns(
|
|
125
|
-
if (outsideProjectMode(
|
|
126
|
-
for (const fence of [
|
|
130
|
+
const denyRead = toSandboxPatterns(filesystem.denyRead);
|
|
131
|
+
const allowRead = toSandboxPatterns(filesystem.allowRead ?? []);
|
|
132
|
+
if (outsideProjectMode(filesystem) !== ReadPosture.Allow) {
|
|
133
|
+
for (const fence of [options.home, dirname(options.cwd)]) {
|
|
127
134
|
if (fence && fence !== "/" && !denyRead.includes(fence)) denyRead.push(fence);
|
|
128
135
|
}
|
|
129
|
-
const grants = [
|
|
136
|
+
const grants = [options.cwd, ...filesystem.allowWrite, ...(filesystem.outsideProject?.allowRead ?? [])];
|
|
130
137
|
for (const grant of grants) {
|
|
131
|
-
const
|
|
132
|
-
if (!allowRead.includes(
|
|
138
|
+
const pattern = toSandboxPatterns([grant])[0];
|
|
139
|
+
if (!allowRead.includes(pattern)) allowRead.push(pattern);
|
|
133
140
|
}
|
|
134
141
|
}
|
|
135
|
-
return { denyRead, allowRead, allowWrite: [...
|
|
142
|
+
return { denyRead, allowRead, allowWrite: [...filesystem.allowWrite], denyWrite: toSandboxPatterns(filesystem.denyWrite) };
|
|
136
143
|
}
|
package/src/core/policy/merge.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* trust.ts) is the tighten-only merge for an untrusted project.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const isPlainObject = (
|
|
11
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const stringList = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Overlay `top` onto `base`. Object keys merge shallowly; `overrides` is
|
|
@@ -18,22 +18,22 @@ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is
|
|
|
18
18
|
* base value. Absent keys keep the base, so a layer only states what it changes.
|
|
19
19
|
*/
|
|
20
20
|
export function overlayPolicy<T extends object>(base: T, top: Partial<T>): T {
|
|
21
|
-
const
|
|
21
|
+
const merged: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
22
22
|
for (const [key, value] of Object.entries(top as Record<string, unknown>)) {
|
|
23
23
|
if (value === undefined) continue;
|
|
24
|
-
const existing =
|
|
24
|
+
const existing = merged[key];
|
|
25
25
|
if (key === "overrides" && isPlainObject(value)) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
allowRead: [...
|
|
29
|
-
allowWrite: [...
|
|
30
|
-
allowDomains: [...
|
|
26
|
+
const baseOverrides = isPlainObject(existing) ? existing : {};
|
|
27
|
+
merged[key] = {
|
|
28
|
+
allowRead: [...stringList(baseOverrides.allowRead), ...stringList(value.allowRead)],
|
|
29
|
+
allowWrite: [...stringList(baseOverrides.allowWrite), ...stringList(value.allowWrite)],
|
|
30
|
+
allowDomains: [...stringList(baseOverrides.allowDomains), ...stringList(value.allowDomains)],
|
|
31
31
|
};
|
|
32
32
|
} else if (isPlainObject(value) && isPlainObject(existing)) {
|
|
33
|
-
|
|
33
|
+
merged[key] = { ...existing, ...value };
|
|
34
34
|
} else {
|
|
35
|
-
|
|
35
|
+
merged[key] = value;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
return
|
|
38
|
+
return merged as T;
|
|
39
39
|
}
|
package/src/core/policy/mode.ts
CHANGED
|
@@ -3,24 +3,33 @@
|
|
|
3
3
|
* downgrades a rule. Pure.
|
|
4
4
|
*
|
|
5
5
|
* `yolo` is the weakest tier: every layer is off. `MODE_ORDER` is weakest →
|
|
6
|
-
* strictest, which is what the untrusted-project merge uses to decide whether
|
|
7
|
-
* project file may change `mode` (it may only move up).
|
|
6
|
+
* strictest, which is what the untrusted-project merge uses to decide whether
|
|
7
|
+
* a project file may change `mode` (it may only move up).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Erasable enum: named constants whose runtime values are the JSON strings, so
|
|
12
|
+
* config files keep working. Node's type stripping cannot run a TS `enum`.
|
|
13
|
+
*/
|
|
14
|
+
export const PermissionMode = {
|
|
15
|
+
Yolo: "yolo",
|
|
16
|
+
Default: "default",
|
|
17
|
+
AdvancedSecure: "advanced-secure",
|
|
18
|
+
} as const;
|
|
19
|
+
export type PermissionMode = (typeof PermissionMode)[keyof typeof PermissionMode];
|
|
11
20
|
|
|
12
|
-
export const DEFAULT_MODE: PermissionMode =
|
|
21
|
+
export const DEFAULT_MODE: PermissionMode = PermissionMode.Default;
|
|
13
22
|
|
|
14
23
|
/** Weakest → strictest. Used by the untrusted-project tighten-only merge. */
|
|
15
|
-
export const MODE_ORDER: readonly PermissionMode[] = [
|
|
24
|
+
export const MODE_ORDER: readonly PermissionMode[] = [PermissionMode.Yolo, PermissionMode.Default, PermissionMode.AdvancedSecure];
|
|
16
25
|
|
|
17
26
|
/** Anything that is not one of the known modes resolves to the default. */
|
|
18
27
|
export function normalizeMode(value: unknown): PermissionMode {
|
|
19
|
-
return value ===
|
|
28
|
+
return value === PermissionMode.AdvancedSecure || value === PermissionMode.Yolo ? value : DEFAULT_MODE;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export function modeLabel(mode: PermissionMode): string {
|
|
23
|
-
if (mode ===
|
|
24
|
-
if (mode ===
|
|
32
|
+
if (mode === PermissionMode.AdvancedSecure) return "Advanced Secure";
|
|
33
|
+
if (mode === PermissionMode.Yolo) return "YOLO";
|
|
25
34
|
return "Default";
|
|
26
35
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted ask-tier overrides (ADR-007). Both layers write the same policy
|
|
3
|
+
* file from their "remember" branch, so the read-modify-write lives here once:
|
|
4
|
+
* appending a grant must preserve the other override kinds and every
|
|
5
|
+
* hand-written key, and a trusted project file must stay trusted after its own
|
|
6
|
+
* grant is written.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { readPolicyForUpdate } from "./policy-file";
|
|
12
|
+
import { recordProjectTrust } from "../trust";
|
|
13
|
+
|
|
14
|
+
/** Erasable enum for the three additive grant lists in `overrides`. */
|
|
15
|
+
export const OverrideKind = {
|
|
16
|
+
AllowRead: "allowRead",
|
|
17
|
+
AllowWrite: "allowWrite",
|
|
18
|
+
AllowDomains: "allowDomains",
|
|
19
|
+
} as const;
|
|
20
|
+
export type OverrideKind = (typeof OverrideKind)[keyof typeof OverrideKind];
|
|
21
|
+
|
|
22
|
+
/** Erasable enum for where a grant is written. */
|
|
23
|
+
export const GrantScope = {
|
|
24
|
+
Cwd: "cwd",
|
|
25
|
+
Global: "global",
|
|
26
|
+
} as const;
|
|
27
|
+
export type GrantScope = (typeof GrantScope)[keyof typeof GrantScope];
|
|
28
|
+
|
|
29
|
+
type OverrideLists = Partial<Record<OverrideKind, string[]>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Append `value` to `overrides[kind]` in `path`, preserving every other key.
|
|
33
|
+
* Returns the path written. Throws when the file does not parse, so a
|
|
34
|
+
* hand-written policy is never overwritten blind.
|
|
35
|
+
*/
|
|
36
|
+
export function addOverride(path: string, kind: OverrideKind, value: string): string {
|
|
37
|
+
const existing = readPolicyForUpdate(path) as { overrides?: OverrideLists };
|
|
38
|
+
const overrides = existing.overrides ?? {};
|
|
39
|
+
const list = overrides[kind] ?? [];
|
|
40
|
+
if (!list.includes(value)) list.push(value);
|
|
41
|
+
overrides[kind] = list;
|
|
42
|
+
existing.overrides = overrides;
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `addOverride` for a trusted project file: record the new content hash, so the
|
|
50
|
+
* grant write itself does not invalidate the trust that allowed it (ADR-013).
|
|
51
|
+
*/
|
|
52
|
+
export function addProjectOverride(projectPath: string, storePath: string, kind: OverrideKind, value: string): string {
|
|
53
|
+
const path = addOverride(projectPath, kind, value);
|
|
54
|
+
recordProjectTrust(projectPath, storePath);
|
|
55
|
+
return path;
|
|
56
|
+
}
|