pi-session-mcp 0.12.0 → 0.13.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/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +8 -3
- package/.pi-session-mcp-cli/config.js +15 -1
- package/.pi-session-mcp-cli/doctor.js +44 -1
- package/.pi-session-mcp-cli/execution-modes.js +13 -3
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
- package/.pi-session-mcp-cli/session-tools.js +6 -2
- package/.pi-session-mcp-cli/writer-boundary.js +27 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
- package/.pi-session-mcp-cli/writer-limits.js +19 -0
- package/.pi-session-mcp-cli/writer-state.js +5 -2
- package/CHANGELOG.md +96 -0
- package/README.md +2 -1
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +3 -1
- package/dist/client-adapters/mcp-verifier.js +8 -3
- package/dist/config.d.ts +1 -0
- package/dist/config.js +15 -1
- package/dist/confined-tools.d.ts +29 -0
- package/dist/confined-tools.js +118 -0
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +44 -1
- package/dist/execution-modes.d.ts +15 -1
- package/dist/execution-modes.js +13 -3
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/main.js +6 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/sandbox-shell.d.ts +71 -0
- package/dist/sandbox-shell.js +254 -0
- package/dist/sdk-pi-adapter.d.ts +17 -1
- package/dist/sdk-pi-adapter.js +64 -10
- package/dist/session-tools.d.ts +4 -2
- package/dist/session-tools.js +6 -2
- package/dist/setup-cli.js +40 -6
- package/dist/setup-modes.d.ts +3 -0
- package/dist/setup-modes.js +18 -1
- package/dist/setup-writers.js +37 -6
- package/dist/tool-schemas.d.ts +4 -0
- package/dist/tool-schemas.js +1 -0
- package/dist/writer-boundary.d.ts +2 -0
- package/dist/writer-boundary.js +27 -0
- package/dist/writer-lifecycle.d.ts +1 -0
- package/dist/writer-lifecycle.js +6 -4
- package/dist/writer-limits.d.ts +13 -0
- package/dist/writer-limits.js +19 -0
- package/dist/writer-relay.js +1 -1
- package/dist/writer-state.d.ts +11 -0
- package/dist/writer-state.js +5 -2
- package/docs/acceptance.md +266 -0
- package/docs/adr/0013-hardened-read-only-profiles.md +182 -0
- package/docs/doctor-design.md +2 -0
- package/docs/execution-modes.md +59 -3
- package/docs/installation.md +34 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +8 -1
- package/docs/session-mcp.md +13 -0
- package/docs/threat-model.md +50 -0
- package/docs/tool-contracts.md +30 -3
- package/package.json +1 -1
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
package/dist/pi-adapter.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface PiAppliedSelection {
|
|
|
14
14
|
readonly model: string;
|
|
15
15
|
readonly thinkingLevel: ThinkingLevel;
|
|
16
16
|
}
|
|
17
|
-
export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict" | "extension_tool_missing" | "skill_load_failed" | "pi_session_creation_failed";
|
|
17
|
+
export type PiSessionCreationErrorCode = "unknown_provider" | "unknown_model" | "local_authentication_missing" | "local_authentication_unavailable" | "model_unavailable" | "thinking_level_unavailable" | "execution_selection_mismatch" | "external_mcp_unavailable" | "external_mcp_no_tools" | "external_mcp_grant_tool_missing" | "external_mcp_activation_mismatch" | "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict" | "extension_tool_missing" | "skill_load_failed" | "sandbox_unavailable" | "pi_session_creation_failed";
|
|
18
18
|
export declare class PiSessionCreationError extends Error {
|
|
19
19
|
readonly code: PiSessionCreationErrorCode;
|
|
20
20
|
constructor(code: PiSessionCreationErrorCode);
|
package/dist/pi-adapter.js
CHANGED
|
@@ -16,6 +16,7 @@ const PI_SESSION_CREATION_MESSAGES = {
|
|
|
16
16
|
extension_tool_conflict: "Configured Pi extension tools conflict",
|
|
17
17
|
extension_tool_missing: "Granted Pi extension tool is not available",
|
|
18
18
|
skill_load_failed: "Configured Pi skill could not be loaded",
|
|
19
|
+
sandbox_unavailable: "Read-only shell sandbox is not available",
|
|
19
20
|
pi_session_creation_failed: "Pi session creation failed",
|
|
20
21
|
};
|
|
21
22
|
export class PiSessionCreationError extends Error {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BashOperations } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
/** The only bubblewrap binary the shell uses: a fixed path, never a `PATH` lookup. */
|
|
3
|
+
export declare const BWRAP_PATH = "/usr/bin/bwrap";
|
|
4
|
+
export declare const SANDBOX_BASH_PATH = "/usr/bin/bash";
|
|
5
|
+
export declare const PROBE_TIMEOUT_MS = 5000;
|
|
6
|
+
/** Seconds a command may run when the tool call names no `timeout`. */
|
|
7
|
+
export declare const DEFAULT_COMMAND_TIMEOUT_SECONDS = 600;
|
|
8
|
+
/** Size limit of the sandbox's tmpfs `/tmp`, in bytes (512 MiB). */
|
|
9
|
+
export declare const SANDBOX_TMP_BYTES = 536870912;
|
|
10
|
+
export declare const SANDBOX_UNAVAILABLE_MESSAGE = "Read-only shell sandbox is not available";
|
|
11
|
+
export type RootEntry = {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly kind: "symlink";
|
|
14
|
+
readonly target: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly kind: "directory";
|
|
18
|
+
};
|
|
19
|
+
export interface SandboxArgsInput {
|
|
20
|
+
/** Real path of the workspace, mounted at the same path as an overlay with a temporary upper layer. */
|
|
21
|
+
readonly workspace: string;
|
|
22
|
+
/** Real path of a linked worktree's main Git directory, mounted the same way. */
|
|
23
|
+
readonly gitCommonDir?: string | undefined;
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
readonly rootEntries: readonly RootEntry[];
|
|
26
|
+
/** The program and its arguments, run after `--`. */
|
|
27
|
+
readonly argv: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/** The complete, fixed bubblewrap argument list of ADR 0013. Pure: host facts come in as `rootEntries`. */
|
|
30
|
+
export declare function sandboxArgs(input: SandboxArgsInput): string[];
|
|
31
|
+
/** The host's `/bin`, `/lib`, `/lib64` and `/sbin`: a symlink is recreated, a directory bound, an absent entry skipped. */
|
|
32
|
+
export declare function hostRootEntries(): RootEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* The main repository's Git directory of a linked worktree, or `undefined`. Only a `.git` regular file
|
|
35
|
+
* whose `gitdir:` names `<common>/worktrees/<name>` is accepted, and only if that directory points
|
|
36
|
+
* back at this workspace's `.git`, its `commondir` resolves to `<common>`, and `<common>` holds `HEAD`,
|
|
37
|
+
* `objects` and `refs`. Never throws on bad content.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveWorktreeGitDir(workspace: string): Promise<string | undefined>;
|
|
40
|
+
/** The part of a child process the sandbox uses; the default is `node:child_process`. */
|
|
41
|
+
export interface SandboxChild {
|
|
42
|
+
readonly stdout: NodeJS.ReadableStream | null;
|
|
43
|
+
readonly stderr: NodeJS.ReadableStream | null;
|
|
44
|
+
once(event: "error", listener: (error: Error) => void): unknown;
|
|
45
|
+
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
46
|
+
kill(signal?: NodeJS.Signals): unknown;
|
|
47
|
+
}
|
|
48
|
+
export type SandboxSpawn = (file: string, args: readonly string[], options: {
|
|
49
|
+
env: Record<string, never>;
|
|
50
|
+
stdio: ["ignore", "pipe", "pipe"];
|
|
51
|
+
}) => SandboxChild;
|
|
52
|
+
export interface SandboxDependencies {
|
|
53
|
+
readonly spawn?: SandboxSpawn;
|
|
54
|
+
/** Whether the fixed bubblewrap binary may be used; defaults to a trusted-system-binary check. */
|
|
55
|
+
readonly trusted?: () => boolean;
|
|
56
|
+
readonly rootEntries?: () => readonly RootEntry[];
|
|
57
|
+
readonly gitCommonDir?: (workspace: string) => Promise<string | undefined>;
|
|
58
|
+
}
|
|
59
|
+
/** Whether the fixed bubblewrap binary is a trusted system binary. */
|
|
60
|
+
export declare function sandboxBinaryTrusted(dependencies?: SandboxDependencies): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Runs `/usr/bin/true` through the same sandbox as a command, bounded by `PROBE_TIMEOUT_MS`.
|
|
63
|
+
* False for an untrusted binary, a spawn failure, a non-zero exit or a timeout.
|
|
64
|
+
*/
|
|
65
|
+
export declare function probeSandbox(workspace: string, dependencies?: SandboxDependencies, signal?: AbortSignal): Promise<boolean>;
|
|
66
|
+
/**
|
|
67
|
+
* `BashOperations` that run each command as `bwrap <fixed arguments> -- bash -c <command>`. The passed
|
|
68
|
+
* `env` is ignored, a working directory outside the workspace is refused and there is no unsandboxed
|
|
69
|
+
* fallback: a sandbox that cannot start fails the command.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createSandboxedBashOperations(workspace: string, dependencies?: SandboxDependencies): BashOperations;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants as osConstants } from "node:os";
|
|
3
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
4
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
|
+
import { isTrustedSystemBinary } from "./setup-platform.js";
|
|
7
|
+
/** The only bubblewrap binary the shell uses: a fixed path, never a `PATH` lookup. */
|
|
8
|
+
export const BWRAP_PATH = "/usr/bin/bwrap";
|
|
9
|
+
export const SANDBOX_BASH_PATH = "/usr/bin/bash";
|
|
10
|
+
const SANDBOX_TRUE_PATH = "/usr/bin/true";
|
|
11
|
+
const SANDBOX_PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
12
|
+
const SANDBOX_HOME = "/tmp/home";
|
|
13
|
+
/** Directories of a merged-`/usr` host that the sandbox recreates as symlinks or binds read-only. */
|
|
14
|
+
const ROOT_ENTRIES = ["/bin", "/lib", "/lib64", "/sbin"];
|
|
15
|
+
export const PROBE_TIMEOUT_MS = 5_000;
|
|
16
|
+
/** Seconds a command may run when the tool call names no `timeout`. */
|
|
17
|
+
export const DEFAULT_COMMAND_TIMEOUT_SECONDS = 600;
|
|
18
|
+
/** Size limit of the sandbox's tmpfs `/tmp`, in bytes (512 MiB). */
|
|
19
|
+
export const SANDBOX_TMP_BYTES = 536_870_912;
|
|
20
|
+
export const SANDBOX_UNAVAILABLE_MESSAGE = "Read-only shell sandbox is not available";
|
|
21
|
+
const GITFILE_LIMIT_BYTES = 4096;
|
|
22
|
+
/** The complete, fixed bubblewrap argument list of ADR 0013. Pure: host facts come in as `rootEntries`. */
|
|
23
|
+
export function sandboxArgs(input) {
|
|
24
|
+
const args = [
|
|
25
|
+
"--unshare-all", "--unshare-user", "--disable-userns", "--die-with-parent", "--new-session", "--clearenv",
|
|
26
|
+
"--setenv", "PATH", SANDBOX_PATH, "--setenv", "HOME", SANDBOX_HOME,
|
|
27
|
+
"--ro-bind", "/usr", "/usr",
|
|
28
|
+
];
|
|
29
|
+
for (const entry of input.rootEntries) {
|
|
30
|
+
if (entry.kind === "symlink")
|
|
31
|
+
args.push("--symlink", entry.target, entry.path);
|
|
32
|
+
else
|
|
33
|
+
args.push("--ro-bind", entry.path, entry.path);
|
|
34
|
+
}
|
|
35
|
+
args.push("--ro-bind", "/etc", "/etc", "--dev", "/dev", "--proc", "/proc", "--size", String(SANDBOX_TMP_BYTES), "--tmpfs", "/tmp", "--dir", SANDBOX_HOME, "--overlay-src", input.workspace, "--tmp-overlay", input.workspace);
|
|
36
|
+
if (input.gitCommonDir !== undefined)
|
|
37
|
+
args.push("--overlay-src", input.gitCommonDir, "--tmp-overlay", input.gitCommonDir);
|
|
38
|
+
args.push("--chdir", input.cwd, "--", ...input.argv);
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
/** The host's `/bin`, `/lib`, `/lib64` and `/sbin`: a symlink is recreated, a directory bound, an absent entry skipped. */
|
|
42
|
+
export function hostRootEntries() {
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const path of ROOT_ENTRIES) {
|
|
45
|
+
try {
|
|
46
|
+
const info = lstatSync(path);
|
|
47
|
+
if (info.isSymbolicLink())
|
|
48
|
+
entries.push({ path, kind: "symlink", target: readlinkSync(path) });
|
|
49
|
+
else if (info.isDirectory())
|
|
50
|
+
entries.push({ path, kind: "directory" });
|
|
51
|
+
}
|
|
52
|
+
catch { /* absent */ }
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
const within = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
57
|
+
async function readSmall(path) {
|
|
58
|
+
try {
|
|
59
|
+
const info = await lstat(path);
|
|
60
|
+
if (!info.isFile() || info.size > GITFILE_LIMIT_BYTES)
|
|
61
|
+
return undefined;
|
|
62
|
+
return (await readFile(path, "utf8")).trim();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const real = async (path) => { try {
|
|
69
|
+
return await realpath(path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
} };
|
|
74
|
+
const isDirectory = async (path) => { try {
|
|
75
|
+
return (await stat(path)).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
} };
|
|
80
|
+
const isFile = async (path) => { try {
|
|
81
|
+
return (await stat(path)).isFile();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
} };
|
|
86
|
+
/**
|
|
87
|
+
* The main repository's Git directory of a linked worktree, or `undefined`. Only a `.git` regular file
|
|
88
|
+
* whose `gitdir:` names `<common>/worktrees/<name>` is accepted, and only if that directory points
|
|
89
|
+
* back at this workspace's `.git`, its `commondir` resolves to `<common>`, and `<common>` holds `HEAD`,
|
|
90
|
+
* `objects` and `refs`. Never throws on bad content.
|
|
91
|
+
*/
|
|
92
|
+
export async function resolveWorktreeGitDir(workspace) {
|
|
93
|
+
try {
|
|
94
|
+
const gitFile = join(workspace, ".git");
|
|
95
|
+
const content = await readSmall(gitFile);
|
|
96
|
+
const match = content === undefined ? null : /^gitdir: (.+)$/.exec(content);
|
|
97
|
+
if (!match)
|
|
98
|
+
return undefined;
|
|
99
|
+
const gitdir = await real(resolve(workspace, match[1]));
|
|
100
|
+
if (gitdir === undefined || !(await isDirectory(gitdir)))
|
|
101
|
+
return undefined;
|
|
102
|
+
const worktrees = dirname(gitdir);
|
|
103
|
+
if (basename(worktrees) !== "worktrees")
|
|
104
|
+
return undefined;
|
|
105
|
+
const common = dirname(worktrees);
|
|
106
|
+
const back = await readSmall(join(gitdir, "gitdir"));
|
|
107
|
+
if (back === undefined || (await real(resolve(gitdir, back))) !== (await real(gitFile)))
|
|
108
|
+
return undefined;
|
|
109
|
+
const commondir = await readSmall(join(gitdir, "commondir"));
|
|
110
|
+
if (commondir === undefined || (await real(resolve(gitdir, commondir))) !== common)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (!(await isFile(join(common, "HEAD"))) || !(await isDirectory(join(common, "objects"))) || !(await isDirectory(join(common, "refs"))))
|
|
113
|
+
return undefined;
|
|
114
|
+
return common;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const defaultSpawn = (file, args, options) => spawn(file, [...args], options);
|
|
121
|
+
const defaultTrusted = () => isTrustedSystemBinary(BWRAP_PATH);
|
|
122
|
+
/** Whether the fixed bubblewrap binary is a trusted system binary. */
|
|
123
|
+
export function sandboxBinaryTrusted(dependencies = {}) {
|
|
124
|
+
try {
|
|
125
|
+
return (dependencies.trusted ?? defaultTrusted)();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The complete argument list for one run; refuses when the fixed binary is not trusted. */
|
|
132
|
+
async function prepare(workspace, cwd, argv, dependencies) {
|
|
133
|
+
if (!sandboxBinaryTrusted(dependencies))
|
|
134
|
+
throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
|
|
135
|
+
const gitCommonDir = await (dependencies.gitCommonDir ?? resolveWorktreeGitDir)(workspace);
|
|
136
|
+
return sandboxArgs({ workspace, gitCommonDir, cwd, rootEntries: (dependencies.rootEntries ?? hostRootEntries)(), argv });
|
|
137
|
+
}
|
|
138
|
+
/** Spawns bubblewrap and attaches the termination listeners in the same tick, so no event is missed. */
|
|
139
|
+
function start(args, dependencies) {
|
|
140
|
+
// An empty environment: neither the server's nor the tool's variables reach the sandbox.
|
|
141
|
+
const child = (dependencies.spawn ?? defaultSpawn)(BWRAP_PATH, args, { env: {}, stdio: ["ignore", "pipe", "pipe"] });
|
|
142
|
+
const closed = new Promise((resolvePromise, reject) => {
|
|
143
|
+
child.once("error", () => reject(new Error(SANDBOX_UNAVAILABLE_MESSAGE)));
|
|
144
|
+
child.once("close", (code, signal) => resolvePromise({ code, signal }));
|
|
145
|
+
});
|
|
146
|
+
closed.catch(() => undefined);
|
|
147
|
+
return { child, closed };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Runs `/usr/bin/true` through the same sandbox as a command, bounded by `PROBE_TIMEOUT_MS`.
|
|
151
|
+
* False for an untrusted binary, a spawn failure, a non-zero exit or a timeout.
|
|
152
|
+
*/
|
|
153
|
+
export async function probeSandbox(workspace, dependencies = {}, signal) {
|
|
154
|
+
let running;
|
|
155
|
+
let timer;
|
|
156
|
+
let onAbort;
|
|
157
|
+
try {
|
|
158
|
+
if (signal?.aborted)
|
|
159
|
+
return false;
|
|
160
|
+
const workspaceReal = await realpath(workspace);
|
|
161
|
+
running = start(await prepare(workspaceReal, workspaceReal, [SANDBOX_TRUE_PATH], dependencies), dependencies);
|
|
162
|
+
const { child, closed } = running;
|
|
163
|
+
child.stdout?.resume();
|
|
164
|
+
child.stderr?.resume();
|
|
165
|
+
const deadline = new Promise((resolvePromise) => {
|
|
166
|
+
timer = setTimeout(() => resolvePromise(null), PROBE_TIMEOUT_MS);
|
|
167
|
+
onAbort = () => resolvePromise(null);
|
|
168
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
169
|
+
if (signal?.aborted)
|
|
170
|
+
resolvePromise(null);
|
|
171
|
+
});
|
|
172
|
+
const outcome = await Promise.race([closed, deadline]);
|
|
173
|
+
if (outcome === null)
|
|
174
|
+
child.kill("SIGKILL");
|
|
175
|
+
return outcome !== null && outcome.code === 0 && signal?.aborted !== true;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
try {
|
|
179
|
+
running?.child.kill("SIGKILL");
|
|
180
|
+
}
|
|
181
|
+
catch { /* already gone */ }
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
if (timer)
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
if (onAbort)
|
|
188
|
+
signal?.removeEventListener("abort", onAbort);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* `BashOperations` that run each command as `bwrap <fixed arguments> -- bash -c <command>`. The passed
|
|
193
|
+
* `env` is ignored, a working directory outside the workspace is refused and there is no unsandboxed
|
|
194
|
+
* fallback: a sandbox that cannot start fails the command.
|
|
195
|
+
*/
|
|
196
|
+
export function createSandboxedBashOperations(workspace, dependencies = {}) {
|
|
197
|
+
return {
|
|
198
|
+
exec: async (command, cwd, { onData, signal, timeout }) => {
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
throw new Error("aborted");
|
|
201
|
+
let workspaceReal;
|
|
202
|
+
let cwdReal;
|
|
203
|
+
try {
|
|
204
|
+
workspaceReal = await realpath(workspace);
|
|
205
|
+
cwdReal = await realpath(isAbsolute(cwd) ? cwd : resolve(workspaceReal, cwd));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error("Working directory is not available");
|
|
209
|
+
}
|
|
210
|
+
if (!within(cwdReal, workspaceReal))
|
|
211
|
+
throw new Error("Working directory is outside the workspace");
|
|
212
|
+
let timeoutMs = DEFAULT_COMMAND_TIMEOUT_SECONDS * 1000;
|
|
213
|
+
if (timeout !== undefined) {
|
|
214
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
215
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
216
|
+
timeoutMs = Math.min(timeout * 1000, 2_147_483_647);
|
|
217
|
+
}
|
|
218
|
+
let running;
|
|
219
|
+
try {
|
|
220
|
+
running = start(await prepare(workspaceReal, cwdReal, [SANDBOX_BASH_PATH, "-c", command], dependencies), dependencies);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw new Error(SANDBOX_UNAVAILABLE_MESSAGE);
|
|
224
|
+
}
|
|
225
|
+
const { child, closed } = running;
|
|
226
|
+
child.stdout?.on("data", onData);
|
|
227
|
+
child.stderr?.on("data", onData);
|
|
228
|
+
let timedOut = false;
|
|
229
|
+
let timer;
|
|
230
|
+
const kill = () => { try {
|
|
231
|
+
child.kill("SIGKILL");
|
|
232
|
+
}
|
|
233
|
+
catch { /* already gone */ } };
|
|
234
|
+
const onAbort = () => kill();
|
|
235
|
+
try {
|
|
236
|
+
timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
237
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
238
|
+
if (signal?.aborted)
|
|
239
|
+
kill();
|
|
240
|
+
const outcome = await closed;
|
|
241
|
+
if (signal?.aborted)
|
|
242
|
+
throw new Error("aborted");
|
|
243
|
+
if (timedOut)
|
|
244
|
+
throw new Error(`timeout:${timeout ?? DEFAULT_COMMAND_TIMEOUT_SECONDS}`);
|
|
245
|
+
return { exitCode: outcome.code ?? (outcome.signal ? 128 + (osConstants.signals[outcome.signal] ?? 0) : 1) };
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (timer)
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
signal?.removeEventListener("abort", onAbort);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
package/dist/sdk-pi-adapter.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
|
|
|
3
3
|
import type { ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
|
|
4
4
|
import { type McpConnectionFactory } from "./mcp-session-tools.js";
|
|
5
5
|
import { type PiSessionAdapter, type PiSessionCreationErrorCode, type PiSessionHandle, type PiTurnOutcome } from "./pi-adapter.js";
|
|
6
|
+
import { type SandboxDependencies } from "./sandbox-shell.js";
|
|
6
7
|
export { PROFILE_TOOLS } from "./session-tools.js";
|
|
7
8
|
type CreateSession = (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
8
9
|
export declare const AUTH_OPERATION_TIMEOUT_MS = 5000;
|
|
@@ -11,7 +12,7 @@ export declare const SESSION_CREATION_TIMEOUT_MS = 10000;
|
|
|
11
12
|
export interface SdkPiAdapterDiagnostic {
|
|
12
13
|
readonly level: "error";
|
|
13
14
|
readonly event: "pi_session_creation_failed";
|
|
14
|
-
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load";
|
|
15
|
+
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load" | "sandbox";
|
|
15
16
|
readonly code: PiSessionCreationErrorCode;
|
|
16
17
|
readonly executionProfile: string;
|
|
17
18
|
readonly provider: string;
|
|
@@ -39,6 +40,13 @@ export interface SdkPiSessionAdapterOptions {
|
|
|
39
40
|
/** Resolves a pinned extension to its installed package directory. */
|
|
40
41
|
readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
|
|
41
42
|
readonly extensionSdk?: ExtensionSdk;
|
|
43
|
+
/** Host seams of the read-only shell sandbox; tests inject a fake runner. */
|
|
44
|
+
readonly sandbox?: SandboxDependencies;
|
|
45
|
+
/**
|
|
46
|
+
* Directories a skill directory must not expose through the read tools, in addition to the
|
|
47
|
+
* operator's home and the Pi agent directory (`~/.pi`). The server passes the configuration directory.
|
|
48
|
+
*/
|
|
49
|
+
readonly protectedReadPaths?: readonly string[];
|
|
42
50
|
}
|
|
43
51
|
type SdkAssistantMessage = Extract<Extract<AgentSessionEvent, {
|
|
44
52
|
type: "message_end";
|
|
@@ -78,6 +86,8 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
|
|
|
78
86
|
private readonly mcpConnectionFactory;
|
|
79
87
|
private readonly resolveExtension;
|
|
80
88
|
private readonly extensionSdk;
|
|
89
|
+
private readonly sandbox;
|
|
90
|
+
private readonly protectedReadPaths;
|
|
81
91
|
constructor(options?: SdkPiSessionAdapterOptions);
|
|
82
92
|
create(input: {
|
|
83
93
|
cwd: string;
|
|
@@ -85,6 +95,12 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
|
|
|
85
95
|
signal?: AbortSignal;
|
|
86
96
|
}): Promise<PiSessionHandle>;
|
|
87
97
|
private createWithExtensions;
|
|
98
|
+
/**
|
|
99
|
+
* The server's own tool definitions of a read-only session (ADR 0013); none for coding. The workspace
|
|
100
|
+
* root is the session directory: a root that cannot be resolved fails the start. With a sandboxed shell
|
|
101
|
+
* the sandbox is probed first, and a profile whose sandbox cannot start never gets a session.
|
|
102
|
+
*/
|
|
103
|
+
private confinedTools;
|
|
88
104
|
private loadSkills;
|
|
89
105
|
private checkResources;
|
|
90
106
|
/** Provider, model, authentication and availability, exactly as configured. */
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { homedir, userInfo } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
|
|
2
5
|
import { PiExtensionError } from "./extension-spec.js";
|
|
3
6
|
import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
|
|
4
7
|
import { createStdioMcpConnection } from "./mcp-stdio-client.js";
|
|
5
8
|
import { PiSessionCreationError, } from "./pi-adapter.js";
|
|
6
9
|
import { resolvePiExtension } from "./pi-extensions.js";
|
|
7
10
|
import { loadSelectedSkills, PiSkillError } from "./pi-skills.js";
|
|
8
|
-
import {
|
|
11
|
+
import { createSandboxedBashOperations, probeSandbox } from "./sandbox-shell.js";
|
|
12
|
+
import { composeSessionTools, profileTools } from "./session-tools.js";
|
|
9
13
|
export { PROFILE_TOOLS } from "./session-tools.js";
|
|
10
14
|
export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
|
|
11
15
|
/** Server-side bound for SDK session construction. The SDK does not document cancellation. */
|
|
@@ -326,6 +330,8 @@ export class SdkPiSessionAdapter {
|
|
|
326
330
|
mcpConnectionFactory;
|
|
327
331
|
resolveExtension;
|
|
328
332
|
extensionSdk;
|
|
333
|
+
sandbox;
|
|
334
|
+
protectedReadPaths;
|
|
329
335
|
constructor(options = {}) {
|
|
330
336
|
this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
|
|
331
337
|
this.createSession = options.createSession ?? createAgentSession;
|
|
@@ -333,6 +339,15 @@ export class SdkPiSessionAdapter {
|
|
|
333
339
|
this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
|
|
334
340
|
this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
|
|
335
341
|
this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
|
|
342
|
+
this.sandbox = options.sandbox ?? {};
|
|
343
|
+
// The account database's home, not `$HOME`, which a caller's environment could point elsewhere.
|
|
344
|
+
const home = (() => { try {
|
|
345
|
+
return userInfo().homedir;
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return homedir();
|
|
349
|
+
} })();
|
|
350
|
+
this.protectedReadPaths = [home, join(home, ".pi"), ...(options.protectedReadPaths ?? [])];
|
|
336
351
|
}
|
|
337
352
|
async create(input) {
|
|
338
353
|
const extensions = input.executionProfile.extensions ?? [];
|
|
@@ -356,8 +371,10 @@ export class SdkPiSessionAdapter {
|
|
|
356
371
|
catch {
|
|
357
372
|
throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
|
|
358
373
|
}
|
|
374
|
+
const confined = await this.confinedTools(input, skills);
|
|
359
375
|
const mcpTools = await this.connectMcpTools(input);
|
|
360
|
-
const tools = [...
|
|
376
|
+
const tools = [...profileTools(executionProfile.permissionProfile, executionProfile.shell), ...mcpTools.tools.map((tool) => tool.name)];
|
|
377
|
+
const customTools = [...confined, ...mcpTools.tools];
|
|
361
378
|
let session;
|
|
362
379
|
try {
|
|
363
380
|
({ session } = await withCreationDeadline(() => this.createSession({
|
|
@@ -370,14 +387,14 @@ export class SdkPiSessionAdapter {
|
|
|
370
387
|
resourceLoader,
|
|
371
388
|
// With an explicit tools list, Pi also needs custom tool names here to activate them.
|
|
372
389
|
tools,
|
|
373
|
-
...(
|
|
390
|
+
...(customTools.length === 0 ? {} : { customTools }),
|
|
374
391
|
}), (late) => disposeSdkSession(late.session)));
|
|
375
392
|
}
|
|
376
393
|
catch {
|
|
377
394
|
await mcpTools.close();
|
|
378
395
|
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
379
396
|
}
|
|
380
|
-
return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
|
|
397
|
+
return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills, [], confined);
|
|
381
398
|
}
|
|
382
399
|
async createWithExtensions(input, extensions) {
|
|
383
400
|
const { cwd, executionProfile } = input;
|
|
@@ -423,9 +440,11 @@ export class SdkPiSessionAdapter {
|
|
|
423
440
|
throw this.failure(executionProfile, "extension_load", "extension_load_failed");
|
|
424
441
|
}
|
|
425
442
|
const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
|
|
443
|
+
const confined = await this.confinedTools(input, skills);
|
|
426
444
|
const mcpTools = await this.connectMcpTools(input);
|
|
445
|
+
const customTools = [...confined, ...mcpTools.tools];
|
|
427
446
|
const grantedTools = executionProfile.grantedExtensionTools ?? [];
|
|
428
|
-
const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools);
|
|
447
|
+
const composed = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools, grantedTools, executionProfile.shell);
|
|
429
448
|
if (!composed.ok) {
|
|
430
449
|
await mcpTools.close();
|
|
431
450
|
throw this.failure(executionProfile, "extension_load", composed.code);
|
|
@@ -440,7 +459,7 @@ export class SdkPiSessionAdapter {
|
|
|
440
459
|
model,
|
|
441
460
|
thinkingLevel: executionProfile.thinkingLevel,
|
|
442
461
|
tools,
|
|
443
|
-
...(
|
|
462
|
+
...(customTools.length === 0 ? {} : { customTools }),
|
|
444
463
|
})),
|
|
445
464
|
services,
|
|
446
465
|
diagnostics: services.diagnostics,
|
|
@@ -474,7 +493,7 @@ export class SdkPiSessionAdapter {
|
|
|
474
493
|
// A `session_start` handler may register a tool that replaces a built-in or granted tool.
|
|
475
494
|
// Tools an extension registers later, during a turn, are not re-checked: allowlisted
|
|
476
495
|
// extensions are trusted code.
|
|
477
|
-
const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools);
|
|
496
|
+
const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services), grantedTools, executionProfile.shell);
|
|
478
497
|
if (!recheck.ok)
|
|
479
498
|
throw this.failure(executionProfile, "extension_bind", recheck.code);
|
|
480
499
|
if (recheck.tools.length !== tools.length || recheck.tools.some((name, index) => name !== tools[index]))
|
|
@@ -490,7 +509,35 @@ export class SdkPiSessionAdapter {
|
|
|
490
509
|
await cleanUp();
|
|
491
510
|
throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
|
|
492
511
|
}
|
|
493
|
-
return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools);
|
|
512
|
+
return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills, grantedTools, confined);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The server's own tool definitions of a read-only session (ADR 0013); none for coding. The workspace
|
|
516
|
+
* root is the session directory: a root that cannot be resolved fails the start. With a sandboxed shell
|
|
517
|
+
* the sandbox is probed first, and a profile whose sandbox cannot start never gets a session.
|
|
518
|
+
*/
|
|
519
|
+
async confinedTools(input, skills) {
|
|
520
|
+
const { executionProfile } = input;
|
|
521
|
+
if (executionProfile.permissionProfile !== "read-only")
|
|
522
|
+
return [];
|
|
523
|
+
let roots;
|
|
524
|
+
try {
|
|
525
|
+
roots = await allowedReadRoots(input.cwd, skills, this.protectedReadPaths);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
529
|
+
}
|
|
530
|
+
const tools = createConfinedReadOnlyTools(input.cwd, roots);
|
|
531
|
+
if (executionProfile.shell === "sandboxed") {
|
|
532
|
+
// The first root is the real path of the workspace.
|
|
533
|
+
if (!(await probeSandbox(roots[0], this.sandbox, input.signal)))
|
|
534
|
+
throw this.failure(executionProfile, "sandbox", "sandbox_unavailable");
|
|
535
|
+
tools.push(createBashToolDefinition(input.cwd, {
|
|
536
|
+
operations: createSandboxedBashOperations(roots[0], this.sandbox),
|
|
537
|
+
exposeSessionEnvironment: false,
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
return tools;
|
|
494
541
|
}
|
|
495
542
|
async loadSkills(input) {
|
|
496
543
|
try {
|
|
@@ -581,9 +628,16 @@ export class SdkPiSessionAdapter {
|
|
|
581
628
|
/** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
|
|
582
629
|
async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
|
|
583
630
|
// `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
|
|
584
|
-
toolCheck = "exact", skills = [], requiredTools = []
|
|
631
|
+
toolCheck = "exact", skills = [], requiredTools = [],
|
|
632
|
+
// The server's own definitions must be the ones the session resolves for their names.
|
|
633
|
+
ownDefinitions = []) {
|
|
585
634
|
try {
|
|
586
635
|
this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
|
|
636
|
+
for (const definition of ownDefinitions) {
|
|
637
|
+
if (session.getToolDefinition(definition.name) !== definition) {
|
|
638
|
+
throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
587
641
|
if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
|
|
588
642
|
throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
|
|
589
643
|
}
|
package/dist/session-tools.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { PermissionProfile } from "./execution-profile.js";
|
|
1
|
+
import type { PermissionProfile, ShellMode } from "./execution-profile.js";
|
|
2
2
|
export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>>;
|
|
3
|
+
/** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
|
|
4
|
+
export declare function profileTools(permissionProfile: PermissionProfile, shell?: ShellMode): readonly string[];
|
|
3
5
|
export declare const MAX_GRANTED_EXTENSION_TOOLS = 32;
|
|
4
6
|
export declare function isAcceptableExtensionToolName(name: string): boolean;
|
|
5
7
|
export type ComposedSessionTools = {
|
|
@@ -15,4 +17,4 @@ export type ComposedSessionTools = {
|
|
|
15
17
|
* in both profiles so an extension can never shadow a built-in or granted MCP tool.
|
|
16
18
|
* Unsafe names and names the SDK treats as MCP tools also fail closed.
|
|
17
19
|
*/
|
|
18
|
-
export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[]): ComposedSessionTools;
|
|
20
|
+
export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[], grantedExtensionTools?: readonly string[], shell?: ShellMode): ComposedSessionTools;
|
package/dist/session-tools.js
CHANGED
|
@@ -2,6 +2,10 @@ export const PROFILE_TOOLS = {
|
|
|
2
2
|
"read-only": ["read", "grep", "find", "ls"],
|
|
3
3
|
coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
|
4
4
|
};
|
|
5
|
+
/** The built-in tools of a profile: a read-only profile with a sandboxed shell also has `bash`. */
|
|
6
|
+
export function profileTools(permissionProfile, shell) {
|
|
7
|
+
return permissionProfile === "read-only" && shell === "sandboxed" ? [...PROFILE_TOOLS["read-only"], "bash"] : PROFILE_TOOLS[permissionProfile];
|
|
8
|
+
}
|
|
5
9
|
export const MAX_GRANTED_EXTENSION_TOOLS = 32;
|
|
6
10
|
/**
|
|
7
11
|
* Letters, digits, `_` and `-`, at most 64 characters, which fits the common provider
|
|
@@ -27,7 +31,7 @@ export function isAcceptableExtensionToolName(name) {
|
|
|
27
31
|
* in both profiles so an extension can never shadow a built-in or granted MCP tool.
|
|
28
32
|
* Unsafe names and names the SDK treats as MCP tools also fail closed.
|
|
29
33
|
*/
|
|
30
|
-
export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = []) {
|
|
34
|
+
export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames, grantedExtensionTools = [], shell) {
|
|
31
35
|
const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
|
|
32
36
|
const extensionTools = [];
|
|
33
37
|
for (const names of extensionToolNames) {
|
|
@@ -41,5 +45,5 @@ export function composeSessionTools(permissionProfile, mcpToolNames, extensionTo
|
|
|
41
45
|
if (permissionProfile === "read-only" && grantedExtensionTools.some((name) => !extensionTools.includes(name))) {
|
|
42
46
|
return { ok: false, code: "extension_tool_missing" };
|
|
43
47
|
}
|
|
44
|
-
return { ok: true, tools: [...
|
|
48
|
+
return { ok: true, tools: [...profileTools(permissionProfile, shell), ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : grantedExtensionTools)] };
|
|
45
49
|
}
|