@pmndrs/pine 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +180 -2
- package/bin/pine.js +27 -0
- package/docs/SKILL.md +189 -0
- package/docs/references/agents.md +368 -0
- package/docs/references/coding.md +126 -0
- package/docs/references/configuration.md +229 -0
- package/docs/references/events.md +263 -0
- package/docs/references/mcp.md +257 -0
- package/docs/references/operations.md +280 -0
- package/docs/references/overview.md +186 -0
- package/docs/references/security.md +489 -0
- package/docs/references/setup.md +181 -0
- package/docs/references/sources.md +768 -0
- package/docs/references/stack.md +105 -0
- package/docs/references/tui.md +348 -0
- package/examples/home/.env.example +5 -0
- package/examples/home/USER.md +4 -0
- package/examples/home/agents/main/agent.json +4 -0
- package/examples/home/agents/main/workspace/AGENTS.md +14 -0
- package/examples/home/agents/main/workspace/MEMORY.md +4 -0
- package/examples/home/agents/main/workspace/SOUL.md +4 -0
- package/examples/home/models.json +62 -0
- package/examples/home/pine.json +30 -0
- package/examples/home/skills/README.txt +7 -0
- package/examples/launchd/com.pine.daemon.plist +32 -0
- package/mcp/browser.d.ts +34 -0
- package/mcp/browser.js +228 -0
- package/mcp/commands.d.ts +20 -0
- package/mcp/commands.js +88 -0
- package/mcp/lib.d.ts +13 -0
- package/mcp/lib.js +50 -0
- package/mcp/web.d.ts +6 -0
- package/mcp/web.js +140 -0
- package/package.json +32 -4
- package/src/agent-admin.d.ts +24 -0
- package/src/agent-admin.js +55 -0
- package/src/agents.d.ts +343 -0
- package/src/agents.js +1587 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +526 -0
- package/src/config.d.ts +351 -0
- package/src/config.js +514 -0
- package/src/dev/claude-model.d.ts +28 -0
- package/src/dev/claude-model.js +174 -0
- package/src/env-file.d.ts +4 -0
- package/src/env-file.js +40 -0
- package/src/http.d.ts +46 -0
- package/src/http.js +407 -0
- package/src/limiter.d.ts +32 -0
- package/src/limiter.js +84 -0
- package/src/login.d.ts +21 -0
- package/src/login.js +126 -0
- package/src/manual.d.ts +61 -0
- package/src/manual.js +425 -0
- package/src/mcp-config.d.ts +40 -0
- package/src/mcp-config.js +132 -0
- package/src/net.d.ts +36 -0
- package/src/net.js +122 -0
- package/src/pine.d.ts +32 -0
- package/src/pine.js +142 -0
- package/src/router.d.ts +56 -0
- package/src/router.js +164 -0
- package/src/sandbox-fs.d.ts +27 -0
- package/src/sandbox-fs.js +172 -0
- package/src/security.d.ts +121 -0
- package/src/security.js +363 -0
- package/src/skills.d.ts +67 -0
- package/src/skills.js +163 -0
- package/src/source-types.d.ts +61 -0
- package/src/source-types.js +1 -0
- package/src/sources/command.d.ts +4 -0
- package/src/sources/command.js +77 -0
- package/src/sources/folder.d.ts +6 -0
- package/src/sources/folder.js +116 -0
- package/src/sources/ftp.d.ts +4 -0
- package/src/sources/ftp.js +307 -0
- package/src/sources/http.d.ts +4 -0
- package/src/sources/http.js +201 -0
- package/src/sources/mqtt.d.ts +18 -0
- package/src/sources/mqtt.js +97 -0
- package/src/sources/telegram.d.ts +22 -0
- package/src/sources/telegram.js +269 -0
- package/src/sources/webhook.d.ts +4 -0
- package/src/sources/webhook.js +52 -0
- package/src/sources.d.ts +339 -0
- package/src/sources.js +955 -0
- package/src/store.d.ts +89 -0
- package/src/store.js +251 -0
- package/src/timers.d.ts +83 -0
- package/src/timers.js +318 -0
- package/src/tools.d.ts +6 -0
- package/src/tools.js +777 -0
- package/src/tui/app.d.ts +209 -0
- package/src/tui/app.js +2223 -0
- package/src/tui/client.d.ts +178 -0
- package/src/tui/client.js +116 -0
- package/src/tui/code-pane.d.ts +51 -0
- package/src/tui/code-pane.js +398 -0
- package/src/tui/highlight.d.ts +8 -0
- package/src/tui/highlight.js +86 -0
- package/src/tui/picture.d.ts +141 -0
- package/src/tui/picture.js +571 -0
- package/src/tui/run.d.ts +8 -0
- package/src/tui/run.js +97 -0
- package/src/tui/theme.d.ts +32 -0
- package/src/tui/theme.js +128 -0
- package/src/types.d.ts +124 -0
- package/src/types.js +1 -0
- package/src/update.d.ts +25 -0
- package/src/update.js +110 -0
- package/src/util.d.ts +32 -0
- package/src/util.js +116 -0
- package/src/watching.d.ts +20 -0
- package/src/watching.js +68 -0
- package/src/workspace-view.d.ts +38 -0
- package/src/workspace-view.js +160 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
const MAX_READ = 50 * 1024 * 1024;
|
|
6
|
+
const MAX_OUT = 40_000;
|
|
7
|
+
export function sandboxExec(fs, argv, opts = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn("/usr/bin/sandbox-exec", ["-f", fs.profile(), ...argv], { cwd: fs.cwd, env: fs.env(), stdio: ["pipe", "pipe", "pipe"] });
|
|
10
|
+
const max = opts.maxBytes ?? MAX_READ;
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
let truncated = false;
|
|
14
|
+
let stderr = "";
|
|
15
|
+
child.stdout.on("data", (d) => {
|
|
16
|
+
if (size + d.length > max) {
|
|
17
|
+
truncated = true;
|
|
18
|
+
chunks.push(d.subarray(0, Math.max(0, max - size)));
|
|
19
|
+
size = max;
|
|
20
|
+
child.kill("SIGKILL");
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
chunks.push(d);
|
|
24
|
+
size += d.length;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
child.stderr.on("data", (d) => (stderr.length < 4000 ? (stderr += d.toString("utf8")) : undefined));
|
|
28
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 60_000);
|
|
29
|
+
const onAbort = () => child.kill("SIGKILL");
|
|
30
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
31
|
+
child.on("error", (e) => (clearTimeout(timer), reject(e)));
|
|
32
|
+
child.on("close", (code) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
35
|
+
resolve({ code: code ?? (truncated ? 0 : 1), stdout: Buffer.concat(chunks), stderr, truncated });
|
|
36
|
+
});
|
|
37
|
+
child.stdin.on("error", () => { });
|
|
38
|
+
child.stdin.end(opts.stdin ?? "");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function explain(file, stderr) {
|
|
42
|
+
if (/Operation not permitted|Permission denied/.test(stderr))
|
|
43
|
+
return new Error(`${file}: not permitted. Your file access is limited to your workspace.`);
|
|
44
|
+
if (/No such file or directory/.test(stderr))
|
|
45
|
+
return Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`), { code: "ENOENT" });
|
|
46
|
+
if (/Is a directory/.test(stderr))
|
|
47
|
+
return Object.assign(new Error(`EISDIR: ${file} is a directory`), { code: "EISDIR" });
|
|
48
|
+
return new Error(`${file}: ${stderr.trim().split("\n")[0] || "operation failed"}`);
|
|
49
|
+
}
|
|
50
|
+
export function sniffImage(head) {
|
|
51
|
+
if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
52
|
+
return "image/png";
|
|
53
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)
|
|
54
|
+
return "image/jpeg";
|
|
55
|
+
if (head.length >= 6 && ["GIF87a", "GIF89a"].includes(head.subarray(0, 6).toString("latin1")))
|
|
56
|
+
return "image/gif";
|
|
57
|
+
if (head.length >= 12 && head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP")
|
|
58
|
+
return "image/webp";
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const text = (s) => ({ content: [{ type: "text", text: s }], details: undefined });
|
|
62
|
+
const clip = (s) => (s.length > MAX_OUT ? `${s.slice(0, MAX_OUT)}\n[output cut at ${MAX_OUT} characters; narrow the search]` : s);
|
|
63
|
+
/** read, write, edit, grep, find and ls, all executed through sandbox-exec. */
|
|
64
|
+
export function sandboxedFileTools(fs, enabled) {
|
|
65
|
+
const readFile = async (file) => {
|
|
66
|
+
const r = await sandboxExec(fs, ["/bin/cat", "--", file]);
|
|
67
|
+
if (r.code !== 0 && !r.truncated)
|
|
68
|
+
throw explain(file, r.stderr);
|
|
69
|
+
return r.stdout;
|
|
70
|
+
};
|
|
71
|
+
const writeFile = async (file, content) => {
|
|
72
|
+
const r = await sandboxExec(fs, ["/bin/sh", "-c", 'cat > "$1"', "sh", file], { stdin: content });
|
|
73
|
+
if (r.code !== 0)
|
|
74
|
+
throw explain(file, r.stderr);
|
|
75
|
+
};
|
|
76
|
+
// Opening the file is the only check that tells "missing" from "not permitted".
|
|
77
|
+
const readable = async (file) => {
|
|
78
|
+
const r = await sandboxExec(fs, ["/usr/bin/head", "-c", "1", "--", file], { maxBytes: 16 });
|
|
79
|
+
if (r.code !== 0)
|
|
80
|
+
throw explain(file, r.stderr || "No such file or directory");
|
|
81
|
+
};
|
|
82
|
+
const writable = async (file) => {
|
|
83
|
+
await readable(file);
|
|
84
|
+
const r = await sandboxExec(fs, ["/bin/test", "-w", file]);
|
|
85
|
+
if (r.code !== 0)
|
|
86
|
+
throw explain(file, "Permission denied");
|
|
87
|
+
};
|
|
88
|
+
const resolve = (p) => path.resolve(fs.cwd, p && p.length ? p : ".");
|
|
89
|
+
const tools = {
|
|
90
|
+
read: () => createReadToolDefinition(fs.cwd, {
|
|
91
|
+
operations: {
|
|
92
|
+
readFile,
|
|
93
|
+
access: readable,
|
|
94
|
+
detectImageMimeType: async (file) => sniffImage((await sandboxExec(fs, ["/usr/bin/head", "-c", "16", "--", file])).stdout),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
write: () => createWriteToolDefinition(fs.cwd, {
|
|
98
|
+
operations: {
|
|
99
|
+
writeFile,
|
|
100
|
+
mkdir: async (dir) => {
|
|
101
|
+
const r = await sandboxExec(fs, ["/bin/mkdir", "-p", "--", dir]);
|
|
102
|
+
if (r.code !== 0)
|
|
103
|
+
throw explain(dir, r.stderr);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
edit: () => createEditToolDefinition(fs.cwd, { operations: { readFile, writeFile, access: writable } }),
|
|
108
|
+
ls: () => defineTool({
|
|
109
|
+
name: "ls",
|
|
110
|
+
label: "ls",
|
|
111
|
+
description: "List the entries of a directory. Directories end with /.",
|
|
112
|
+
parameters: Type.Object({ path: Type.Optional(Type.String({ description: "Directory, default: your workspace" })), limit: Type.Optional(Type.Number({ description: "Maximum entries, default 500" })) }),
|
|
113
|
+
execute: async (_id, p, signal) => {
|
|
114
|
+
const dir = resolve(p.path);
|
|
115
|
+
const r = await sandboxExec(fs, ["/bin/ls", "-1Ap", "--", dir], { signal, maxBytes: 2_000_000 });
|
|
116
|
+
if (r.code !== 0)
|
|
117
|
+
throw explain(p.path ?? ".", r.stderr);
|
|
118
|
+
const entries = r.stdout.toString("utf8").split("\n").filter(Boolean);
|
|
119
|
+
const limit = Math.max(1, Math.min(Number(p.limit) || 500, 5000));
|
|
120
|
+
return text(entries.length ? entries.slice(0, limit).join("\n") + (entries.length > limit ? `\n[${entries.length - limit} more entries]` : "") : "(empty directory)");
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
find: () => defineTool({
|
|
124
|
+
name: "find",
|
|
125
|
+
label: "find",
|
|
126
|
+
description: "Find files by name. The pattern is a glob such as *.md or src/*.ts; .git and node_modules are skipped.",
|
|
127
|
+
parameters: Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String({ description: "Directory to search, default: your workspace" })), limit: Type.Optional(Type.Number({ description: "Maximum results, default 200" })) }),
|
|
128
|
+
execute: async (_id, p, signal) => {
|
|
129
|
+
const dir = resolve(p.path);
|
|
130
|
+
const match = p.pattern.includes("/") ? ["-path", `*${p.pattern.replace(/^\.?\//, "")}`] : ["-name", p.pattern];
|
|
131
|
+
const r = await sandboxExec(fs, ["/usr/bin/find", dir, "(", "-name", ".git", "-o", "-name", "node_modules", ")", "-prune", "-o", ...match, "-print"], { signal, maxBytes: 4_000_000 });
|
|
132
|
+
const lines = r.stdout.toString("utf8").split("\n").filter(Boolean).map((f) => path.relative(dir, f) || ".");
|
|
133
|
+
if (!lines.length && r.code !== 0 && r.stderr)
|
|
134
|
+
throw explain(p.path ?? ".", r.stderr);
|
|
135
|
+
const limit = Math.max(1, Math.min(Number(p.limit) || 200, 2000));
|
|
136
|
+
return text(lines.length ? clip(lines.slice(0, limit).join("\n") + (lines.length > limit ? `\n[${lines.length - limit} more results]` : "")) : "no files found");
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
grep: () => defineTool({
|
|
140
|
+
name: "grep",
|
|
141
|
+
label: "grep",
|
|
142
|
+
description: "Search file contents with a regular expression. Returns file:line:text. .git and node_modules are skipped.",
|
|
143
|
+
parameters: Type.Object({
|
|
144
|
+
pattern: Type.String({ description: "Regular expression (extended syntax)" }),
|
|
145
|
+
path: Type.Optional(Type.String({ description: "File or directory, default: your workspace" })),
|
|
146
|
+
glob: Type.Optional(Type.String({ description: "Only files whose name matches, for example *.md" })),
|
|
147
|
+
ignoreCase: Type.Optional(Type.Boolean()),
|
|
148
|
+
literal: Type.Optional(Type.Boolean({ description: "Treat the pattern as plain text" })),
|
|
149
|
+
context: Type.Optional(Type.Number({ description: "Lines of context around each match" })),
|
|
150
|
+
limit: Type.Optional(Type.Number({ description: "Maximum output lines, default 200" })),
|
|
151
|
+
}),
|
|
152
|
+
execute: async (_id, p, signal) => {
|
|
153
|
+
const target = resolve(p.path);
|
|
154
|
+
const args = ["-rnI", p.literal ? "-F" : "-E", "--exclude-dir=.git", "--exclude-dir=node_modules"];
|
|
155
|
+
if (p.ignoreCase)
|
|
156
|
+
args.push("-i");
|
|
157
|
+
if (p.glob)
|
|
158
|
+
args.push(`--include=${p.glob}`);
|
|
159
|
+
if (Number(p.context) > 0)
|
|
160
|
+
args.push("-C", String(Math.min(Number(p.context), 20)));
|
|
161
|
+
const r = await sandboxExec(fs, ["/usr/bin/grep", ...args, "-e", p.pattern, "--", target], { signal, maxBytes: 4_000_000 });
|
|
162
|
+
if (r.code > 1 && !r.stdout.length)
|
|
163
|
+
throw explain(p.path ?? ".", r.stderr);
|
|
164
|
+
const prefix = target.endsWith(path.sep) ? target : target + path.sep;
|
|
165
|
+
const lines = r.stdout.toString("utf8").split("\n").filter(Boolean).map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : l));
|
|
166
|
+
const limit = Math.max(1, Math.min(Number(p.limit) || 200, 2000));
|
|
167
|
+
return text(lines.length ? clip(lines.slice(0, limit).join("\n") + (lines.length > limit ? `\n[${lines.length - limit} more lines]` : "")) : "no matches");
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
return enabled.filter((name) => tools[name]).map((name) => tools[name]());
|
|
172
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type AgentConfig, type PineConfig } from "./config.ts";
|
|
2
|
+
import type { Bus } from "./store.ts";
|
|
3
|
+
import { type Logger } from "./util.ts";
|
|
4
|
+
/** realpath of the deepest existing ancestor, plus the part that does not exist yet. Defeats symlink escapes. */
|
|
5
|
+
export declare function realish(p: string): string;
|
|
6
|
+
export declare const isInside: (root: string, p: string) => boolean;
|
|
7
|
+
export interface Roots {
|
|
8
|
+
workspace: string;
|
|
9
|
+
/** Private HOME and TMPDIR of the agent's shell commands. */
|
|
10
|
+
sandboxHome: string;
|
|
11
|
+
/** pine's home directory: off limits except for the parts listed in ownedRead / ownedWrite. */
|
|
12
|
+
home: string;
|
|
13
|
+
read: string[];
|
|
14
|
+
write: string[];
|
|
15
|
+
/** The agent's own roots, without the shared temp directories. */
|
|
16
|
+
ownedRead: string[];
|
|
17
|
+
ownedWrite: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Where this session's shell and file tools start. The agent's workspace, or the folder the
|
|
20
|
+
* owner opened for the conversation, which is then readable and writable like the workspace.
|
|
21
|
+
*/
|
|
22
|
+
cwd: string;
|
|
23
|
+
/** The folder the owner opened for the conversation, if any. Its instruction files are guarded like the workspace's. */
|
|
24
|
+
folder?: string;
|
|
25
|
+
/** USER.md in pine's home: what all agents know about the owner. Only the main agent may write it. */
|
|
26
|
+
userFile: string;
|
|
27
|
+
/** Read access is refused here even when a broader rule would allow it. */
|
|
28
|
+
secrets: string[];
|
|
29
|
+
/** Workspaces of the other agents. Agents exchange information through delegation, not through each other's files. */
|
|
30
|
+
others: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function rootsFor(cfg: PineConfig, agent: AgentConfig): Roots;
|
|
33
|
+
/**
|
|
34
|
+
* Which sandbox applies to a call.
|
|
35
|
+
* normal: the agent's own folders.
|
|
36
|
+
* restricted: the same, and its instruction files are read-only (the run has handled outside content).
|
|
37
|
+
* open: bypass mode: the whole user account, except pine's home and pine's own code.
|
|
38
|
+
*/
|
|
39
|
+
export type SandboxVariant = "normal" | "restricted" | "open";
|
|
40
|
+
/** Why a file tool may not touch `target`, or undefined when it may. */
|
|
41
|
+
export declare function checkPath(roots: Roots, kind: "read" | "write", target: string, cwd: string, variant: SandboxVariant | boolean): string | undefined;
|
|
42
|
+
export declare function isInstructionPath(workspace: string, p: string): boolean;
|
|
43
|
+
/** Whether a path is one of the files that are read into the agent's prompt from its workspace, or the owner's file in pine's home. */
|
|
44
|
+
export declare function isOwnFile(roots: Roots, p: string): boolean;
|
|
45
|
+
/** Whether a path in one of the agent's folders is a repository's hooks or configuration, or the repository's own folder. */
|
|
46
|
+
export declare function isGitRunPath(root: string, p: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* macOS sandbox profile for everything an agent does on the file system: its shell commands and
|
|
49
|
+
* its file tools. Nothing is allowed unless a rule here allows it; for file rules the last match wins.
|
|
50
|
+
*
|
|
51
|
+
* Allowed: running programs, reading outside the user's home, writing in the agent's own folders
|
|
52
|
+
* and temp. Not allowed: the user's home, pine's home (secrets, configuration, other agents),
|
|
53
|
+
* other processes (signals, their arguments and environment), launching apps, AppleScript,
|
|
54
|
+
* the keychain, the clipboard, and the network unless the agent's configuration turns it on.
|
|
55
|
+
*/
|
|
56
|
+
export declare function sandboxProfile(_cfg: PineConfig, agent: AgentConfig, roots: Roots, variant: SandboxVariant | boolean, network?: boolean): string;
|
|
57
|
+
export declare const sandboxAvailable: () => boolean;
|
|
58
|
+
/** Environment for an agent's shell: nothing from the daemon except what a shell needs. */
|
|
59
|
+
export declare function shellEnv(agent: AgentConfig, roots: Roots, sessionEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
60
|
+
export declare const shq: (s: string) => string;
|
|
61
|
+
/** Glob with `*` only, anchored. */
|
|
62
|
+
export declare function globMatch(pattern: string, value: string): boolean;
|
|
63
|
+
/** "tool" or "tool:argument-glob". The argument is the command, topic, agent or path the call is about. */
|
|
64
|
+
export declare function matchesToolPattern(patterns: readonly string[], tool: string, subject: string): boolean;
|
|
65
|
+
/** The part of a tool call a person needs to see to judge it. */
|
|
66
|
+
export declare function toolSubject(tool: string, input: any): string;
|
|
67
|
+
export interface ApprovalRequest {
|
|
68
|
+
id: string;
|
|
69
|
+
agent: string;
|
|
70
|
+
task: number;
|
|
71
|
+
/** The task at the top of the delegation tree this request belongs to. */
|
|
72
|
+
root?: number;
|
|
73
|
+
tool: string;
|
|
74
|
+
summary: string;
|
|
75
|
+
why: string;
|
|
76
|
+
expires: number;
|
|
77
|
+
/** What "always" would cover for the rest of the conversation, e.g. "all home-link tools". Absent: the question is asked every time. */
|
|
78
|
+
remember?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Values only the owner has and that must not pass through a model: a password, a token. The
|
|
81
|
+
* owner types them where the question is shown. A yes without them is not a yes.
|
|
82
|
+
*/
|
|
83
|
+
inputs?: Array<{
|
|
84
|
+
name: string;
|
|
85
|
+
label: string;
|
|
86
|
+
secret?: boolean;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
|
89
|
+
export interface ApprovalDecision {
|
|
90
|
+
allow: boolean;
|
|
91
|
+
by: string;
|
|
92
|
+
/** Allowed, and not to be asked again in this conversation. */
|
|
93
|
+
always?: boolean;
|
|
94
|
+
/** What the owner typed for the request's `inputs`. Never published as an event. */
|
|
95
|
+
values?: Record<string, string>;
|
|
96
|
+
}
|
|
97
|
+
/** Human-in-the-loop gate. Requests are events; anything that can reach the owner may answer them. */
|
|
98
|
+
export declare class Approvals {
|
|
99
|
+
private bus;
|
|
100
|
+
private log;
|
|
101
|
+
private timeoutMs;
|
|
102
|
+
private open;
|
|
103
|
+
constructor(bus: Bus, log: Logger, timeoutMs: number);
|
|
104
|
+
request(r: Omit<ApprovalRequest, "id" | "expires">, signal?: AbortSignal): Promise<ApprovalDecision>;
|
|
105
|
+
/** The fields of an open request that a yes has to come with and that `values` does not have. */
|
|
106
|
+
missing(id: string, values?: Record<string, string>): string[];
|
|
107
|
+
/** Returns false when the id is unknown or already answered, or when a yes lacks what the request asks the owner to type. */
|
|
108
|
+
decide(id: string, allow: boolean, by: string, always?: boolean, values?: Record<string, string>): boolean;
|
|
109
|
+
pending(): ApprovalRequest[];
|
|
110
|
+
denyAll(by: string): void;
|
|
111
|
+
}
|
|
112
|
+
/** Replaces known secret values in text that leaves the daemon. */
|
|
113
|
+
export declare class Redactor {
|
|
114
|
+
private secrets;
|
|
115
|
+
add(values: Iterable<string | undefined>): void;
|
|
116
|
+
text(s: string): string;
|
|
117
|
+
deep<T>(v: T): T;
|
|
118
|
+
}
|
|
119
|
+
export declare const timingSafeEqual: (a: string, b: string) => boolean;
|
|
120
|
+
/** Things about a configuration that weaken its defences. Shown by `pine check` and at startup. */
|
|
121
|
+
export declare function audit(cfg: PineConfig): string[];
|
package/src/security.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { agentSkillsDir, conversationsDir, globalSkillsDir, mainAgentId, PINE_ROOT, userFile, WORKSPACE_FILES } from "./config.js";
|
|
6
|
+
import { truncate } from "./util.js";
|
|
7
|
+
/** realpath of the deepest existing ancestor, plus the part that does not exist yet. Defeats symlink escapes. */
|
|
8
|
+
export function realish(p) {
|
|
9
|
+
let cur = path.resolve(p);
|
|
10
|
+
const rest = [];
|
|
11
|
+
for (;;) {
|
|
12
|
+
try {
|
|
13
|
+
return path.join(fs.realpathSync(cur), ...rest.reverse());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
const parent = path.dirname(cur);
|
|
17
|
+
if (parent === cur)
|
|
18
|
+
return path.resolve(p);
|
|
19
|
+
rest.push(path.basename(cur));
|
|
20
|
+
cur = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const isInside = (root, p) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
25
|
+
/** Files that steer an agent or the daemon. A run that has seen outside content must not change them. */
|
|
26
|
+
const INSTRUCTION_FILES = ["AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "SYSTEM.md"];
|
|
27
|
+
const INSTRUCTION_DIRS = [".pi", ".agents", ".git/hooks"];
|
|
28
|
+
export function rootsFor(cfg, agent) {
|
|
29
|
+
const sandboxHome = path.join(cfg.home, "sandbox", agent.id);
|
|
30
|
+
fs.mkdirSync(path.join(sandboxHome, "tmp"), { recursive: true });
|
|
31
|
+
const workspace = realish(agent.workspace);
|
|
32
|
+
const home = realish(cfg.home);
|
|
33
|
+
const tmp = agent.sandbox.sharedTmp ? [...new Set([realish(os.tmpdir()), "/private/tmp"])] : [];
|
|
34
|
+
// pine's manual is readable by every agent, so it can explain how its owner changes the setup.
|
|
35
|
+
// The one file of pine's home that an agent may touch: what is known about the owner, and only for the main agent.
|
|
36
|
+
const user = path.join(home, path.basename(userFile(cfg)));
|
|
37
|
+
const keepsUser = agent.id === mainAgentId(cfg) ? [user] : [];
|
|
38
|
+
// Skills are read, never written, by the agent they are for: its own, its conversations', and those for all agents.
|
|
39
|
+
const skills = [agentSkillsDir(agent), conversationsDir(agent), globalSkillsDir(cfg), ...cfg.skills, ...agent.skills].map(realish);
|
|
40
|
+
const ownedRead = [...new Set([workspace, realish(sandboxHome), realish(path.join(PINE_ROOT, "docs")), ...skills, ...agent.sandbox.read.map(realish), ...keepsUser])];
|
|
41
|
+
const ownedWrite = [...new Set([workspace, realish(sandboxHome), ...agent.sandbox.write.map(realish), ...keepsUser])];
|
|
42
|
+
return {
|
|
43
|
+
workspace,
|
|
44
|
+
cwd: workspace,
|
|
45
|
+
sandboxHome: realish(sandboxHome),
|
|
46
|
+
home,
|
|
47
|
+
read: [...ownedRead, ...tmp],
|
|
48
|
+
write: [...ownedWrite, ...tmp],
|
|
49
|
+
ownedRead,
|
|
50
|
+
ownedWrite,
|
|
51
|
+
userFile: user,
|
|
52
|
+
secrets: [path.join(home, ".env"), path.join(home, "auth.json"), path.join(home, "models.json"), path.join(home, "pine.json"), realish(cfg.dataDir)],
|
|
53
|
+
others: Object.values(cfg.agents)
|
|
54
|
+
.filter((a) => a.id !== agent.id)
|
|
55
|
+
.map((a) => realish(a.workspace))
|
|
56
|
+
.filter((w) => ![...ownedRead, ...ownedWrite].some((own) => isInside(own, w) || isInside(w, own))),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** pine's own program files. Never writable by an agent, in any mode. */
|
|
60
|
+
const PINE_CODE = realish(PINE_ROOT);
|
|
61
|
+
/** Why a file tool may not touch `target`, or undefined when it may. */
|
|
62
|
+
export function checkPath(roots, kind, target, cwd, variant) {
|
|
63
|
+
const p = realish(path.isAbsolute(target) ? target : path.join(cwd, target));
|
|
64
|
+
const restricted = variant === true || variant === "restricted";
|
|
65
|
+
if (roots.secrets.some((s) => isInside(s, p)))
|
|
66
|
+
return `${target} is not accessible to agents`;
|
|
67
|
+
if (variant === "open") {
|
|
68
|
+
if (isInside(roots.home, p) && !(kind === "read" ? roots.ownedRead : roots.ownedWrite).some((r) => isInside(r, p)))
|
|
69
|
+
return `${target} is part of pine's own configuration, which stays closed in every mode`;
|
|
70
|
+
if (kind === "write" && isInside(PINE_CODE, p))
|
|
71
|
+
return `${target} is part of pine's own code, which agents cannot change`;
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (roots.others.some((o) => isInside(o, p)))
|
|
75
|
+
return `${target} belongs to another agent; ask that agent instead`;
|
|
76
|
+
// Inside pine's home only the agent's own folders count, even if a shared temp root would cover the path.
|
|
77
|
+
const inHome = isInside(roots.home, p);
|
|
78
|
+
const allowed = kind === "read" ? (inHome ? roots.ownedRead : roots.read) : inHome ? roots.ownedWrite : roots.write;
|
|
79
|
+
if (!allowed.some((r) => isInside(r, p)))
|
|
80
|
+
return `${target} is outside the paths this agent may ${kind} (workspace: ${roots.workspace}). If you need it, call request_access with the path and the reason; your owner decides.`;
|
|
81
|
+
const mine = [roots.workspace, ...(roots.folder !== undefined ? [roots.folder] : [])];
|
|
82
|
+
if (kind === "write" && restricted && mine.some((r) => isGitRunPath(r, p)))
|
|
83
|
+
return `${target} is part of a repository's hooks or configuration, which make git run programs, and cannot be changed in a run that has handled outside content`;
|
|
84
|
+
if (kind === "write" && restricted && (mine.some((r) => isInstructionPath(r, p)) || isOwnFile(roots, p)))
|
|
85
|
+
return `${target} holds agent instructions and cannot be changed in a run that has handled outside content`;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
export function isInstructionPath(workspace, p) {
|
|
89
|
+
if (!isInside(workspace, p))
|
|
90
|
+
return false;
|
|
91
|
+
const rel = path.relative(workspace, p);
|
|
92
|
+
return INSTRUCTION_FILES.includes(path.basename(p)) || INSTRUCTION_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep));
|
|
93
|
+
}
|
|
94
|
+
/** Whether a path is one of the files that are read into the agent's prompt from its workspace, or the owner's file in pine's home. */
|
|
95
|
+
export function isOwnFile(roots, p) {
|
|
96
|
+
return p === roots.userFile || (path.dirname(p) === roots.workspace && WORKSPACE_FILES.includes(path.basename(p)));
|
|
97
|
+
}
|
|
98
|
+
/** Whether a path in one of the agent's folders is a repository's hooks or configuration, or the repository's own folder. */
|
|
99
|
+
export function isGitRunPath(root, p) {
|
|
100
|
+
return isInside(root, p) && GIT_RUNS.test(`/${path.relative(root, p)}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The parts of a repository that make git run programs: its hooks and its configuration (which
|
|
104
|
+
* can name a hooks folder, a pager, a file system monitor, an ssh command), also of submodules
|
|
105
|
+
* and of repositories further down. What is put there runs with the owner's rights the next
|
|
106
|
+
* time they use git, so a run that has handled outside content cannot change them, nor move
|
|
107
|
+
* the repository's folder aside to put another in its place.
|
|
108
|
+
*/
|
|
109
|
+
const GIT_RUNS = /\/\.git(\/(modules\/.+\/)?(hooks(\/.*)?|config))?$/;
|
|
110
|
+
const GIT_RUNS_PROFILE = String.raw `(.*/)?\.git(/(modules/.+/)?(hooks(/.*)?|config))?$`;
|
|
111
|
+
const q = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
112
|
+
const rx = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
113
|
+
const subpaths = (list) => list.map((p) => `(subpath ${q(p)})`).join(" ");
|
|
114
|
+
/**
|
|
115
|
+
* macOS sandbox profile for everything an agent does on the file system: its shell commands and
|
|
116
|
+
* its file tools. Nothing is allowed unless a rule here allows it; for file rules the last match wins.
|
|
117
|
+
*
|
|
118
|
+
* Allowed: running programs, reading outside the user's home, writing in the agent's own folders
|
|
119
|
+
* and temp. Not allowed: the user's home, pine's home (secrets, configuration, other agents),
|
|
120
|
+
* other processes (signals, their arguments and environment), launching apps, AppleScript,
|
|
121
|
+
* the keychain, the clipboard, and the network unless the agent's configuration turns it on.
|
|
122
|
+
*/
|
|
123
|
+
export function sandboxProfile(_cfg, agent, roots, variant, network = false) {
|
|
124
|
+
const userHome = realish(os.homedir());
|
|
125
|
+
const restricted = variant === true || variant === "restricted";
|
|
126
|
+
if (variant === "open") {
|
|
127
|
+
// Bypass mode. The owner has said: act with my rights. What stays closed is what would let an
|
|
128
|
+
// agent rewrite the system that runs it: pine's home (configuration, secrets) and pine's code.
|
|
129
|
+
return `${[
|
|
130
|
+
"(version 1)",
|
|
131
|
+
"(allow default)",
|
|
132
|
+
`(deny file-read* file-write* (subpath ${q(roots.home)}))`,
|
|
133
|
+
`(allow file-read-metadata (subpath ${q(roots.home)}))`,
|
|
134
|
+
`(allow file-read* ${subpaths(roots.ownedRead.filter((r) => isInside(roots.home, r)))})`,
|
|
135
|
+
`(allow file-write* ${subpaths(roots.ownedWrite.filter((r) => isInside(roots.home, r)))})`,
|
|
136
|
+
`(deny file-read* file-write* ${subpaths(roots.secrets)})`,
|
|
137
|
+
`(deny file-write* (subpath ${q(PINE_CODE)}))`,
|
|
138
|
+
].join("\n")}\n`;
|
|
139
|
+
}
|
|
140
|
+
const lines = [
|
|
141
|
+
"(version 1)",
|
|
142
|
+
"(deny default)",
|
|
143
|
+
"(allow process-exec)",
|
|
144
|
+
"(allow process-fork)",
|
|
145
|
+
"(allow signal (target same-sandbox))",
|
|
146
|
+
"(allow process-info* (target same-sandbox))",
|
|
147
|
+
"(allow sysctl-read)",
|
|
148
|
+
"(allow ipc-posix-shm-read*)",
|
|
149
|
+
"(allow ipc-posix-sem)",
|
|
150
|
+
"(allow pseudo-tty)",
|
|
151
|
+
'(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo") (global-name "com.apple.system.DirectoryService.libinfo.v1") (global-name "com.apple.system.notification_center") (global-name "com.apple.system.logger") (global-name "com.apple.logd") (global-name "com.apple.bsd.dirhelper"))',
|
|
152
|
+
'(allow file-ioctl (regex #"^/dev/tty") (literal "/dev/null"))',
|
|
153
|
+
"(allow file-read*)",
|
|
154
|
+
`(allow file-write* ${subpaths(roots.write)} (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/"))`,
|
|
155
|
+
`(deny file-read* (subpath ${q(userHome)}))`,
|
|
156
|
+
`(allow file-read-metadata (subpath ${q(userHome)}))`,
|
|
157
|
+
`(allow file-read* ${subpaths(roots.read)})`,
|
|
158
|
+
// pine's home is closed wherever it lies, except for this agent's own folders.
|
|
159
|
+
`(deny file-read* file-write* (subpath ${q(roots.home)}))`,
|
|
160
|
+
`(allow file-read-metadata (subpath ${q(roots.home)}))`,
|
|
161
|
+
`(allow file-read* ${subpaths(roots.ownedRead)})`,
|
|
162
|
+
`(allow file-write* ${subpaths(roots.ownedWrite)})`,
|
|
163
|
+
`(deny file-read* file-write* ${subpaths([...roots.secrets, ...roots.others])})`,
|
|
164
|
+
];
|
|
165
|
+
if (restricted) {
|
|
166
|
+
// What is read into the prompt cannot be rewritten by a run that outside content may steer.
|
|
167
|
+
lines.push(`(deny file-write* ${[roots.userFile, ...WORKSPACE_FILES.map((f) => path.join(roots.workspace, f))].map((f) => `(literal ${q(f)})`).join(" ")})`);
|
|
168
|
+
for (const dir of [roots.workspace, ...(roots.folder ? [roots.folder] : [])]) {
|
|
169
|
+
lines.push(`(deny file-write* (regex #"^${rx(dir)}/(.*/)?(${INSTRUCTION_FILES.map(rx).join("|")})$") (regex #"^${rx(dir)}/${GIT_RUNS_PROFILE}") ${subpaths(INSTRUCTION_DIRS.map((d) => path.join(dir, d)))})`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (agent.sandbox.network || network) {
|
|
173
|
+
lines.push("(allow network*)", "(allow system-socket)", '(allow mach-lookup (global-name "com.apple.trustd.agent") (global-name "com.apple.trustd") (global-name "com.apple.SystemConfiguration.configd") (global-name "com.apple.SystemConfiguration.DNSConfiguration") (global-name "com.apple.dnssd.service") (global-name "com.apple.networkd") (global-name "com.apple.symptomsd"))');
|
|
174
|
+
}
|
|
175
|
+
return `${lines.join("\n")}\n`;
|
|
176
|
+
}
|
|
177
|
+
export const sandboxAvailable = () => process.platform === "darwin" && fs.existsSync("/usr/bin/sandbox-exec");
|
|
178
|
+
/** Environment for an agent's shell: nothing from the daemon except what a shell needs. */
|
|
179
|
+
export function shellEnv(agent, roots, sessionEnv) {
|
|
180
|
+
const keep = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TZ", "USER", "LOGNAME", "SHELL"];
|
|
181
|
+
const env = {};
|
|
182
|
+
for (const k of keep)
|
|
183
|
+
if (process.env[k] !== undefined)
|
|
184
|
+
env[k] = process.env[k];
|
|
185
|
+
for (const [k, v] of Object.entries(sessionEnv))
|
|
186
|
+
if (k.startsWith("PI_") && !/KEY|TOKEN|SECRET|AUTH/i.test(k))
|
|
187
|
+
env[k] = v;
|
|
188
|
+
env.HOME = roots.sandboxHome;
|
|
189
|
+
env.TMPDIR = path.join(roots.sandboxHome, "tmp");
|
|
190
|
+
env.GIT_CONFIG_GLOBAL = path.join(roots.sandboxHome, ".gitconfig");
|
|
191
|
+
env.PINE_AGENT = agent.id;
|
|
192
|
+
return { ...env, ...agent.sandbox.env };
|
|
193
|
+
}
|
|
194
|
+
export const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
195
|
+
/** Glob with `*` only, anchored. */
|
|
196
|
+
export function globMatch(pattern, value) {
|
|
197
|
+
return new RegExp(`^${pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[\\s\\S]*")}$`).test(value);
|
|
198
|
+
}
|
|
199
|
+
/** "tool" or "tool:argument-glob". The argument is the command, topic, agent or path the call is about. */
|
|
200
|
+
export function matchesToolPattern(patterns, tool, subject) {
|
|
201
|
+
return patterns.some((pat) => {
|
|
202
|
+
const i = pat.indexOf(":");
|
|
203
|
+
return i < 0 ? globMatch(pat, tool) : globMatch(pat.slice(0, i), tool) && globMatch(pat.slice(i + 1), subject);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** The part of a tool call a person needs to see to judge it. */
|
|
207
|
+
export function toolSubject(tool, input) {
|
|
208
|
+
if (!input || typeof input !== "object")
|
|
209
|
+
return "";
|
|
210
|
+
const v = tool === "download" ? input.url : tool === "send" ? input.source : (input.command ?? input.topic ?? input.agent ?? input.path ?? input.url ?? input.key ?? input.query);
|
|
211
|
+
return typeof v === "string" ? v : "";
|
|
212
|
+
}
|
|
213
|
+
/** Human-in-the-loop gate. Requests are events; anything that can reach the owner may answer them. */
|
|
214
|
+
export class Approvals {
|
|
215
|
+
bus;
|
|
216
|
+
log;
|
|
217
|
+
timeoutMs;
|
|
218
|
+
open = new Map();
|
|
219
|
+
constructor(bus, log, timeoutMs) {
|
|
220
|
+
this.bus = bus;
|
|
221
|
+
this.log = log;
|
|
222
|
+
this.timeoutMs = timeoutMs;
|
|
223
|
+
}
|
|
224
|
+
request(r, signal) {
|
|
225
|
+
const req = { ...r, summary: truncate(r.summary, 1500), id: crypto.randomBytes(4).toString("hex"), expires: Date.now() + this.timeoutMs };
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const finish = (allow, by, always = false, values) => {
|
|
228
|
+
const entry = this.open.get(req.id);
|
|
229
|
+
if (!entry)
|
|
230
|
+
return;
|
|
231
|
+
clearTimeout(entry.timer);
|
|
232
|
+
this.open.delete(req.id);
|
|
233
|
+
const remembered = allow && always && req.remember !== undefined;
|
|
234
|
+
// What the owner typed goes to whoever asked, and nowhere else: not into this event.
|
|
235
|
+
this.bus.publish("approval/decided", { id: req.id, allow, by, agent: req.agent, tool: req.tool, always: remembered || undefined, remember: remembered ? req.remember : undefined }, { source: "approvals" });
|
|
236
|
+
resolve({ allow, by, always: remembered, values: allow ? values : undefined });
|
|
237
|
+
};
|
|
238
|
+
const timer = setTimeout(() => finish(false, "timeout"), this.timeoutMs);
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
this.open.set(req.id, { req, resolve: ({ allow, by, always, values }) => finish(allow, by, always, values), timer });
|
|
241
|
+
signal?.addEventListener("abort", () => finish(false, "aborted"), { once: true });
|
|
242
|
+
this.log.info(`approval ${req.id} requested: ${req.agent} wants ${req.tool} (${req.why})`);
|
|
243
|
+
this.bus.publish("approval/requested", req, { source: "approvals" });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/** The fields of an open request that a yes has to come with and that `values` does not have. */
|
|
247
|
+
missing(id, values) {
|
|
248
|
+
return (this.open.get(id)?.req.inputs ?? []).filter((i) => !String(values?.[i.name] ?? "").trim()).map((i) => i.name);
|
|
249
|
+
}
|
|
250
|
+
/** Returns false when the id is unknown or already answered, or when a yes lacks what the request asks the owner to type. */
|
|
251
|
+
decide(id, allow, by, always = false, values) {
|
|
252
|
+
const entry = this.open.get(id);
|
|
253
|
+
if (!entry)
|
|
254
|
+
return false;
|
|
255
|
+
if (allow && this.missing(id, values).length)
|
|
256
|
+
return false;
|
|
257
|
+
const wanted = new Set((entry.req.inputs ?? []).map((i) => i.name));
|
|
258
|
+
entry.resolve({ allow, by, always, values: values ? Object.fromEntries(Object.entries(values).filter(([k]) => wanted.has(k)).map(([k, v]) => [k, String(v).trim()])) : undefined });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
pending() {
|
|
262
|
+
return [...this.open.values()].map((e) => e.req);
|
|
263
|
+
}
|
|
264
|
+
denyAll(by) {
|
|
265
|
+
for (const id of [...this.open.keys()])
|
|
266
|
+
this.decide(id, false, by);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Replaces known secret values in text that leaves the daemon. */
|
|
270
|
+
export class Redactor {
|
|
271
|
+
secrets = [];
|
|
272
|
+
add(values) {
|
|
273
|
+
for (const v of values)
|
|
274
|
+
if (v && v.length >= 8 && !this.secrets.includes(v))
|
|
275
|
+
this.secrets.push(v);
|
|
276
|
+
this.secrets.sort((a, b) => b.length - a.length);
|
|
277
|
+
}
|
|
278
|
+
text(s) {
|
|
279
|
+
for (const secret of this.secrets)
|
|
280
|
+
if (s.includes(secret))
|
|
281
|
+
s = s.split(secret).join("[redacted]");
|
|
282
|
+
return s;
|
|
283
|
+
}
|
|
284
|
+
deep(v) {
|
|
285
|
+
if (!this.secrets.length)
|
|
286
|
+
return v;
|
|
287
|
+
if (typeof v === "string")
|
|
288
|
+
return this.text(v);
|
|
289
|
+
if (Array.isArray(v))
|
|
290
|
+
return v.map((x) => this.deep(x));
|
|
291
|
+
if (v && typeof v === "object")
|
|
292
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, this.deep(x)]));
|
|
293
|
+
return v;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
export const timingSafeEqual = (a, b) => {
|
|
297
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
298
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
299
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
300
|
+
};
|
|
301
|
+
/** Things about a configuration that weaken its defences. Shown by `pine check` and at startup. */
|
|
302
|
+
export function audit(cfg) {
|
|
303
|
+
const out = [];
|
|
304
|
+
const mode = (p) => {
|
|
305
|
+
try {
|
|
306
|
+
return fs.statSync(p).mode & 0o777;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
for (const f of [".env", "auth.json", "models.json", "pine.json"]) {
|
|
313
|
+
const m = mode(path.join(cfg.home, f));
|
|
314
|
+
if (m !== undefined && m & 0o077)
|
|
315
|
+
out.push(`${f} is readable by other users (mode ${m.toString(8)}); run: chmod 600 ${path.join(cfg.home, f)}`);
|
|
316
|
+
}
|
|
317
|
+
if (!sandboxAvailable())
|
|
318
|
+
out.push("no OS sandbox on this platform: agents with the sandbox on get no shell, and command sources do not run; an agent with \"sandbox\": false runs its shell with the daemon's full permissions");
|
|
319
|
+
const homeMode = mode(cfg.home);
|
|
320
|
+
if (homeMode !== undefined && homeMode & 0o077)
|
|
321
|
+
out.push(`pine's home is open to other users (mode ${homeMode.toString(8)}); run: chmod 700 ${cfg.home}`);
|
|
322
|
+
for (const a of Object.values(cfg.agents)) {
|
|
323
|
+
const tools = a.tools ?? ["read", "bash", "edit", "write"];
|
|
324
|
+
if (!a.sandbox.enabled && tools.includes("bash"))
|
|
325
|
+
out.push(`agent ${a.id}: sandbox is off and bash is enabled; its commands can read and change anything the daemon user can`);
|
|
326
|
+
if (a.sandbox.enabled && a.sandbox.network && tools.includes("bash"))
|
|
327
|
+
out.push(`agent ${a.id}: shell has network access; a prompt injection could send workspace files anywhere (approval is required once the session has seen outside content)`);
|
|
328
|
+
if (a.mode === "bypass")
|
|
329
|
+
out.push(`agent ${a.id}: bypass mode. It never asks, and its shell reaches your whole account and the network${cfg.security.bypassWhenTainted ? ", even after it has read outside content (security.bypassWhenTainted)" : " (it asks again once a session has read outside content)"}`);
|
|
330
|
+
if (a.trustWorkspace)
|
|
331
|
+
out.push(`agent ${a.id}: trustWorkspace is on; code in ${a.workspace}/.pi/extensions runs inside the daemon`);
|
|
332
|
+
if (isInside(realish(a.workspace), realish(cfg.home)))
|
|
333
|
+
out.push(`agent ${a.id}: the workspace contains pine's home directory`);
|
|
334
|
+
if (a.untrusted && a.untrusted.tools.some((t) => ["bash", "write", "edit"].includes(t)))
|
|
335
|
+
out.push(`agent ${a.id}: runs triggered by untrusted events may use ${a.untrusted.tools.filter((t) => ["bash", "write", "edit"].includes(t)).join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
if (cfg.http && !["127.0.0.1", "localhost", "::1"].includes(cfg.http.host))
|
|
338
|
+
out.push(`http listens on ${cfg.http.host}; anyone who can reach it and knows the token controls every agent. Prefer loopback plus a tunnel.`);
|
|
339
|
+
for (const [name, h] of Object.entries(cfg.http?.hooks ?? {}))
|
|
340
|
+
if (!h.secret)
|
|
341
|
+
out.push(`http hook "${name}" has no secret; anyone who can reach the port can publish ${h.topic}`);
|
|
342
|
+
// A source whose events count as the owner's words: fine for a paired Telegram account, a risk for anything others can feed.
|
|
343
|
+
for (const [where, sources] of [["pine.json", cfg.sources], ...Object.values(cfg.agents).map((a) => [`agent ${a.id}`, a.sources])]) {
|
|
344
|
+
for (const [name, s] of Object.entries(sources))
|
|
345
|
+
if (s.trust === "owner" && s.type !== "telegram")
|
|
346
|
+
out.push(`${where}: what the source "${name}" (${s.type ?? "custom"}) reports is treated as coming from you; whoever can feed it gets an agent's full tool set`);
|
|
347
|
+
}
|
|
348
|
+
// A source type is a program inside the daemon, whoever wrote it. So is a module a single source names with "use".
|
|
349
|
+
for (const [name, file] of Object.entries(cfg.sourceTypes))
|
|
350
|
+
out.push(`source type "${name}": ${file} runs inside pine with your rights, outside any sandbox`);
|
|
351
|
+
for (const [where, sources] of [["pine.json", cfg.sources], ...Object.values(cfg.agents).map((a) => [`agent ${a.id}`, a.sources])]) {
|
|
352
|
+
for (const [name, s] of Object.entries(sources)) {
|
|
353
|
+
if (s.use)
|
|
354
|
+
out.push(`${where}: the source "${name}" runs the module ${s.use} inside pine with your rights, outside any sandbox`);
|
|
355
|
+
if (s.type === "webhook" && s.options.open === true)
|
|
356
|
+
out.push(`${where}: the webhook source "${name}" has no secret; anyone who can reach the port can make it report`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const r of cfg.routes)
|
|
360
|
+
if (r.trust === "owner")
|
|
361
|
+
out.push(`route "${r.name}" treats events on ${r.on.join(", ")} as coming from you; whoever can publish there gets the agent's full tool set`);
|
|
362
|
+
return out;
|
|
363
|
+
}
|