Files
PaperClipAI/server/src/services/tool-content-guards.ts
T
DottaandPaperclip cfa5e0704e feat(mcp) [split 3/8]: add tool access policy core (#9558)
## 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>
2026-07-14 14:22:39 -05:00

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,
};
}