Harden tool action argument handling

This commit is contained in:
Dotta committed 2026-06-10 22:32:10 +00:00
1 parent 2fa0e12aab
commit 2d6238bb70
7 files changed
+126 -14

No files matched your search

+1
View File
@@ -2,6 +2,7 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
PORT=3100
SERVE_UI=false
BETTER_AUTH_SECRET=paperclip-dev-secret
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-change-me
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
+3
View File
@@ -33,6 +33,7 @@ docker run --name paperclip \
-e HOST=0.0.0.0 \
-e PAPERCLIP_HOME=/paperclip \
-e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
-e PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
-v "$(pwd)/data/docker-paperclip:/paperclip" \
paperclip-local
```
@@ -56,6 +57,7 @@ Single container, no external database. Data persists via a bind mount.
```sh
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
docker compose -f docker/docker-compose.quickstart.yml up --build
```
@@ -187,6 +189,7 @@ The `docker/quadlet/` directory contains unit files to run Paperclip + PostgreSQ
```sh
cat > ~/.config/containers/systemd/paperclip.env <<EOL
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32)
POSTGRES_USER=paperclip
POSTGRES_PASSWORD=paperclip
POSTGRES_DB=paperclip
+2
View File
@@ -4,6 +4,8 @@ This runbook covers Paperclip Tools & Access runtime slots for MCP connections.
Do not print raw bearer tokens, gateway session tokens, credential headers, environment variables, or secret values while following this runbook. The APIs below return redacted state and audit metadata; keep shell tracing disabled when exporting credentials.
Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation.
## Support Matrix
| Transport | Local trusted | Hosted cloud / public authenticated | Notes |
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
canonicalToolArguments,
readSignedToolArguments,
@@ -9,6 +9,32 @@ import {
} from "../services/tool-content-guards.js";
describe("tool content guards", () => {
const originalToolActionSigningSecret = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
const originalAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const originalAuthSecret = process.env.BETTER_AUTH_SECRET;
beforeEach(() => {
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "test-tool-action-signing-secret";
});
afterEach(() => {
if (originalToolActionSigningSecret === undefined) {
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
} else {
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalToolActionSigningSecret;
}
if (originalAgentJwtSecret === undefined) {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
} else {
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalAgentJwtSecret;
}
if (originalAuthSecret === undefined) {
delete process.env.BETTER_AUTH_SECRET;
} else {
process.env.BETTER_AUTH_SECRET = originalAuthSecret;
}
});
it("signs canonical arguments and rejects tampered arguments", () => {
const canonicalArguments = canonicalToolArguments({ body: "hello", noteId: "n1" });
const signedArguments = signToolArguments({
@@ -40,6 +66,20 @@ describe("tool content guards", () => {
})).toEqual({ body: "hello", noteId: "n1" });
});
it("requires a dedicated tool action signing secret", () => {
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
process.env.PAPERCLIP_AGENT_JWT_SECRET = "agent-jwt-secret";
process.env.BETTER_AUTH_SECRET = "auth-secret";
expect(() =>
signToolArguments({
invocationId: "invocation-1",
toolName: "mcp-remote-fixture:update_note",
canonicalArguments: canonicalToolArguments({ body: "hello" }),
}),
).toThrow("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET");
});
it("redacts sensitive argument values before summarizing them", () => {
const result = validateToolContent({
value: { query: "ok", apiKey: "sk-secret-value" },
@@ -30,6 +30,7 @@ import {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const originalToolActionSigningSecret = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
async function createRunFixture(db: ReturnType<typeof createDb>) {
const company = await db.insert(companies).values({
@@ -94,6 +95,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "test-tool-action-signing-secret";
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-");
db = createDb(tempDb.connectionString);
}, 20_000);
@@ -117,6 +119,11 @@ describeEmbeddedPostgres("tool gateway service", () => {
afterAll(async () => {
await tempDb?.cleanup();
if (originalToolActionSigningSecret === undefined) {
delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
} else {
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalToolActionSigningSecret;
}
});
it("gates write tools with an action request and executes only stored reviewed arguments once", async () => {
@@ -326,4 +333,62 @@ describeEmbeddedPostgres("tool gateway service", () => {
});
expect(serialized).not.toContain(maliciousContent);
});
it("passes original sensitive arguments to plugin executors while redacting stored summaries", async () => {
const { company, agent, run } = await createRunFixture(db);
let executedParameters: unknown;
const gateway = createToolGatewayService(db, {
pluginToolDispatcher: {
initialize: async () => {},
teardown: () => {},
listToolsForAgent: () => [
{
name: "fixture:read_status",
displayName: "Read status",
description: "Echoes parameters for executor assertions.",
parametersSchema: { type: "object" },
pluginId: "fixture-plugin",
},
],
getTool: () => null,
executeTool: async (_name, parameters) => {
executedParameters = parameters;
return {
pluginId: "fixture-plugin",
toolName: "read_status",
result: { ok: true },
};
},
registerPluginTools: () => {},
unregisterPluginTools: () => {},
toolCount: () => 1,
getRegistry: () => {
throw new Error("not implemented");
},
},
});
await db.insert(toolPolicies).values({
companyId: company.id,
name: "Allow read fixture",
policyType: "allow",
selectors: { toolName: "fixture:read_status" },
});
await gateway.executePluginTool({
actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id },
tool: "fixture:read_status",
parameters: { query: "ok", apiKey: "sk-secret-value" },
runContext: { companyId: company.id, agentId: agent.id, runId: run.id },
});
expect(executedParameters).toEqual({ query: "ok", apiKey: "sk-secret-value" });
const [invocation] = await db.select().from(toolInvocations);
const [callEvent] = await db.select().from(toolCallEvents).where(eq(toolCallEvents.eventType, "call_completed"));
const [audit] = await db.select().from(activityLog).where(eq(activityLog.action, "tool_gateway.call_allowed"));
const serialized = JSON.stringify({ invocation, callEvent, audit });
expect(serialized).not.toContain("sk-secret-value");
expect(serialized).toContain("***REDACTED***");
});
});
+5 -6
View File
@@ -41,12 +41,11 @@ function scanPromptInjection(value: unknown): string[] {
}
function signingSecret() {
return (
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim()
|| process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()
|| process.env.BETTER_AUTH_SECRET?.trim()
|| "paperclip-dev-tool-action-signing-secret"
);
const secret = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim();
if (!secret) {
throw new Error("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET is required for signed tool action approvals");
}
return secret;
}
export function canonicalToolArguments(value: unknown) {
+9 -7
View File
@@ -1204,13 +1204,14 @@ export function createToolGatewayService(
const tool = findTool(input.tool);
const requestedParameters = input.parameters ?? {};
const argumentValidation = validateToolContent({
value: input.parameters ?? {},
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
});
let effectiveParameters = argumentValidation.value;
let effectiveParameters = requestedParameters;
let effectiveArgumentsSummary = argumentValidation.summary;
if (input.approvedActionRequestId) {
@@ -1360,7 +1361,7 @@ export function createToolGatewayService(
throw new ToolGatewayHttpError(409, "Tool action request was already consumed", "action_already_consumed");
}
invocationId = storedInvocation.id as typeof invocationId;
effectiveParameters = storedArgumentValidation.value;
effectiveParameters = storedParameters;
effectiveArgumentsSummary = storedArgumentValidation.summary;
await db
.update(toolInvocations)
@@ -1649,8 +1650,9 @@ export function createToolGatewayService(
throw new ToolGatewayHttpError(404, `Tool "${input.tool}" is not a plugin tool`, "tool_not_found");
}
const requestedParameters = input.parameters ?? {};
const argumentValidation = validateToolContent({
value: input.parameters ?? {},
value: requestedParameters,
direction: "arguments",
sensitiveMode: "redact",
promptInjectionMode: "ignore",
@@ -1659,7 +1661,7 @@ export function createToolGatewayService(
const decisionInput = policyInputForTool({
session: sessionLike,
tool,
parameters: argumentValidation.value,
parameters: requestedParameters,
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
@@ -1677,7 +1679,7 @@ export function createToolGatewayService(
actionRequest: recorded.actionRequest,
session: sessionLike,
tool,
parameters: argumentValidation.value,
parameters: requestedParameters,
argumentsSummary: argumentValidation.summary,
policyDecision: accessDecision,
});
@@ -1740,7 +1742,7 @@ export function createToolGatewayService(
const startedAt = Date.now();
try {
const result = await pluginToolDispatcher.executeTool(input.tool, argumentValidation.value, input.runContext);
const result = await pluginToolDispatcher.executeTool(input.tool, requestedParameters, input.runContext);
const resultValidation = validateToolContent({
value: result,
direction: "result",