mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
Verify native semantic input against its raw wire digest (#15301)
## Thinking Path > - Paperclip manages AI agents and their work. > - Native runners send authenticated semantic tool inputs to the control plane. > - The runner hashes the complete input, but the receiver used a redacted receipt hash. > - Protected fields and credential-like document text can therefore fail integrity validation even when the input is unchanged. > - This pull request verifies the complete input with the existing canonical hash function. > - Receipt redaction and permanent rejection of altered input remain in place. ## Linked Issues or Issue Description **What happened?** The Rust runner preserves tool arguments and hashes their canonical JSON. The TypeScript receiver instead redacts the input before hashing. A valid input such as a synthetic document containing `Bearer fixture_token_123456` fails with `native_event_replay_conflict`. A digest of redacted input can also pass without proving the original protected values. **Expected behavior** Verify the complete transmitted input after authentication and exact scope checks. Reject any incorrect digest before durable commit, dispatch, or ACK. **Steps to reproduce** Run the new authenticated controller regressions against the prior receiver. The protected-field and credential-like document cases fail, and the redacted-digest rejection case receives an ACK. The same tests pass with this change. **Paperclip version or commit** Reproduced from source at `858094ba8123c7edb56623597cd96f0391f7e2d4` with synthetic fixtures. Applies to native runner deployments. Related: #14937 preserves semantic input bytes for execution. GitHub issue and PR searches found no duplicate fix; #14591 touches a separate question-draft contract. ## What Changed - Use the existing bounded raw canonical digest for incoming semantic and MCP tool inputs. - Keep receipt and diagnostic redaction unchanged. - Share four digest fixtures between Rust and the authenticated TypeScript controller, including protected fields, document text, Unicode keys, and number boundaries. - Test raw acceptance, altered protected values, forged and redacted digests, canonicalization limits, permanent reconnect fences, and authentication/scope rejection. - Document the separate wire and receipt contracts and the unchanged recovery fence. ## Verification - Before the production fix, the new selected regressions produced three expected failures and eight passes. - Focused controller, receipt, and semantic-tool suites: 138 tests passed. - Rust shared digest fixture test: 1 test passed. - Full workspace typecheck and build passed; the final receiver delta also passed its TypeScript typecheck. Canonical Linux PR CI passed the full aggregate test gate, runner checks, build, and canary dry run at `efcd38e2d8a9fa6340db4f4e863b6febbe8ca32a`. - Independent review passed, including 18 independently run authenticated regressions and the Rust golden test, plus both matching-digest size-limit cases after the test-only follow-up. Greptile is 5/5 on the current head with no unresolved threads. Diff whitespace and local secret/PII scan passed; fixtures are synthetic. ## Risks The verifier remains strict: there is no redacted-digest fallback. Existing authentication, scope, sequence, replay, settlement, and authorization checks remain in force. Receipt storage and redaction behavior are unchanged. This patch does not clear failed-run fences or replay prior work. Synthetic tests prove the protocol mismatch; they do not identify the contents of any historical rejected input. ## Model Used OpenAI Codex (GPT-6), with reasoning, code execution, and independent agent review. The exact serving model identifier and context-window size are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d21fe31243
commit
7eadc714d2
5 files changed
+240
-9
No files matched your search
@@ -95,6 +95,15 @@ welcome and every control envelope against the authenticated connection,
|
||||
runner, environment lease, run, normalized session, turn, item, protocol,
|
||||
lease ID, expiry, and revocation metadata before applying an ACK or command.
|
||||
|
||||
Semantic input integrity uses SHA-256 of the complete transmitted input's
|
||||
canonical JSON, with UTF-16 object-key order and JavaScript number formatting.
|
||||
The controller checks this proof after authentication and exact run/session/
|
||||
turn/item correlation, before committing, dispatching, or acknowledging the
|
||||
input. Receipt redaction has a separate digest; a redacted digest cannot stand
|
||||
in for wire integrity, including when only a protected field changes. A failed
|
||||
integrity proof keeps the existing operator-required recovery fence. Updating
|
||||
the verifier does not clear a previously failed run or replay its work.
|
||||
|
||||
The daemon captures and removes the bootstrap environment variable before it
|
||||
parses arguments or starts child work. Secret buffers are overwritten when they
|
||||
are dropped. It resolves the destination once before sending a bearer value and
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use paperclip_runner_core::provider_bridge::semantic_value_digest;
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
fn semantic_input_digests_match_the_authenticated_controller_fixtures() {
|
||||
let fixtures: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test/fixtures/semantic-input-digests.json"
|
||||
))
|
||||
.expect("parse shared semantic input digest fixtures");
|
||||
for fixture in fixtures.as_array().expect("digest fixture array") {
|
||||
assert_eq!(
|
||||
semantic_value_digest(&fixture["input"]),
|
||||
fixture["digest"].as_str().expect("fixture digest"),
|
||||
"fixture {}",
|
||||
fixture["name"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { validatePrpEvent } from "../protocol/replay-contract.js";
|
||||
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
|
||||
import {
|
||||
DurablePrpControlPlane,
|
||||
durableRecoveryInternals,
|
||||
SemanticToolNotDispatchedError,
|
||||
inspectWarmRunTransition,
|
||||
spawnRunner,
|
||||
@@ -1164,7 +1165,17 @@ async function receiveSecure(
|
||||
return value;
|
||||
}
|
||||
|
||||
function semanticInputEvent(sourceSeq = 1): Record<string, unknown> {
|
||||
const semanticInputDigestFixtures = JSON.parse(readFileSync(
|
||||
new URL("../../test/fixtures/semantic-input-digests.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as Array<{ name: string; input: Record<string, unknown>; digest: string }>;
|
||||
|
||||
function rawSemanticInputDigest(input: unknown): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(durableRecoveryInternals.canonicalJson(input)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function semanticInputEvent(sourceSeq = 1, input: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
protocol: "paperclip.runner",
|
||||
version: 1,
|
||||
@@ -1204,11 +1215,11 @@ function semanticInputEvent(sourceSeq = 1): Record<string, unknown> {
|
||||
},
|
||||
idempotencyKey: null,
|
||||
content: {
|
||||
digest: digestPaperclipSemanticContent({}),
|
||||
digest: rawSemanticInputDigest(input),
|
||||
redactionDisposition: "digest_only",
|
||||
references: [],
|
||||
},
|
||||
input: {},
|
||||
input,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1687,6 +1698,144 @@ describe.sequential("DurablePrpControlPlane", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(semanticInputDigestFixtures.flatMap((fixture) =>
|
||||
["semantic_tool.input", "mcp_app.tool_input"].map((eventType) => ({ ...fixture, eventType })),
|
||||
))(
|
||||
"accepts the Rust wire digest and preserves exact input: $eventType / $name",
|
||||
async ({ input, digest, eventType }) => {
|
||||
// The JSON transport normalizes negative zero before the receiver sees it.
|
||||
const wireInput = JSON.parse(JSON.stringify(input)) as Record<string, unknown>;
|
||||
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-input-"));
|
||||
const onProtocolIntegrityError = vi.fn();
|
||||
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
|
||||
onProtocolIntegrityError, onSemanticToolInput,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
|
||||
const event = semanticInputEvent(1, wireInput);
|
||||
(event.payload as any).eventType = eventType;
|
||||
const semantic = (event.payload as any).payload.semantic_tool;
|
||||
// This shared golden also runs against the Rust producer's hash.
|
||||
semantic.content.digest = digest;
|
||||
expect(rawSemanticInputDigest(input)).toBe(digest);
|
||||
sendSecure(client, event);
|
||||
const frames = [await receiveSecure(client), await receiveSecure(client)];
|
||||
expect(frames).toContainEqual(expect.objectContaining({
|
||||
kind: "ack", payload: expect.objectContaining({ ackedSourceSeq: 1 }),
|
||||
}));
|
||||
const command = frames.find((frame) => frame?.kind === "command")!.payload as any;
|
||||
expect(command.payload.inputDigest).toBe(digest.slice("sha256:".length));
|
||||
expect(onSemanticToolInput).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ input: wireInput }));
|
||||
expect(core.store.state.committedEvents).toHaveLength(1);
|
||||
expect(core.store.state.committedEvents[0]?.envelope).toEqual(event);
|
||||
expect(onProtocolIntegrityError).not.toHaveBeenCalled();
|
||||
client.socket.destroy();
|
||||
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"redacted_digest", "protected_field_tampering", "forged_digest",
|
||||
"over_node_limit", "over_depth_limit",
|
||||
] as const)(
|
||||
"rejects invalid raw semantic integrity without commit, dispatch or ACK: %s",
|
||||
async (fault) => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-integrity-"));
|
||||
const onProtocolIntegrityError = vi.fn();
|
||||
const onCommittedEvent = vi.fn(async () => undefined);
|
||||
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
|
||||
onProtocolIntegrityError, onCommittedEvent, onSemanticToolInput,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
|
||||
const input = { password: "fixture-before", safe: true };
|
||||
const event = semanticInputEvent(1, input);
|
||||
const semantic = (event.payload as any).payload.semantic_tool;
|
||||
const redactedDigest = digestPaperclipSemanticContent(input);
|
||||
expect(redactedDigest).not.toBe(semantic.content.digest);
|
||||
if (fault === "redacted_digest") semantic.content.digest = redactedDigest;
|
||||
if (fault === "forged_digest") semantic.content.digest = `sha256:${"0".repeat(64)}`;
|
||||
if (fault === "protected_field_tampering") {
|
||||
semantic.input = { ...input, password: "fixture-after" };
|
||||
// Redacted receipt hashes intentionally cannot prove byte integrity.
|
||||
expect(digestPaperclipSemanticContent(semantic.input)).toBe(redactedDigest);
|
||||
}
|
||||
if (fault === "over_node_limit") semantic.input = { values: Array(10_001).fill(0) };
|
||||
if (fault === "over_depth_limit") {
|
||||
let nested: unknown = {};
|
||||
for (let depth = 0; depth < 65; depth += 1) nested = { child: nested };
|
||||
semantic.input = nested;
|
||||
}
|
||||
if (fault === "over_node_limit" || fault === "over_depth_limit") {
|
||||
// Each fixture object has one key, so JSON.stringify is canonical.
|
||||
// Hash independently of the bounded helper: the limits themselves
|
||||
// must reject even a matching raw digest and keep the permanent fence.
|
||||
semantic.content.digest = `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(semantic.input)).digest("hex")}`;
|
||||
}
|
||||
sendSecure(client, event);
|
||||
await expect(receiveSecure(client)).resolves.toBeNull();
|
||||
expect(onProtocolIntegrityError).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
code: "native_event_replay_conflict", reason: "semantic_input_digest_mismatch",
|
||||
recovery: "operator_required",
|
||||
}));
|
||||
expect(core.store.state.ackedSourceSeq).toBe(0);
|
||||
expect(core.store.state.committedEvents).toEqual([]);
|
||||
expect(onCommittedEvent).not.toHaveBeenCalled();
|
||||
expect(onSemanticToolInput).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(onProtocolIntegrityError.mock.calls)).not.toContain("fixture-before");
|
||||
expect(JSON.stringify(onProtocolIntegrityError.mock.calls)).not.toContain("fixture-after");
|
||||
const replay = (await authenticate(core, client.leaseToken!))!;
|
||||
sendSecure(replay, semanticInputEvent());
|
||||
await expect(receiveSecure(replay)).resolves.toBeNull();
|
||||
expect(onProtocolIntegrityError).toHaveBeenCalledTimes(1);
|
||||
expect(onSemanticToolInput).not.toHaveBeenCalled();
|
||||
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["authentication", "envelope", "event", "correlation"] as const)(
|
||||
"rejects raw semantic input outside its exact authenticated scope: %s",
|
||||
async (boundary) => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-scope-"));
|
||||
const onProtocolIntegrityError = vi.fn();
|
||||
const onCommittedEvent = vi.fn(async () => undefined);
|
||||
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
|
||||
onProtocolIntegrityError, onCommittedEvent, onSemanticToolInput,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
const wrongRunId = "00000000-0000-4000-8000-000000000999";
|
||||
const client = await authenticate(core, core.issueBootstrapTicket(),
|
||||
boundary === "authentication" ? { ...identity, runId: wrongRunId } : identity);
|
||||
if (boundary === "authentication") {
|
||||
expect(client).toBeNull();
|
||||
} else {
|
||||
const event = semanticInputEvent(1, { password: "fixture-only" });
|
||||
const payload = event.payload as any;
|
||||
if (boundary === "envelope") event.runId = wrongRunId;
|
||||
if (boundary === "event") payload.runId = wrongRunId;
|
||||
if (boundary === "correlation") payload.payload.semantic_tool.correlation.runId = wrongRunId;
|
||||
sendSecure(client!, event);
|
||||
await expect(receiveSecure(client!)).resolves.toBeNull();
|
||||
}
|
||||
expect(core.store.state.ackedSourceSeq).toBe(0);
|
||||
expect(core.store.state.committedEvents).toEqual([]);
|
||||
expect(onCommittedEvent).not.toHaveBeenCalled();
|
||||
expect(onSemanticToolInput).not.toHaveBeenCalled();
|
||||
expect(onProtocolIntegrityError).not.toHaveBeenCalled();
|
||||
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
|
||||
},
|
||||
);
|
||||
|
||||
it("latches an authenticated semantic integrity fault without ACK or dispatch and still permits suspension", async () => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-integrity-"));
|
||||
const onProtocolIntegrityError = vi.fn();
|
||||
@@ -1848,7 +1997,7 @@ describe.sequential("DurablePrpControlPlane", () => {
|
||||
).semantic_tool as Record<string, unknown>;
|
||||
semantic.input = { changed: true };
|
||||
(semantic.content as Record<string, unknown>).digest =
|
||||
digestPaperclipSemanticContent(semantic.input);
|
||||
rawSemanticInputDigest(semantic.input);
|
||||
sendSecure(client, changed);
|
||||
await expect(receiveSecure(client)).resolves.toBeNull();
|
||||
expect(onProtocolIntegrityError).toHaveBeenCalledTimes(1);
|
||||
@@ -2232,13 +2381,13 @@ describe.sequential("DurablePrpControlPlane", () => {
|
||||
const event = semanticInputEvent();
|
||||
const semantic = ((event.payload as any).payload as any).semantic_tool;
|
||||
semantic.input = args;
|
||||
semantic.content.digest = digestPaperclipSemanticContent(args);
|
||||
semantic.content.digest = rawSemanticInputDigest(args);
|
||||
sendSecure(client, event);
|
||||
const frames = [await receiveSecure(client), await receiveSecure(client)];
|
||||
const wire = frames.find((frame) => frame?.kind === "command")!.payload as any;
|
||||
expect(wire.payload.result.content).toBe(content);
|
||||
expect(wire.payload.input).toBeUndefined();
|
||||
expect(wire.payload.inputDigest).toBe(digestPaperclipSemanticContent(args).slice("sha256:".length));
|
||||
expect(wire.payload.inputDigest).toBe(rawSemanticInputDigest(args).slice("sha256:".length));
|
||||
expect(Buffer.byteLength(JSON.stringify(wire))).toBeLessThan(1024 * 1024);
|
||||
sendSecure(client, { protocol: "paperclip.runner", version: 1, kind: "command_result", payload: {
|
||||
commandId: wire.commandId, controllerSeq: wire.controllerSeq, commandType: wire.type,
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
validatePrpEvent,
|
||||
type PrpEvent,
|
||||
} from "../protocol/replay-contract.js";
|
||||
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
|
||||
import {
|
||||
type DurableRecoveryCommittedEvent,
|
||||
type DurableRecoveryCoreCommand,
|
||||
@@ -416,6 +415,18 @@ function canonicalDigest(value: unknown): string {
|
||||
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
||||
}
|
||||
|
||||
function matchesSemanticInputDigest(input: unknown, digest: unknown): boolean {
|
||||
try {
|
||||
// Wire integrity covers the complete input, including protected fields.
|
||||
// Receipt redaction can erase those differences and has a separate hash.
|
||||
return digest === `sha256:${canonicalDigest(input)}`;
|
||||
} catch {
|
||||
// Canonicalization bounds must keep the same permanent integrity fence.
|
||||
// Never attach an input-derived error or payload to the diagnostic.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function exactIdentity(value: unknown): value is DurableRecoveryIdentity {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
@@ -3141,8 +3152,10 @@ export class DurablePrpControlPlane {
|
||||
if (
|
||||
isSemanticInput &&
|
||||
semantic !== undefined &&
|
||||
(semantic.content as Record<string, unknown>).digest !==
|
||||
digestPaperclipSemanticContent(semantic.input)
|
||||
!matchesSemanticInputDigest(
|
||||
semantic.input,
|
||||
(semantic.content as Record<string, unknown>).digest,
|
||||
)
|
||||
) {
|
||||
// Only the authenticated, schema-valid, exactly correlated input may
|
||||
// permanently fail its owner. Never commit, dispatch, or ACK these bytes.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"name": "plain document",
|
||||
"input": {
|
||||
"body": "Plain fixture document"
|
||||
},
|
||||
"digest": "sha256:2ba38770004c4e7672a5097c4d8e3241c62750b21e034903796fb035fcf9a2a4"
|
||||
},
|
||||
{
|
||||
"name": "protected fields",
|
||||
"input": {
|
||||
"password": "fixture-only-not-a-real-credential",
|
||||
"safe": true
|
||||
},
|
||||
"digest": "sha256:98ecc80f0e6d2d4b99ce3620d18a21392356f99def55ed599c1b1c002ccd2d47"
|
||||
},
|
||||
{
|
||||
"name": "credential-like document text",
|
||||
"input": {
|
||||
"body": "Example Bearer fixture_token_123456\nhttps://example.test/callback?token=fixture-only"
|
||||
},
|
||||
"digest": "sha256:aa7f429cc5dd63e85fd11aa93e0659fa1c7d5041f5f35843fef62092ff86fdc0"
|
||||
},
|
||||
{
|
||||
"name": "canonical Unicode and numbers",
|
||||
"input": {
|
||||
"": "private-use key",
|
||||
"😀": "astral key",
|
||||
"values": [
|
||||
-0,
|
||||
0.000001,
|
||||
9.99999e-7,
|
||||
100000000000000000000,
|
||||
1e+21,
|
||||
true,
|
||||
null,
|
||||
"日本語\n\"text\""
|
||||
]
|
||||
},
|
||||
"digest": "sha256:3104a5e5db1fe2c6dcdaa0bfcf362d7bd36441e7e08905a48ae03be06eea9dc4"
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user