mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 3/8 and focuses on tool-access policy and authorization core > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: Authorization, OAuth binding, secret projection, content guards, and policy evaluation need a security-reviewable server boundary. - Proposed solution: Adds tool-access services/routes/tests plus the runtime service dependencies directly imported by the core, without registering the routes in the application. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/02-schema-shared`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: SecurityEngineer for authz, OAuth, secrets, and content guards; Greptile on every PR. ## What Changed - Adds tool-access services/routes/tests plus the runtime service dependencies directly imported by the core, without registering the routes in the application. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - Focused server Vitest run — 4 files, 143 tests passed ## Risks - Authorization bugs could permit cross-company or over-broad tool access; the PR remains inert until PR 4 wiring and requires dedicated security review. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## 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] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change 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 ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
import { redactEventPayload, redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
|
|
|
export class ToolContentValidationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly reasonCode: string,
|
|
public readonly findings: string[],
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
const PROMPT_INJECTION_PATTERNS: Array<{ code: string; re: RegExp }> = [
|
|
{ code: "ignore_previous_instructions", re: /\bignore\b.{0,40}\b(previous|above|earlier)\b.{0,40}\binstructions?\b/i },
|
|
{ code: "reveal_system_prompt", re: /\b(reveal|print|dump|show)\b.{0,40}\b(system|developer)\b.{0,20}\b(prompt|message|instructions?)\b/i },
|
|
{ code: "instruction_hijack", re: /\b(new|updated)\b.{0,20}\b(system|developer)\b.{0,20}\b(instructions?|message)\b/i },
|
|
{ code: "secret_exfiltration", re: /\b(exfiltrate|leak|steal|send)\b.{0,40}\b(secret|token|api[-_ ]?key|credential)s?\b/i },
|
|
];
|
|
|
|
type ToolActionSigningSecretEnv = Partial<
|
|
Record<"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET" | "PAPERCLIP_AGENT_JWT_SECRET" | "BETTER_AUTH_SECRET", string | undefined>
|
|
>;
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
const proto = Object.getPrototypeOf(value);
|
|
return proto === Object.prototype || proto === null;
|
|
}
|
|
|
|
function stableSerialize(value: unknown): string {
|
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`);
|
|
return `{${entries.join(",")}}`;
|
|
}
|
|
|
|
function scanPromptInjection(value: unknown): string[] {
|
|
const text = typeof value === "string" ? value : stableSerialize(value);
|
|
return PROMPT_INJECTION_PATTERNS
|
|
.filter((pattern) => pattern.re.test(text))
|
|
.map((pattern) => pattern.code);
|
|
}
|
|
|
|
export class ToolActionSigningSecretMissingError extends Error {
|
|
constructor() {
|
|
super(
|
|
"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET is not configured; signed tool action approvals cannot be issued. " +
|
|
"Set PAPERCLIP_TOOL_ACTION_SIGNING_SECRET in this instance's environment (worktrees inherit it from .paperclip/.env).",
|
|
);
|
|
this.name = "ToolActionSigningSecretMissingError";
|
|
}
|
|
}
|
|
|
|
export function resolveToolActionSigningSecret(env: ToolActionSigningSecretEnv = process.env as ToolActionSigningSecretEnv) {
|
|
const secret = env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim();
|
|
if (!secret) {
|
|
throw new ToolActionSigningSecretMissingError();
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function signingSecret(explicitSecret?: string) {
|
|
const secret = explicitSecret?.trim();
|
|
return secret || resolveToolActionSigningSecret();
|
|
}
|
|
|
|
export function canonicalToolArguments(value: unknown) {
|
|
return stableSerialize(value ?? {});
|
|
}
|
|
|
|
export function hashToolValue(value: unknown) {
|
|
return createHash("sha256").update(stableSerialize(value)).digest("hex");
|
|
}
|
|
|
|
export function signToolArguments(args: {
|
|
invocationId: string;
|
|
toolName: string;
|
|
canonicalArguments: string;
|
|
approvalSnapshot?: unknown;
|
|
executionOnApprove?: boolean;
|
|
signingSecret?: string;
|
|
}) {
|
|
const payloadValue: Record<string, unknown> = {
|
|
invocationId: args.invocationId,
|
|
toolName: args.toolName,
|
|
canonicalArguments: args.canonicalArguments,
|
|
};
|
|
if (args.executionOnApprove === true) {
|
|
payloadValue.executionOnApprove = true;
|
|
}
|
|
if (args.approvalSnapshot !== undefined) {
|
|
payloadValue.approvalSnapshot = args.approvalSnapshot;
|
|
}
|
|
const payload = stableSerialize(payloadValue);
|
|
const signature = createHmac("sha256", signingSecret(args.signingSecret)).update(payload).digest("base64url");
|
|
return Buffer.from(JSON.stringify({ version: 1, alg: "HS256", payload, signature }), "utf8").toString("base64url");
|
|
}
|
|
|
|
export function verifyToolArgumentsSignature(input: {
|
|
signedArguments: string | null | undefined;
|
|
invocationId: string;
|
|
toolName: string;
|
|
canonicalArguments: string;
|
|
approvalSnapshot?: unknown;
|
|
executionOnApprove?: boolean;
|
|
signingSecret?: string;
|
|
}) {
|
|
if (!input.signedArguments) return false;
|
|
let parsed: { version?: unknown; alg?: unknown; payload?: unknown; signature?: unknown };
|
|
try {
|
|
parsed = JSON.parse(Buffer.from(input.signedArguments, "base64url").toString("utf8"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (parsed.version !== 1 || parsed.alg !== "HS256") return false;
|
|
if (typeof parsed.payload !== "string" || typeof parsed.signature !== "string") return false;
|
|
const expectedPayloadValue: Record<string, unknown> = {
|
|
invocationId: input.invocationId,
|
|
toolName: input.toolName,
|
|
canonicalArguments: input.canonicalArguments,
|
|
};
|
|
if (input.executionOnApprove !== undefined) {
|
|
expectedPayloadValue.executionOnApprove = input.executionOnApprove;
|
|
}
|
|
if (input.approvalSnapshot !== undefined) {
|
|
expectedPayloadValue.approvalSnapshot = input.approvalSnapshot;
|
|
}
|
|
const expectedPayload = stableSerialize(expectedPayloadValue);
|
|
if (parsed.payload !== expectedPayload) return false;
|
|
const expected = createHmac("sha256", signingSecret(input.signingSecret)).update(parsed.payload).digest("base64url");
|
|
const left = Buffer.from(parsed.signature);
|
|
const right = Buffer.from(expected);
|
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
}
|
|
|
|
export function readSignedToolArgumentsPayload(input: {
|
|
signedArguments: string | null | undefined;
|
|
invocationId: string;
|
|
toolName: string;
|
|
signingSecret?: string;
|
|
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean } | null {
|
|
if (!input.signedArguments) return null;
|
|
let parsed: { payload?: unknown };
|
|
try {
|
|
parsed = JSON.parse(Buffer.from(input.signedArguments, "base64url").toString("utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof parsed.payload !== "string") return null;
|
|
let payload: {
|
|
invocationId?: unknown;
|
|
toolName?: unknown;
|
|
canonicalArguments?: unknown;
|
|
approvalSnapshot?: unknown;
|
|
executionOnApprove?: unknown;
|
|
};
|
|
try {
|
|
payload = JSON.parse(parsed.payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (payload.invocationId !== input.invocationId || payload.toolName !== input.toolName) return null;
|
|
if (typeof payload.canonicalArguments !== "string") return null;
|
|
if (!verifyToolArgumentsSignature({
|
|
signedArguments: input.signedArguments,
|
|
invocationId: input.invocationId,
|
|
toolName: input.toolName,
|
|
canonicalArguments: payload.canonicalArguments,
|
|
approvalSnapshot: payload.approvalSnapshot,
|
|
executionOnApprove: payload.executionOnApprove === true ? true : undefined,
|
|
signingSecret: input.signingSecret,
|
|
})) {
|
|
return null;
|
|
}
|
|
try {
|
|
return {
|
|
arguments: JSON.parse(payload.canonicalArguments) as unknown,
|
|
...(payload.approvalSnapshot !== undefined ? { approvalSnapshot: payload.approvalSnapshot } : {}),
|
|
...(payload.executionOnApprove === true ? { executionOnApprove: true } : {}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readSignedToolArguments(input: {
|
|
signedArguments: string | null | undefined;
|
|
invocationId: string;
|
|
toolName: string;
|
|
signingSecret?: string;
|
|
}) {
|
|
return readSignedToolArgumentsPayload(input)?.arguments ?? null;
|
|
}
|
|
|
|
export function summarizeToolValue(value: unknown) {
|
|
const redacted = isPlainObject(value) ? redactEventPayload(value) : value;
|
|
const serialized = stableSerialize(redacted);
|
|
const redactedText = redactSensitiveText(serialized);
|
|
return {
|
|
summary: redactedText.length > 4000 ? `${redactedText.slice(0, 3997)}...` : redactedText,
|
|
sizeBytes: Buffer.byteLength(serialized, "utf8"),
|
|
sha256: createHash("sha256").update(serialized).digest("hex"),
|
|
redactedFields: redactedText.includes(REDACTED_EVENT_VALUE) ? ["sensitive_value"] : [],
|
|
};
|
|
}
|
|
|
|
export function validateToolContent(input: {
|
|
value: unknown;
|
|
direction: "arguments" | "result";
|
|
sensitiveMode?: "redact" | "block";
|
|
promptInjectionMode?: "redact" | "block" | "ignore";
|
|
}) {
|
|
const sensitiveMode = input.sensitiveMode ?? "redact";
|
|
const promptInjectionMode = input.promptInjectionMode ?? (input.direction === "result" ? "block" : "ignore");
|
|
const redactedValue = isPlainObject(input.value) ? redactEventPayload(input.value) : input.value;
|
|
const redactedSummary = summarizeToolValue(redactedValue);
|
|
const findings: string[] = [];
|
|
|
|
if (redactedSummary.redactedFields?.length) {
|
|
findings.push("sensitive_value");
|
|
if (sensitiveMode === "block") {
|
|
throw new ToolContentValidationError("Tool content contains sensitive values", "sensitive_value_blocked", findings);
|
|
}
|
|
}
|
|
|
|
const promptFindings = promptInjectionMode === "ignore" ? [] : scanPromptInjection(input.value);
|
|
if (promptFindings.length > 0) {
|
|
findings.push(...promptFindings);
|
|
if (promptInjectionMode === "block") {
|
|
throw new ToolContentValidationError(
|
|
"Tool result contained prompt-injection instructions and was blocked",
|
|
"prompt_injection_blocked",
|
|
promptFindings,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
value: redactedValue,
|
|
summary: redactedSummary,
|
|
findings,
|
|
};
|
|
}
|