mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The runner package now has a Codex-only native backend > - A native Codex process needs an isolated runtime context before it can start safely > - Assigned skills, local authentication, and MCP bindings cross separate trust boundaries > - Runtime materialization must reject symlink escapes and unsafe remote bindings > - This pull request adds the package-local Codex runtime context boundary > - It does not start runnerd or enable the Paperclip Runner adapter ## Linked Issues or Issue Description **Subsystem affected** `packages/paperclip-runner` Codex runtime context materialization. **Problem or motivation** The runner needs a private Codex home for each native session. It must stage only assigned skills, copy local Codex authentication safely, and validate native MCP bindings before it exposes them to the child process. **Proposed solution** Create an isolated runtime directory. Validate the skill tree before and after copying it. Make staged skill files read-only. Read authentication through a no-follow file descriptor with a size bound. Accept only HTTPS or loopback MCP endpoints and bounded tokens. **Alternatives considered** Using the operator Codex home directly would expose unrelated state and skills. Following symlinks while copying skills or authentication could escape the assigned source. Accepting arbitrary MCP URLs could send a bearer token to an untrusted endpoint. **Roadmap alignment** This adds a package-local safety boundary for the reviewed Codex runner path. It does not enable a new adapter or change an existing direct adapter path. ## What Changed - Added the native MCP binding contract and strict validation. - Added isolated Codex home materialization with shell snapshots disabled. - Added assigned-skill staging with lexical containment and two-pass symlink checks. - Added read-only permissions for staged skill trees. - Added owner-only authentication staging with no-follow reads and a size bound. - Added cleanup for complete and partially materialized runtime directories. ## Verification - The focused runtime context suite has 7 passing cases. - `pnpm --filter @paperclipai/paperclip-runner test:typescript` (36 files, 351 tests) - `pnpm -r typecheck` - `pnpm build` ## Risks The main risks are filesystem escape, secret exposure, and token delivery to an unsafe endpoint. The materializer rejects symlinks before and after skill copying, resolves existing source paths, reads authentication with `O_NOFOLLOW`, applies private permissions, and restricts MCP URLs to HTTPS or loopback hosts. Existing direct adapters do not use this package-local runtime context. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
294 lines
9.7 KiB
TypeScript
294 lines
9.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
chmod,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
|
import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js";
|
|
import type { NativeMcpLaunchBinding } from "./native-mcp.js";
|
|
|
|
async function assertSafeTree(root: string, child = ""): Promise<void> {
|
|
const directory = child ? join(root, child) : root;
|
|
const directoryStat = await lstat(directory);
|
|
if (directoryStat.isSymbolicLink()) {
|
|
throw new Error(
|
|
`runtime context asset contains a symlink: ${child || "."}`,
|
|
);
|
|
}
|
|
if (!directoryStat.isDirectory()) {
|
|
throw new Error("runtime context asset root must be a directory");
|
|
}
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const childRelative = child ? `${child}/${entry.name}` : entry.name;
|
|
const stat = await lstat(join(root, childRelative));
|
|
if (stat.isSymbolicLink()) {
|
|
throw new Error(
|
|
`runtime context asset contains a symlink: ${childRelative}`,
|
|
);
|
|
}
|
|
if (stat.isDirectory()) await assertSafeTree(root, childRelative);
|
|
else if (!stat.isFile()) {
|
|
throw new Error(
|
|
`runtime context asset contains an unsupported file: ${childRelative}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function safeMaterializationTarget(root: string, runtimeName: string): string {
|
|
const segments = runtimeName.split("/");
|
|
if (
|
|
runtimeName.startsWith("/")
|
|
|| runtimeName.includes("\\")
|
|
|| runtimeName.includes("\0")
|
|
|| segments.some((segment) => /[<>:"|?*\u0000-\u001f\u007f]/u.test(segment))
|
|
|| segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
|| segments.some((segment) => !segment.replace(/[ .]+$/u, ""))
|
|
|| segments.some(isWin32ReservedPathSegment)
|
|
) {
|
|
throw new Error("runtime context skill name must be a safe relative path");
|
|
}
|
|
const target = resolve(root, runtimeName);
|
|
const relation = relative(resolve(root), target);
|
|
if (
|
|
relation === ""
|
|
|| relation === ".."
|
|
|| relation.startsWith(`..${sep}`)
|
|
|| isAbsolute(relation)
|
|
) {
|
|
throw new Error("runtime context skill name must stay inside the skills home");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function isWin32ReservedPathSegment(segment: string): boolean {
|
|
const basename = segment
|
|
.normalize("NFC")
|
|
.split(".", 1)[0]!
|
|
.replace(/[ .]+$/u, "")
|
|
.toUpperCase();
|
|
return /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(
|
|
basename,
|
|
);
|
|
}
|
|
|
|
function portableRuntimeNameKey(runtimeName: string): string {
|
|
// Skill assignments must remain unambiguous when the same context is moved
|
|
// between the case-sensitive Linux runner and the case-insensitive default
|
|
// filesystems on macOS or Windows. NFC also catches composed/decomposed
|
|
// aliases, while per-segment trimming matches Win32's treatment of trailing
|
|
// dots and spaces before either spelling reaches the staging tree.
|
|
return runtimeName
|
|
.normalize("NFC")
|
|
.split("/")
|
|
.map((segment) => segment.replace(/[ .]+$/u, ""))
|
|
.join("/")
|
|
.toLowerCase();
|
|
}
|
|
|
|
async function protectStagedTree(root: string): Promise<void> {
|
|
const rootHandle = await open(
|
|
root,
|
|
constants.O_RDONLY
|
|
| (constants.O_NOFOLLOW ?? 0)
|
|
| (constants.O_DIRECTORY ?? 0),
|
|
);
|
|
try {
|
|
const opened = await rootHandle.stat();
|
|
if (!opened.isDirectory()) {
|
|
throw new Error("staged runtime context root must be a directory");
|
|
}
|
|
// Keep directories owner-writable so the private staging tree can be
|
|
// removed without a second path-based chmod traversal.
|
|
await rootHandle.chmod(0o700);
|
|
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
const child = join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await protectStagedTree(child);
|
|
continue;
|
|
}
|
|
const childHandle = await open(
|
|
child,
|
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
);
|
|
try {
|
|
const openedChild = await childHandle.stat();
|
|
if (!openedChild.isFile()) {
|
|
throw new Error("staged runtime context asset must be a regular file");
|
|
}
|
|
await childHandle.chmod(openedChild.mode & 0o555);
|
|
} finally {
|
|
await childHandle.close();
|
|
}
|
|
}
|
|
} finally {
|
|
await rootHandle.close();
|
|
}
|
|
}
|
|
|
|
export async function materializeNativeRuntimeSkills(
|
|
context: NativeRuntimeContextSnapshot | null,
|
|
skillsHome: string,
|
|
dependencies: {
|
|
/** Internal test seam for failed staging cleanup coverage. */
|
|
removeTree?: (path: string) => Promise<void>;
|
|
/** Internal test seam for publication failure coverage. */
|
|
renameTree?: (source: string, destination: string) => Promise<void>;
|
|
} = {},
|
|
): Promise<void> {
|
|
const removeTree =
|
|
dependencies.removeTree ??
|
|
((path: string) => rm(path, { recursive: true, force: true }));
|
|
const renameTree = dependencies.renameTree ?? rename;
|
|
if (context) {
|
|
const runtimeNames = new Set<string>();
|
|
for (const skill of context.skills) {
|
|
safeMaterializationTarget(skillsHome, skill.runtimeName);
|
|
const runtimeName = portableRuntimeNameKey(skill.runtimeName);
|
|
if (runtimeNames.has(runtimeName)) {
|
|
throw new Error("runtime context skill names must not overlap");
|
|
}
|
|
runtimeNames.add(runtimeName);
|
|
}
|
|
for (const runtimeName of runtimeNames) {
|
|
const segments = runtimeName.split("/");
|
|
for (let length = 1; length < segments.length; length += 1) {
|
|
if (runtimeNames.has(segments.slice(0, length).join("/"))) {
|
|
throw new Error("runtime context skill names must not overlap");
|
|
}
|
|
}
|
|
}
|
|
for (const skill of context.skills) {
|
|
await assertSafeTree(skill.bundle.rootPath);
|
|
}
|
|
}
|
|
|
|
const skillsHomeExists = await lstat(skillsHome).then(
|
|
() => true,
|
|
(error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return false;
|
|
throw error;
|
|
},
|
|
);
|
|
if (skillsHomeExists) {
|
|
throw new Error(
|
|
"runtime context skills home must be a fresh destination",
|
|
);
|
|
}
|
|
|
|
const parent = dirname(skillsHome);
|
|
const nonce = randomUUID();
|
|
const stagingHome = join(parent, `.paperclip-skills-staging-${nonce}`);
|
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
await chmod(parent, 0o700);
|
|
await mkdir(stagingHome, { mode: 0o700 });
|
|
try {
|
|
for (const skill of context?.skills ?? []) {
|
|
const target = safeMaterializationTarget(stagingHome, skill.runtimeName);
|
|
await cp(skill.bundle.rootPath, target, {
|
|
recursive: true,
|
|
force: false,
|
|
errorOnExist: true,
|
|
dereference: false,
|
|
verbatimSymlinks: true,
|
|
});
|
|
await assertSafeTree(target);
|
|
await protectStagedTree(target);
|
|
}
|
|
|
|
// The caller supplies a new isolated home for each provider launch. Never
|
|
// move an existing assignment out of its canonical path: a failed bounded
|
|
// rollback cannot portably guarantee that name is restored on every
|
|
// filesystem. Publication therefore targets only a fresh destination.
|
|
await renameTree(stagingHome, skillsHome);
|
|
} catch (error) {
|
|
await removeTree(stagingHome).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readSourceCodexAuth(sourceAuth: string): Promise<Buffer | null> {
|
|
const handle = await open(
|
|
sourceAuth,
|
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT" || error.code === "ELOOP") return null;
|
|
throw error;
|
|
});
|
|
if (!handle) return null;
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile()) return null;
|
|
if (stat.size > 1024 * 1024) {
|
|
throw new Error("source Codex auth file exceeds the 1 MiB limit");
|
|
}
|
|
return await handle.readFile();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
export async function prepareIsolatedCodexHome(input: {
|
|
context: NativeRuntimeContextSnapshot | null;
|
|
codexHome: string;
|
|
sourceCodexHome?: string | null;
|
|
nativeMcp?: NativeMcpLaunchBinding | null;
|
|
apiKey?: string | null;
|
|
}): Promise<void> {
|
|
await materializeNativeRuntimeSkills(
|
|
input.context,
|
|
join(input.codexHome, "skills"),
|
|
);
|
|
|
|
const configPath = join(input.codexHome, "config.toml");
|
|
await rm(configPath, { force: true });
|
|
await writeFile(configPath, [
|
|
// Codex shell snapshots serialize the provider process environment. The
|
|
// native runner injects short-lived provider and MCP bindings, so a
|
|
// snapshot would turn ephemeral credentials into durable session state.
|
|
"[features]",
|
|
"shell_snapshot = false",
|
|
"",
|
|
...(input.nativeMcp
|
|
? [
|
|
`[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`,
|
|
`url = ${JSON.stringify(input.nativeMcp.url)}`,
|
|
`http_headers = { Authorization = ${JSON.stringify(`Bearer ${input.nativeMcp.token}`)} }`,
|
|
"",
|
|
]
|
|
: []),
|
|
].join("\n"), { mode: 0o600 });
|
|
|
|
const targetAuth = join(input.codexHome, "auth.json");
|
|
await rm(targetAuth, { force: true });
|
|
const apiKey = input.apiKey?.trim();
|
|
if (apiKey) {
|
|
// The pinned Codex app-server authenticates API-key automation through its
|
|
// login cache rather than the CLI-only CODEX_API_KEY path. Keep this file
|
|
// owner-only in the disposable session home.
|
|
await writeFile(
|
|
targetAuth,
|
|
JSON.stringify({ OPENAI_API_KEY: apiKey }),
|
|
{ mode: 0o600 },
|
|
);
|
|
await chmod(targetAuth, 0o600);
|
|
return;
|
|
}
|
|
|
|
const sourceHome = input.sourceCodexHome?.trim();
|
|
if (!sourceHome) return;
|
|
const sourceAuth = await readSourceCodexAuth(join(sourceHome, "auth.json"));
|
|
if (!sourceAuth) return;
|
|
await writeFile(targetAuth, sourceAuth, { mode: 0o600 });
|
|
await chmod(targetAuth, 0o600);
|
|
}
|