Root cause
Path & link handling introduced
fix(adapter): combine instructions file and path directive into single temp file
How AI contributed: Direct introductionPublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Paperclip server runtime reads adapterConfig.instructionsFilePath (agent-controlled via PATCH /api/agents/:id, z.record(z.unknown()) schema with no path restriction) directly from the host filesystem via fs.readFile in the claude-local adapter execution path, allowing any holder of an Agent API key to read arbitrary files (e.g. .env, SSH keys, CI secrets) accessible to the server process. CWE-73 / CWE-284 trust-boundary failure.
Instructions are loaded through a file path the agent can rewrite without any restriction, and the server resolves that path itself rather than through a sandboxed or curated document store.
Root cause
fix(adapter): combine instructions file and path directive into single temp file
Fix
[codex] harden authenticated routes and issue editor reliability (#3741)
Case facts
@@ -291,6 +291,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec const billingType = resolveClaudeBillingType(env); const skillsDir = await buildSkillsDir(); + // When instructionsFilePath is configured, create a combined temp file that+ // includes both the file content and the path directive, so we only need+ // --append-system-prompt-file (Claude CLI forbids using both flags together).+ let effectiveInstructionsFilePath = instructionsFilePath;+ if (instructionsFilePath) {+ const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");+ const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;+ const combinedPath = path.join(skillsDir, "agent-instructions.md");+ await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");+ effectiveInstructionsFilePath = combinedPath;+ }+ const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");@@ -323,12 +335,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec if (model) args.push("--model", model); if (effort) args.push("--effort", effort); if (maxTurns > 0) args.push("--max-turns", String(maxTurns));- if (instructionsFilePath) {- args.push("--append-system-prompt-file", instructionsFilePath);- args.push(- "--append-system-prompt",- `The above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`,- );+ if (effectiveInstructionsFilePath) {+ args.push("--append-system-prompt-file", effectiveInstructionsFilePath); } args.push("--add-dir", skillsDir); if (extraArgs.length > 0) args.push(...extraArgs);@@ -605,29 +638,34 @@ export function agentRoutes(db: Db) { delete nextAdapterConfig.promptTemplate; const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }); return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId);- if (req.actor.type === "board") return;- if (!req.actor.agentId) throw forbidden("Agent authentication required");-- const actorAgent = await svc.getById(req.actor.agentId);- if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {- throw forbidden("Agent key cannot access another company");+ if (req.actor.type !== "board") {+ throw forbidden(+ "Only board-authenticated callers can manage instructions path or bundle configuration",+ ); }- if (actorAgent.id === targetAgent.id) return;-- const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);- if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;+ await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);+ } - throw forbidden("Only the target agent or an ancestor manager can update instructions path");+ function assertNoAgentInstructionsConfigMutation(+ req: Request,+ adapterConfig: Record<string, unknown> | null | undefined,+ ) {+ if (req.actor.type !== "agent" || !adapterConfig) return;+ const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);+ if (changedSensitiveKeys.length === 0) return;+ throw forbidden(+ `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,+ ); } function summarizeAgentUpdateDetails(patch: Record<string, unknown>) { const changedTopLevelKeys = Object.keys(patch).sort(); const details: Record<string, unknown> = { changedTopLevelKeys }; const adapterConfigPatch = asRecord(patch.adapterConfig); if (adapterConfigPatch) {@@ -1885,20 +1937,25 @@ export function agentRoutes(db: Db) { const replaceAdapterConfig = patchData.replaceAdapterConfig === true; delete patchData.replaceAdapterConfig; if (hasOwn(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { res.status(422).json({ error: "adapterConfig must be an object" }); return; }- const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>- KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),+ assertNoAgentInstructionsConfigMutation(req, adapterConfig);+ assertNoAgentHostWorkspaceCommandMutation(+ req,+ collectAgentAdapterWorkspaceCommandPaths(adapterConfig),+ );+ const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>+ KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), );- if (changingInstructionsPath) {+ if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; } const requestedAdapterType = hasOwn(patchData, "adapterType") ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) : existing.adapterType;AI-assisted change 5936bebca48a21a082612eaaaa053ab04831c721a98c30f78250d9e6fa6e55ba · Fix 6c58913c7392c2cecd9fc2b919bc94b317daf2fadede92ce487d227a87a9306e