auto-permission-system 3.6.0 → 3.7.8
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 +10 -2
- package/extensions/guard.ts +17 -35
- package/extensions/permission-mode.ts +9 -5
- package/extensions/sandbox.ts +5 -18
- package/package.json +1 -1
- package/sandbox.example.json +10 -0
- package/skills/auto-permission-system/SKILL.md +17 -2
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical policy-file locations, shared by both layers. Keeping the strings
|
|
3
|
+
* here means the file a "remember" writes and the file the trust store keys
|
|
4
|
+
* can never drift apart.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** `<cwd>/.pi/sandbox.json` — the project-local policy (ADR-007, ADR-013). */
|
|
10
|
+
export const projectPolicyPath = (cwd: string): string => join(cwd, ".pi", "sandbox.json");
|
|
11
|
+
|
|
12
|
+
/** `<agentDir>/extensions/sandbox.json` — the global policy (and the persisted mode). */
|
|
13
|
+
export const globalPolicyPath = (agentDir: string): string => join(agentDir, "extensions", "sandbox.json");
|
|
14
|
+
|
|
15
|
+
/** `<agentDir>/extensions/sandbox.trust.json` — project-policy hashes (ADR-013). */
|
|
16
|
+
export const trustStorePath = (agentDir: string): string => join(agentDir, "extensions", "sandbox.trust.json");
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Path pattern matching shared by both layers. Pure: no pi, no OS side effects.
|
|
3
|
+
*
|
|
4
|
+
* It also owns Layer 1's violation attribution: the evidence patterns that
|
|
5
|
+
* recognise a refused filesystem access and recover the operand path from a
|
|
6
|
+
* sandboxed command's output.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
// ---------- Layer 1 violation attribution ----------
|
|
6
|
-
|
|
7
9
|
/**
|
|
8
10
|
* Filesystem-refusal evidence in a sandboxed command's output. Linux
|
|
9
11
|
* bubblewrap reports a write outside `allowWrite` as `EROFS: read-only file
|
|
10
12
|
* system`; macOS sandbox-exec reports the same fence as `Operation not
|
|
11
13
|
* permitted`; EACCES covers a permission-denied variant either can print.
|
|
12
14
|
*/
|
|
13
|
-
const
|
|
15
|
+
const BLOCKED_ACCESS_RE = /operation not permitted|EPERM|EACCES|EROFS|read-only file system/i;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* `EROFS` can only come from a modification, so it proves the refused access
|
|
@@ -21,7 +23,7 @@ const WRITE_BLOCK_RE = /EROFS|read-only file system/i;
|
|
|
21
23
|
|
|
22
24
|
/** True when the output shows the OS sandbox refused a filesystem access. */
|
|
23
25
|
export function isBlockedAccessError(output: string): boolean {
|
|
24
|
-
return
|
|
26
|
+
return BLOCKED_ACCESS_RE.test(output);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** True when the refused access was a write (EROFS only happens on a modification). */
|
|
@@ -30,56 +32,90 @@ export function isWriteBlockError(output: string): boolean {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
|
|
33
|
-
function
|
|
34
|
-
let token =
|
|
35
|
+
function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
|
|
36
|
+
let token = rawToken.trim().replace(/[.,;]+$/, "");
|
|
35
37
|
if (!token || /^(bash|sh|zsh)$/.test(token)) return undefined;
|
|
36
38
|
if (token === "~" || token.startsWith("~/")) token = home + token.slice(1);
|
|
37
|
-
const
|
|
38
|
-
return normalizeAbs(
|
|
39
|
+
const absolute = token.startsWith("/") ? token : `${cwd.replace(/\/+$/, "")}/${token}`;
|
|
40
|
+
return normalizeAbs(absolute);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strip the diagnostic lead-in that precedes an unquoted operand so the whole
|
|
45
|
+
* operand survives (`open /path with spaces/file` -> `/path with spaces/file`).
|
|
46
|
+
* Handles the `[pid:...]` prefix runtimes print before errno-style messages and
|
|
47
|
+
* the coreutils verbs (`cannot touch`, `cannot remove`, ...).
|
|
48
|
+
*/
|
|
49
|
+
function stripErrnoLeadIn(text: string): string {
|
|
50
|
+
const withoutPidPrefix = text.replace(/^\[[^\]]*\]\s*/, "");
|
|
51
|
+
return withoutPidPrefix
|
|
52
|
+
.replace(/^bwrap:\s*/i, "")
|
|
53
|
+
.replace(/^(?:open|cannot (?:touch|remove|create|open|stat|access)|(?:can't|failed to) create file|touch|creat|unlink|mkdir|rmdir|rename)\s+/i, "")
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a recovered operand really names a file. Operation words that appear
|
|
59
|
+
* in diagnostics (`socket`, `display`, `failed`, `Failed to open socket`) are
|
|
60
|
+
* not paths; a path either is rooted, contains a separator, or carries an
|
|
61
|
+
* extension. A candidate with whitespace must contain a separator, so a
|
|
62
|
+
* sentence like `. socket() failed` is rejected while `Crash Reports/x.dmp`
|
|
63
|
+
* (an unquoted path with a space) is kept.
|
|
64
|
+
*/
|
|
65
|
+
function looksLikePath(token: string): boolean {
|
|
66
|
+
if (!token) return false;
|
|
67
|
+
if (/\s/.test(token)) return token.includes("/");
|
|
68
|
+
if (token.startsWith("/") || token.startsWith("~")) return true;
|
|
69
|
+
if (token.startsWith(".")) return true;
|
|
70
|
+
return token.includes("/") || token.includes(".");
|
|
39
71
|
}
|
|
40
72
|
|
|
41
73
|
/**
|
|
42
|
-
* The path a sandboxed bash command was refused, from its error output, as
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
74
|
+
* The path a sandboxed bash command was refused, from its error output, as an
|
|
75
|
+
* absolute path. Handles `tool: ./rel: Operation not permitted`, quoted paths
|
|
76
|
+
* (`cannot touch 'x.pem'`), `~/…` and absolute paths, plus the errno-style
|
|
77
|
+
* forms that put the path *after* the error text
|
|
46
78
|
* (`EROFS: read-only file system, open '/abs/path'`,
|
|
47
|
-
* `[Errno 30] Read-only file system: '/abs/path'`).
|
|
48
|
-
*
|
|
79
|
+
* `[Errno 30] Read-only file system: '/abs/path'`). A path with spaces keeps
|
|
80
|
+
* its spaces (`open /…/Crash Reports/new/x.dmp`) while operation words
|
|
81
|
+
* (`socket`, `display`) are not mistaken for files. A `bwrap: Can't create
|
|
82
|
+
* file <path>` line is the OS sandbox failing to build its own mount mask, but
|
|
83
|
+
* its path is still recovered so the caller can report it as a setup problem.
|
|
84
|
+
* Relative paths resolve against the command's working directory, never
|
|
85
|
+
* against `/`.
|
|
49
86
|
*/
|
|
50
87
|
export function extractBlockedPath(output: string, cwd: string, home: string): string | undefined {
|
|
51
|
-
for (const line of output.split("\n").reverse()) {
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const quoted = [...line.matchAll(/['"“‘`]([^'"”’`]+)['"”’`]/g)].map((q) => q[1] as string);
|
|
57
|
-
const quotedPath = quoted.length ? toAbsPath(quoted[quoted.length - 1] as string, cwd, home) : undefined;
|
|
88
|
+
for (const line of [...output.split("\n")].reverse()) {
|
|
89
|
+
const match = BLOCKED_ACCESS_RE.exec(line);
|
|
90
|
+
if (!match) continue;
|
|
91
|
+
const quotedTokens = [...line.matchAll(/['"“‘`]([^'"”’`]+)['"”’`]/g)].map((quote) => quote[1] as string);
|
|
92
|
+
const quotedPath = toAbsolutePath(quotedTokens.at(-1) ?? "", cwd, home);
|
|
58
93
|
if (quotedPath) return quotedPath;
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
94
|
+
const beforeError = line.slice(0, match.index).replace(/[\s:]+$/, "");
|
|
95
|
+
const candidate = stripErrnoLeadIn(beforeError.split(/:\s+/).pop() ?? "");
|
|
96
|
+
if (!looksLikePath(candidate)) continue;
|
|
97
|
+
const resolved = toAbsolutePath(candidate, cwd, home);
|
|
98
|
+
if (resolved) return resolved;
|
|
64
99
|
}
|
|
65
100
|
return undefined;
|
|
66
101
|
}
|
|
67
102
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
/** Collapse `.` and `..` segments to a canonical absolute path (no filesystem access). */
|
|
104
|
+
export function normalizeAbs(path: string): string {
|
|
105
|
+
const parts: string[] = [];
|
|
106
|
+
for (const part of path.split("/")) {
|
|
71
107
|
if (!part || part === ".") continue;
|
|
72
|
-
if (part === "..")
|
|
73
|
-
else
|
|
108
|
+
if (part === "..") parts.pop();
|
|
109
|
+
else parts.push(part);
|
|
74
110
|
}
|
|
75
|
-
return `/${
|
|
111
|
+
return `/${parts.join("/")}`;
|
|
76
112
|
}
|
|
77
113
|
|
|
78
114
|
/** A folder Layer 1 may offer as a one-click write grant: never `/`, the home folder, or anything above it. */
|
|
79
|
-
export function isSafeFolderGrant(
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
return
|
|
115
|
+
export function isSafeFolderGrant(directory: string, home: string): boolean {
|
|
116
|
+
const normalizedDirectory = normalizeAbs(directory);
|
|
117
|
+
const normalizedHome = normalizeAbs(home);
|
|
118
|
+
return normalizedDirectory !== "/" && normalizedDirectory !== normalizedHome && !normalizedHome.startsWith(`${normalizedDirectory}/`);
|
|
83
119
|
}
|
|
84
120
|
|
|
85
121
|
/**
|
|
@@ -88,26 +124,26 @@ export function isSafeFolderGrant(dir: string, home: string): boolean {
|
|
|
88
124
|
* `.` is the project root, anything else matches the basename (glob allowed).
|
|
89
125
|
*/
|
|
90
126
|
export function matchesPolicyPattern(absPath: string, pattern: string, cwd: string, home: string): boolean {
|
|
91
|
-
let
|
|
92
|
-
if (
|
|
93
|
-
else if (
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const body =
|
|
127
|
+
let expandedPattern = pattern;
|
|
128
|
+
if (expandedPattern === "~") expandedPattern = home;
|
|
129
|
+
else if (expandedPattern.startsWith("~/")) expandedPattern = `${home}/${expandedPattern.slice(2)}`;
|
|
130
|
+
const caseInsensitiveFlag = process.platform === "darwin" ? "i" : "";
|
|
131
|
+
const compileGlob = (source: string): RegExp => {
|
|
132
|
+
const body = source
|
|
97
133
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
98
134
|
.split("**")
|
|
99
135
|
.map((part) => part.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"))
|
|
100
136
|
.join(".*");
|
|
101
|
-
return new RegExp(`^${body}$`,
|
|
137
|
+
return new RegExp(`^${body}$`, caseInsensitiveFlag);
|
|
102
138
|
};
|
|
103
|
-
if (
|
|
104
|
-
if (
|
|
105
|
-
if (
|
|
106
|
-
return absPath ===
|
|
139
|
+
if (expandedPattern === ".") return absPath === cwd || absPath.startsWith(`${cwd}/`);
|
|
140
|
+
if (expandedPattern.startsWith("/")) {
|
|
141
|
+
if (expandedPattern.includes("*")) return compileGlob(expandedPattern).test(absPath);
|
|
142
|
+
return absPath === expandedPattern || absPath.startsWith(`${expandedPattern}/`);
|
|
107
143
|
}
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
return
|
|
144
|
+
const basename = absPath.slice(absPath.lastIndexOf("/") + 1);
|
|
145
|
+
if (expandedPattern.includes("*")) return compileGlob(expandedPattern).test(basename);
|
|
146
|
+
return caseInsensitiveFlag ? basename.toLowerCase() === expandedPattern.toLowerCase() : basename === expandedPattern;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
/**
|
|
@@ -121,5 +157,5 @@ export function matchesPolicyPattern(absPath: string, pattern: string, cwd: stri
|
|
|
121
157
|
* are passed through unchanged.
|
|
122
158
|
*/
|
|
123
159
|
export function toSandboxPatterns(patterns: readonly string[]): string[] {
|
|
124
|
-
return patterns.map((
|
|
160
|
+
return patterns.map((pattern) => (pattern === "." || pattern.includes("/") || pattern.startsWith("~") ? pattern : `**/${pattern}`));
|
|
125
161
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and updating a policy file in place. Shared by both layers so the
|
|
3
|
+
* parse guard and the mode write cannot drift.
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
7
|
import { dirname } from "node:path";
|
|
3
8
|
import { normalizeMode, type PermissionMode } from "./mode";
|
|
@@ -16,8 +21,8 @@ export function policyFileError(path: string): string | null {
|
|
|
16
21
|
try {
|
|
17
22
|
JSON.parse(readFileSync(path, "utf-8"));
|
|
18
23
|
return null;
|
|
19
|
-
} catch (
|
|
20
|
-
return
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -30,15 +35,15 @@ export function readPolicyForUpdate(path: string): Record<string, unknown> {
|
|
|
30
35
|
if (!existsSync(path)) return {};
|
|
31
36
|
try {
|
|
32
37
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
33
|
-
} catch (
|
|
34
|
-
throw new Error(`refusing to overwrite ${path}: it does not parse as JSON (${
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`refusing to overwrite ${path}: it does not parse as JSON (${error instanceof Error ? error.message : error})`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* Set `mode` in a policy file in place, preserving its other fields (ADR-018).
|
|
40
|
-
* Used by `/permission-mode` and the Shift+S shortcut. Creates the file when
|
|
41
|
-
* is absent; refuses to overwrite one that does not parse.
|
|
45
|
+
* Used by `/permission-mode` and the Shift+S shortcut. Creates the file when
|
|
46
|
+
* it is absent; refuses to overwrite one that does not parse.
|
|
42
47
|
*/
|
|
43
48
|
export function setPolicyMode(path: string, mode: PermissionMode): void {
|
|
44
49
|
const policy = readPolicyForUpdate(path);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3 vocabulary: the `subagent.network` posture (ADR-005). Pure.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Erasable enum for `subagent.network`. */
|
|
6
|
+
export const SubagentNetwork = {
|
|
7
|
+
Allow: "allow",
|
|
8
|
+
Deny: "deny",
|
|
9
|
+
ResearchOnly: "research-only",
|
|
10
|
+
} as const;
|
|
11
|
+
export type SubagentNetwork = (typeof SubagentNetwork)[keyof typeof SubagentNetwork];
|