From 95b36a53b69027d0f3763f38f2ea02428302556c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:37:49 +0000 Subject: [PATCH 1/5] Fail closed on unlabeled X posts and scrub upload provenance Re-encode JPEG and PNG attachments to pixels-only bytes before X upload, reject residual C2PA/caBX markers, and treat a live Made with AI sparkle on CreateTweet or TweetResultByRestId as an unlabeled-copy failure. Do not delete the post. x-web still sends empty semantic_annotation_ids and rejects invented disclosure inputs. Co-authored-by: ben <0thernet@users.noreply.github.com> --- CHANGELOG.md | 6 + package.json | 2 + scripts/package-smoke.ts | 3 + skills/wrench/references/cross-posting.md | 2 +- skills/wrench/references/platform-patterns.md | 2 +- skills/wrench/references/x-adapter.md | 5 +- skills/wrench/references/x-ai-disclosure.md | 32 +- src/assets/adapters/x/wrench-web-adapter.json | 2 +- .../x/wrench-web-adapter.v1.10.0.json | 2 +- src/providers/x-image-provenance.test.ts | 102 ++++ src/providers/x-image-provenance.ts | 523 ++++++++++++++++++ src/providers/x-made-with-ai.test.ts | 63 +++ src/providers/x-made-with-ai.ts | 106 ++++ src/providers/x-web-runtime.internal.test.ts | 179 +++++- src/providers/x-web-runtime.ts | 39 +- src/providers/x.test.ts | 60 +- src/providers/x.ts | 19 +- src/skill-x-ai-disclosure.test.ts | 4 + src/web-session-contract-definitions.ts | 2 +- 19 files changed, 1124 insertions(+), 29 deletions(-) create mode 100644 src/providers/x-image-provenance.test.ts create mode 100644 src/providers/x-image-provenance.ts create mode 100644 src/providers/x-made-with-ai.test.ts create mode 100644 src/providers/x-made-with-ai.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a63e9..78b432a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Scrub JPEG and PNG provenance before X media upload, and fail closed when + CreateTweet or independent TweetResultByRestId readback shows a Made with AI + sparkle on user-supplied copy. + ## 0.13.2 - 2026-08-23 - Publish exact Threads text through `posts.publish@5` without requiring a diff --git a/package.json b/package.json index 3393ab8..cbec4cd 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,8 @@ "src/providers/whatsapp-web.ts", "src/providers/whatsapp-omni.ts", "src/providers/x-transaction-id.ts", + "src/providers/x-image-provenance.ts", + "src/providers/x-made-with-ai.ts", "src/providers/x-web-runtime.ts", "src/providers/x-web.ts", "src/providers/x.ts", diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index 1f8a0cb..d6d2770 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -111,6 +111,9 @@ async function verifyPackagedSkill(consumer: string): Promise { "live permalink", "the publish failed", "Do not delete or repost unless the user asks", + "pixels-only", + "caBX", + "locked", ] as const) { if (!disclosure.includes(required)) { throw new Error( diff --git a/skills/wrench/references/cross-posting.md b/skills/wrench/references/cross-posting.md index a93f047..85cde22 100644 --- a/skills/wrench/references/cross-posting.md +++ b/skills/wrench/references/cross-posting.md @@ -24,7 +24,7 @@ When the user supplied the cross-post copy, never mark that package as AI-genera - Do not add "Made with AI", "Made with Grok", or similar disclosure text to the body. - Do not set a provider metadata flag that labels the post or its media as AI-generated. Official `x` `posts.publish` exposes `made_with_ai`; leave it unset or `false` for this workflow. `x-web` `posts.publish` has no such input field; do not invent one. Inspect each installed schema for a comparable field and leave it unset or `false`. - Prefer the installed Wrench transport. Do not click the X composer because a contract is missing or `capture-required`. -- If the user explicitly asked for a composer fallback, open Content disclosure and require Made with AI OFF before Post. After publishing, open the live permalink. A sparkle Made with AI or Made with Grok label means the publish failed; do not report success, and do not delete or repost unless the user asks. +- If the user explicitly asked for a composer fallback, open Content disclosure and require Made with AI OFF before Post. A locked or greyed toggle is an auto-label; stop. After publishing, open the live permalink. A sparkle Made with AI or Made with Grok label means the publish failed; do not report success, and do not delete or repost unless the user asks. ## Preflight every target diff --git a/skills/wrench/references/platform-patterns.md b/skills/wrench/references/platform-patterns.md index e77917a..a5bdd89 100644 --- a/skills/wrench/references/platform-patterns.md +++ b/skills/wrench/references/platform-patterns.md @@ -41,7 +41,7 @@ Read [linkedin-adapter.md](linkedin-adapter.md) before changing the LinkedIn reg - Current GraphQL query IDs, feature sets, and authorization/CSRF material are dynamic inputs to owned code, not manifest fields. Resolve each by an exact reviewed source and require one unambiguous current value. - The observed R1 set includes feed, post, and reply-tree reads. Authorized direct live evidence for the current contract version covers For You, bookmarks, one exact post, and its comments. DM folder and conversation reads remain capture-required because current X Chat events require the separate reviewed key-recovery and acknowledgement-free contract. Native Article reads remain capture-required where entitlement changes the exchange. - Never pair a sliced provider page with its end cursor. Return the cursor only after projecting the complete matching page; fail closed on an over-limit page. Require user and List responses to echo the exact requested identity. -- The observed X web writes are R2 `likes.set`, `content.save`, and private `articles.draft.save`, plus R3 `posts.publish@4`. Prior reversible fixtures proved exact like/bookmark desired state. The Article contract separately creates or replaces one bound structured-text-and-native-links draft with 1–20 bounded inline JPEG/PNG/WebP images and optional captions, then verifies the unpublished owner/lifecycle/content/media readback; it has no publish-capable branch. The post contract publishes exact text with at most one plan-bound PNG or MP4, durably retains the response-bound post/media target before readback, waits for bounded MP4 processing when applicable, and polls only that exact post. Self-thread, reply, repost, and quote mutations remain capture-required. +- The observed X web writes are R2 `likes.set`, `content.save`, and private `articles.draft.save`, plus R3 `posts.publish@4`. Prior reversible fixtures proved exact like/bookmark desired state. The Article contract separately creates or replaces one bound structured-text-and-native-links draft with 1–20 bounded inline JPEG/PNG/WebP images and optional captions, then verifies the unpublished owner/lifecycle/content/media readback; it has no publish-capable branch. The post contract publishes exact text with at most one plan-bound PNG or MP4, scrubs JPEG/PNG provenance before upload, durably retains the response-bound post/media target before readback, waits for bounded MP4 processing when applicable, and polls only that exact post. A live Made with AI sparkle is a failed unlabeled-copy publish. Self-thread, reply, repost, and quote mutations remain capture-required. - Keep DM list/read/send, other media variants, `articles.publish`, and every other mutation capture-required until their cryptographic, request/response, and account/target bindings are complete. Article covers and native inline-image alt text remain outside the observed draft contract. Never open the composer or Article editor as a fallback. - Bind the current X user ID before private reads and mutations. Bind reply/quote/root IDs and every returned created post ID in ordered threads. diff --git a/skills/wrench/references/x-adapter.md b/skills/wrench/references/x-adapter.md index d2181f7..f4f0ae9 100644 --- a/skills/wrench/references/x-adapter.md +++ b/skills/wrench/references/x-adapter.md @@ -189,7 +189,10 @@ content-disclosure field. The reviewed GraphQL contract has no `made_with_ai` or `content_disclosure` input; do not invent one. Official OAuth `x` `posts.publish` exposes optional `made_with_ai` and sends `true` only when the caller explicitly asks. Leave that field unset or `false` for -user-supplied cross-post copy. See [X AI disclosure](x-ai-disclosure.md). +user-supplied cross-post copy. JPEG and PNG uploads are re-encoded to +pixels-only bytes before INIT or APPEND. A live Made with AI sparkle on +CreateTweet or TweetResultByRestId is a failed unlabeled-copy publish. +See [X AI disclosure](x-ai-disclosure.md). Bind every CreateTweet response to the authenticated account and requested reply/quote parent. For a thread, bind each returned post ID, use it as the next reviewed parent, and durably mark each dispatch. Stop on `partial` or `indeterminate`; never replay the root or remaining continuations automatically. diff --git a/skills/wrench/references/x-ai-disclosure.md b/skills/wrench/references/x-ai-disclosure.md index bae9c12..6f3e322 100644 --- a/skills/wrench/references/x-ai-disclosure.md +++ b/skills/wrench/references/x-ai-disclosure.md @@ -18,7 +18,8 @@ to compensate for a missing, `capture-required`, or failed contract. explicit request to label attached media as AI-generated. - `x-web` `posts.publish` has no AI-disclosure input. Do not invent one. The reviewed CreateTweet contract sends empty `semantic_annotation_ids` and no - content-disclosure field. + content-disclosure field. Wrench rejects `made_with_ai`, + `content_disclosure`, and nonempty `semantic_annotation_ids` on this route. - Inspect every other installed schema for a comparable flag and leave it unset or `false` for the user-supplied package. @@ -28,6 +29,19 @@ body. Keep R3 preview and confirm unchanged. Review the digest, then run the printed `wrench confirm `. +## Scrub attachment provenance + +Turning Content disclosure off is not enough. X can still auto-apply Made with +AI from C2PA or other provenance in the uploaded bytes (`caBX` PNG chunks, +`trainedAlgorithmicMedia`, `digitalSourceType`, OpenAI Content Credentials). + +Wrench re-encodes each attached JPEG or PNG to pixels-only bytes before INIT +or APPEND. If the bytes that would be uploaded still match those provenance +markers, the run fails before dispatch. + +Classifier labels can still appear on images that have no obvious C2PA. Treat +live readback as the source of truth. + ## Composer fallback Use the X composer only when the user explicitly asked for that fallback after @@ -35,8 +49,8 @@ a Wrench transport was unavailable. Before Post: 1. Open the post `…` menu. 2. Open Content disclosure. -3. Confirm Made with AI is OFF. If the switch will not turn off, stop. Do not - post. +3. Confirm Made with AI is OFF. If the switch will not turn off, or is locked + or greyed, stop. That is an auto-label. Do not post. 4. Close the dialog and confirm the composer no longer shows a Made with AI disclosure before clicking Post. @@ -45,7 +59,13 @@ After publish: 1. Open the live permalink. Do not infer success from a cleared composer. 2. If the sparkle Made with AI or Made with Grok label is present, the publish failed. Report the permalink and the label. Do not report success. -3. Do not delete or repost unless the user asks. +3. A locked or greyed Content disclosure toggle after Post is the same + auto-label failure. +4. Do not delete or repost unless the user asks. + +## Fail-closed live readback -A labeled post is a failed publish. Leave it in place unless the user asks for -cleanup through an installed `content.delete` capability. +A labeled post is a failed publish. Wrench classifies that outcome as a +terminal unlabeled-copy failure and does not report success. The post may +already exist on X. Leave it in place unless the user asks for cleanup through +an installed `content.delete` capability. diff --git a/src/assets/adapters/x/wrench-web-adapter.json b/src/assets/adapters/x/wrench-web-adapter.json index f7c7277..6f1d4a7 100644 --- a/src/assets/adapters/x/wrench-web-adapter.json +++ b/src/assets/adapters/x/wrench-web-adapter.json @@ -336,7 +336,7 @@ } }, "posts.publish": { - "description": "Observed contract: publish one confirmed X post with optional reviewed PNG or MP4 through exact upload, CreateTweet response, and independent TweetResultByRestId readback binding.", + "description": "Observed contract: publish one confirmed X post with optional reviewed PNG or MP4 through pixels-only upload scrub, CreateTweet response, and fail-closed TweetResultByRestId Made with AI readback.", "risk": "R3", "sideEffect": "Publishes one externally visible X post with the exact confirmed content and reply audience.", "idempotency": "local-at-most-once", diff --git a/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json b/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json index 997ed57..011685f 100644 --- a/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +++ b/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json @@ -309,7 +309,7 @@ } }, "posts.publish": { - "description": "Observed contract: publish one confirmed X post with optional reviewed PNG or MP4 through exact upload, CreateTweet response, and independent TweetResultByRestId readback binding.", + "description": "Observed contract: publish one confirmed X post with optional reviewed PNG or MP4 through pixels-only upload scrub, CreateTweet response, and fail-closed TweetResultByRestId Made with AI readback.", "risk": "R3", "sideEffect": "Publishes one externally visible X post with the exact confirmed content and reply audience.", "idempotency": "local-at-most-once", diff --git a/src/providers/x-image-provenance.test.ts b/src/providers/x-image-provenance.test.ts new file mode 100644 index 0000000..b625359 --- /dev/null +++ b/src/providers/x-image-provenance.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; + +import { + embedPngChunk, + encodePixelsOnlyPng, + imageBytesContainProvenance, + minimalPngBytes, + rejectGifProvenanceMarkers, + scrubXUploadImage, +} from "./x-image-provenance"; + +function pngHasChunk(bytes: Uint8Array, type: string): boolean { + const needle = Buffer.from(type, "ascii"); + return Buffer.from(bytes).includes(needle); +} + +describe("X upload image provenance scrub", () => { + test("re-encodes a PNG that embeds caBX and C2PA markers into pixels-only bytes", () => { + const clean = encodePixelsOnlyPng({ + width: 2, + height: 1, + rgba: Uint8Array.of(10, 20, 30, 255, 40, 50, 60, 255), + }); + const tainted = embedPngChunk( + clean, + "caBX", + Buffer.from("c2pa trainedAlgorithmicMedia digitalSourceType OpenAI", "utf8"), + ); + expect(pngHasChunk(tainted, "caBX")).toBeTrue(); + expect(imageBytesContainProvenance(tainted)).toBeTrue(); + + const scrubbed = scrubXUploadImage(tainted, "image/png"); + expect(pngHasChunk(scrubbed, "caBX")).toBeFalse(); + expect(imageBytesContainProvenance(scrubbed)).toBeFalse(); + expect(Buffer.from(scrubbed).includes(Buffer.from("c2pa"))).toBeFalse(); + expect(Buffer.from(scrubbed).includes(Buffer.from("OpenAI"))).toBeFalse(); + expect(scrubbed.subarray(0, 8)).toEqual(Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)); + expect(scrubXUploadImage(scrubbed, "image/png")).toEqual(scrubbed); + }); + + test("fails closed when residual provenance would still be uploaded", () => { + expect(() => rejectGifProvenanceMarkers(Buffer.from("GIF89a trainedAlgorithmicMedia"))).toThrow( + "GIF attachment contained provenance markers", + ); + }); + + test("strips JPEG APP and COM provenance before upload", () => { + const jpeg = Uint8Array.of( + 0xff, 0xd8, + 0xff, 0xe1, 0x00, 0x12, + ...Buffer.from("c2pa caBX OpenAI", "ascii"), + 0xff, 0xfe, 0x00, 0x19, + ...Buffer.from("trainedAlgorithmicMedia", "ascii"), + 0xff, 0xd9, + ); + expect(imageBytesContainProvenance(jpeg)).toBeTrue(); + const scrubbed = scrubXUploadImage(jpeg, "image/jpeg"); + expect(imageBytesContainProvenance(scrubbed)).toBeFalse(); + expect(scrubbed).toEqual(Uint8Array.of(0xff, 0xd8, 0xff, 0xd9)); + }); + + test("rejects a PNG signature that is not a complete image", () => { + expect(() => scrubXUploadImage(Uint8Array.of(0x89, 0x50, 0x4e, 0x47), "image/png")) + .toThrow("not a complete PNG"); + }); + + test("property: pixel-only PNG re-encode preserves RGBA and drops ancillary chunks", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 4 }), + fc.integer({ min: 1, max: 4 }), + fc.uint8Array({ minLength: 4, maxLength: 64 }), + (width, height, entropy) => { + const rgba = new Uint8Array(width * height * 4); + for (let index = 0; index < rgba.byteLength; index += 1) { + rgba[index] = entropy[index % entropy.byteLength] ?? 0; + } + const clean = encodePixelsOnlyPng({ width, height, rgba }); + const tainted = embedPngChunk( + clean, + "caBX", + Buffer.from(`c2pa-${entropy.byteLength}-trainedAlgorithmicMedia`, "utf8"), + ); + const scrubbed = scrubXUploadImage(tainted, "image/png"); + expect(imageBytesContainProvenance(scrubbed)).toBeFalse(); + expect(pngHasChunk(scrubbed, "caBX")).toBeFalse(); + expect(pngHasChunk(scrubbed, "iTXt")).toBeFalse(); + expect(scrubXUploadImage(scrubbed, "image/png")).toEqual(scrubbed); + }, + ), + { numRuns: 64 }, + ); + }); + + test("minimal PNG fixture is a pixels-only 1x1 image", () => { + const bytes = minimalPngBytes(); + expect(bytes.byteLength).toBeGreaterThan(67); + expect(imageBytesContainProvenance(bytes)).toBeFalse(); + expect(scrubXUploadImage(bytes, "image/png")).toEqual(bytes); + }); +}); diff --git a/src/providers/x-image-provenance.ts b/src/providers/x-image-provenance.ts new file mode 100644 index 0000000..31614d6 --- /dev/null +++ b/src/providers/x-image-provenance.ts @@ -0,0 +1,523 @@ +import { deflateSync, inflateSync } from "node:zlib"; + +/** + * Pixel-only X upload bytes. Re-encode PNG/JPEG so C2PA, XMP, Exif, IPTC, and + * Adobe provenance cannot ride along in ancillary chunks or APP segments. + */ + +export const IMAGE_PROVENANCE_MARKER = /c2pa|cabx|trainedalgorithmic|digitalsourcetype/iu; + +const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); +const MAX_PNG_DIMENSION = 16_384; +const MAX_PNG_PIXELS = 16_384 * 16_384; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + return table; +})(); + +function pngCrc(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function readUint32(bytes: Uint8Array, offset: number): number { + return ( + ((bytes[offset] ?? 0) << 24) + | ((bytes[offset + 1] ?? 0) << 16) + | ((bytes[offset + 2] ?? 0) << 8) + | (bytes[offset + 3] ?? 0) + ) >>> 0; +} + +function writeUint32(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} + +function asciiBytes(value: string): Uint8Array { + const output = new Uint8Array(value.length); + for (let index = 0; index < value.length; index += 1) { + output[index] = value.charCodeAt(index) & 0xff; + } + return output; +} + +type PngChunk = { + readonly type: string; + readonly data: Uint8Array; +}; + +function parsePngChunks(bytes: Uint8Array): readonly PngChunk[] { + if (bytes.byteLength < PNG_SIGNATURE.byteLength + 12) { + throw new Error("X PNG attachment is not a complete PNG"); + } + for (const [index, expected] of PNG_SIGNATURE.entries()) { + if (bytes[index] !== expected) throw new Error("X PNG attachment is not a complete PNG"); + } + const chunks: PngChunk[] = []; + let offset = PNG_SIGNATURE.byteLength; + let sawIend = false; + while (offset + 12 <= bytes.byteLength) { + const length = readUint32(bytes, offset); + if (length > bytes.byteLength - offset - 12) { + throw new Error("X PNG attachment contained a truncated chunk"); + } + const typeBytes = bytes.subarray(offset + 4, offset + 8); + const type = String.fromCharCode(...typeBytes); + if (!/^[A-Za-z]{4}$/u.test(type)) throw new Error("X PNG attachment contained an invalid chunk type"); + const data = bytes.subarray(offset + 8, offset + 8 + length); + const expectedCrc = readUint32(bytes, offset + 8 + length); + if (pngCrc(concatBytes([typeBytes, data])) !== expectedCrc) { + throw new Error("X PNG attachment failed its chunk checksum"); + } + chunks.push({ type, data: new Uint8Array(data) }); + offset += 12 + length; + if (type === "IEND") { + sawIend = true; + break; + } + } + if (!sawIend) throw new Error("X PNG attachment omitted IEND"); + if (chunks[0]?.type !== "IHDR") throw new Error("X PNG attachment omitted IHDR"); + return Object.freeze(chunks); +} + +function writePngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = asciiBytes(type); + const output = new Uint8Array(12 + data.byteLength); + writeUint32(output, 0, data.byteLength); + output.set(typeBytes, 4); + output.set(data, 8); + writeUint32(output, 8 + data.byteLength, pngCrc(concatBytes([typeBytes, data]))); + return output; +} + +function paethPredictor(left: number, up: number, upLeft: number): number { + const estimate = left + up - upLeft; + const distanceLeft = Math.abs(estimate - left); + const distanceUp = Math.abs(estimate - up); + const distanceUpLeft = Math.abs(estimate - upLeft); + if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left; + if (distanceUp <= distanceUpLeft) return up; + return upLeft; +} + +function bytesPerPixel(bitDepth: number, colorType: number): number { + const samples = colorType === 0 || colorType === 3 + ? 1 + : colorType === 2 + ? 3 + : colorType === 4 + ? 2 + : 4; + return Math.max(1, Math.ceil((samples * bitDepth) / 8)); +} + +function scanlineWidth(width: number, bitDepth: number, colorType: number): number { + const samples = colorType === 0 || colorType === 3 + ? 1 + : colorType === 2 + ? 3 + : colorType === 4 + ? 2 + : 4; + return Math.ceil((width * samples * bitDepth) / 8); +} + +function unfilter( + filtered: Uint8Array, + width: number, + height: number, + bitDepth: number, + colorType: number, +): Uint8Array { + const bpp = bytesPerPixel(bitDepth, colorType); + const stride = scanlineWidth(width, bitDepth, colorType); + const expected = height * (stride + 1); + if (filtered.byteLength !== expected) { + throw new Error("X PNG attachment scanlines did not match IHDR"); + } + const raw = new Uint8Array(height * stride); + for (let row = 0; row < height; row += 1) { + const filter = filtered[row * (stride + 1)] ?? 0; + const source = filtered.subarray(row * (stride + 1) + 1, row * (stride + 1) + 1 + stride); + const destination = raw.subarray(row * stride, row * stride + stride); + const prior = row === 0 ? null : raw.subarray((row - 1) * stride, row * stride); + for (let column = 0; column < stride; column += 1) { + const sample = source[column] ?? 0; + const left = column >= bpp ? destination[column - bpp] ?? 0 : 0; + const up = prior?.[column] ?? 0; + const upLeft = prior !== null && column >= bpp ? prior[column - bpp] ?? 0 : 0; + const reconstructed = filter === 0 + ? sample + : filter === 1 + ? (sample + left) & 0xff + : filter === 2 + ? (sample + up) & 0xff + : filter === 3 + ? (sample + Math.floor((left + up) / 2)) & 0xff + : filter === 4 + ? (sample + paethPredictor(left, up, upLeft)) & 0xff + : null; + if (reconstructed === null) throw new Error("X PNG attachment used an unsupported filter"); + destination[column] = reconstructed; + } + } + return raw; +} + +function sampleChannel( + packed: Uint8Array, + pixelIndex: number, + channel: number, + channels: number, + bitDepth: number, +): number { + if (bitDepth === 8) return packed[pixelIndex * channels + channel] ?? 0; + if (bitDepth === 16) { + const offset = (pixelIndex * channels + channel) * 2; + return packed[offset] ?? 0; + } + const bitsPerPixel = channels * bitDepth; + const bitOffset = pixelIndex * bitsPerPixel + channel * bitDepth; + const byteIndex = Math.floor(bitOffset / 8); + const shift = 8 - bitDepth - (bitOffset % 8); + const mask = (1 << bitDepth) - 1; + const value = ((packed[byteIndex] ?? 0) >> shift) & mask; + return bitDepth === 1 ? value * 255 : Math.round((value * 255) / mask); +} + +function expandToRgba( + packed: Uint8Array, + width: number, + height: number, + bitDepth: number, + colorType: number, + palette: Uint8Array | null, + transparency: Uint8Array | null, +): Uint8Array { + const rgba = new Uint8Array(width * height * 4); + const channels = colorType === 0 + ? 1 + : colorType === 2 + ? 3 + : colorType === 3 + ? 1 + : colorType === 4 + ? 2 + : 4; + for (let pixel = 0; pixel < width * height; pixel += 1) { + const dest = pixel * 4; + if (colorType === 3) { + const index = sampleChannel(packed, pixel, 0, 1, bitDepth); + rgba[dest] = palette?.[index * 3] ?? 0; + rgba[dest + 1] = palette?.[index * 3 + 1] ?? 0; + rgba[dest + 2] = palette?.[index * 3 + 2] ?? 0; + rgba[dest + 3] = transparency?.[index] ?? 255; + continue; + } + const red = sampleChannel(packed, pixel, 0, channels, bitDepth); + const green = colorType === 0 || colorType === 4 + ? red + : sampleChannel(packed, pixel, 1, channels, bitDepth); + const blue = colorType === 0 || colorType === 4 + ? red + : sampleChannel(packed, pixel, 2, channels, bitDepth); + const alpha = colorType === 4 + ? sampleChannel(packed, pixel, 1, channels, bitDepth) + : colorType === 6 + ? sampleChannel(packed, pixel, 3, channels, bitDepth) + : transparency === null + ? 255 + : colorType === 0 + ? (red === (transparency[0] ?? -1) ? 0 : 255) + : ( + red === (transparency[1] ?? -1) + && green === (transparency[3] ?? -1) + && blue === (transparency[5] ?? -1) + ) ? 0 : 255; + rgba[dest] = red; + rgba[dest + 1] = green; + rgba[dest + 2] = blue; + rgba[dest + 3] = alpha; + } + return rgba; +} + +const ADAM7 = [ + { x: 0, y: 0, dx: 8, dy: 8 }, + { x: 4, y: 0, dx: 8, dy: 8 }, + { x: 0, y: 4, dx: 4, dy: 8 }, + { x: 2, y: 0, dx: 4, dy: 4 }, + { x: 0, y: 2, dx: 2, dy: 4 }, + { x: 1, y: 0, dx: 2, dy: 2 }, + { x: 0, y: 1, dx: 1, dy: 2 }, +] as const; + +function passSize(width: number, height: number, pass: (typeof ADAM7)[number]): { + readonly width: number; + readonly height: number; +} { + return { + width: pass.x >= width ? 0 : Math.floor((width - pass.x - 1) / pass.dx) + 1, + height: pass.y >= height ? 0 : Math.floor((height - pass.y - 1) / pass.dy) + 1, + }; +} + +function decodePngToRgba(bytes: Uint8Array): { + readonly width: number; + readonly height: number; + readonly rgba: Uint8Array; +} { + const chunks = parsePngChunks(bytes); + const ihdr = chunks[0]!.data; + if (ihdr.byteLength !== 13) throw new Error("X PNG attachment IHDR was invalid"); + const width = readUint32(ihdr, 0); + const height = readUint32(ihdr, 4); + const bitDepth = ihdr[8] ?? 0; + const colorType = ihdr[9] ?? 0; + const compression = ihdr[10] ?? 1; + const filter = ihdr[11] ?? 1; + const interlace = ihdr[12] ?? 1; + if ( + width < 1 + || height < 1 + || width > MAX_PNG_DIMENSION + || height > MAX_PNG_DIMENSION + || width * height > MAX_PNG_PIXELS + ) { + throw new Error("X PNG attachment dimensions escaped the reviewed bound"); + } + if (compression !== 0 || filter !== 0 || (interlace !== 0 && interlace !== 1)) { + throw new Error("X PNG attachment used an unsupported IHDR method"); + } + if (![0, 2, 3, 4, 6].includes(colorType)) { + throw new Error("X PNG attachment used an unsupported color type"); + } + if ( + (colorType === 3 && ![1, 2, 4, 8].includes(bitDepth)) + || (colorType !== 3 && ![8, 16].includes(bitDepth) && !(colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth))) + ) { + throw new Error("X PNG attachment used an unsupported bit depth"); + } + let palette: Uint8Array | null = null; + let transparency: Uint8Array | null = null; + const idat: Uint8Array[] = []; + for (const chunk of chunks.slice(1)) { + if (chunk.type === "PLTE") palette = chunk.data; + else if (chunk.type === "tRNS") transparency = chunk.data; + else if (chunk.type === "IDAT") idat.push(chunk.data); + } + if (colorType === 3 && palette === null) throw new Error("X PNG attachment omitted PLTE"); + if (idat.length === 0) throw new Error("X PNG attachment omitted IDAT"); + let inflated: Uint8Array; + try { + inflated = new Uint8Array(inflateSync(Buffer.concat(idat.map((part) => Buffer.from(part))))); + } catch { + throw new Error("X PNG attachment IDAT could not be inflated"); + } + if (interlace === 0) { + const raw = unfilter(inflated, width, height, bitDepth, colorType); + return { + width, + height, + rgba: expandToRgba(raw, width, height, bitDepth, colorType, palette, transparency), + }; + } + const rgba = new Uint8Array(width * height * 4); + let offset = 0; + for (const pass of ADAM7) { + const size = passSize(width, height, pass); + if (size.width === 0 || size.height === 0) continue; + const expected = size.height * (scanlineWidth(size.width, bitDepth, colorType) + 1); + const passBytes = inflated.subarray(offset, offset + expected); + if (passBytes.byteLength !== expected) { + throw new Error("X PNG attachment interlacing did not match IHDR"); + } + const raw = unfilter(passBytes, size.width, size.height, bitDepth, colorType); + const passRgba = expandToRgba(raw, size.width, size.height, bitDepth, colorType, palette, transparency); + for (let row = 0; row < size.height; row += 1) { + for (let column = 0; column < size.width; column += 1) { + const source = (row * size.width + column) * 4; + const destX = pass.x + column * pass.dx; + const destY = pass.y + row * pass.dy; + rgba.set(passRgba.subarray(source, source + 4), (destY * width + destX) * 4); + } + } + offset += expected; + } + if (offset !== inflated.byteLength) { + throw new Error("X PNG attachment interlacing left unread IDAT"); + } + return { width, height, rgba }; +} + +export function encodePixelsOnlyPng(input: { + readonly width: number; + readonly height: number; + readonly rgba: Uint8Array; +}): Uint8Array { + if ( + input.width < 1 + || input.height < 1 + || input.width > MAX_PNG_DIMENSION + || input.height > MAX_PNG_DIMENSION + || input.rgba.byteLength !== input.width * input.height * 4 + ) { + throw new Error("X PNG encoder received an invalid pixel buffer"); + } + const ihdr = new Uint8Array(13); + writeUint32(ihdr, 0, input.width); + writeUint32(ihdr, 4, input.height); + ihdr[8] = 8; + ihdr[9] = 6; + const stride = input.width * 4; + const filtered = new Uint8Array(input.height * (stride + 1)); + for (let row = 0; row < input.height; row += 1) { + filtered[row * (stride + 1)] = 0; + filtered.set( + input.rgba.subarray(row * stride, row * stride + stride), + row * (stride + 1) + 1, + ); + } + const idat = new Uint8Array(deflateSync(Buffer.from(filtered), { level: 9 })); + return concatBytes([ + PNG_SIGNATURE, + writePngChunk("IHDR", ihdr), + writePngChunk("IDAT", idat), + writePngChunk("IEND", new Uint8Array()), + ]); +} + +export function embedPngChunk( + bytes: Uint8Array, + type: string, + data: Uint8Array, +): Uint8Array { + if (!/^[A-Za-z]{4}$/u.test(type)) throw new Error("PNG test chunk type must be four letters"); + const chunks = [...parsePngChunks(bytes)]; + const iend = chunks.findIndex((chunk) => chunk.type === "IEND"); + if (iend < 0) throw new Error("PNG fixture omitted IEND"); + chunks.splice(iend, 0, { type, data: new Uint8Array(data) }); + return concatBytes([ + PNG_SIGNATURE, + ...chunks.map((chunk) => writePngChunk(chunk.type, chunk.data)), + ]); +} + +function pngChunkTypes(bytes: Uint8Array): readonly string[] { + return parsePngChunks(bytes).map((chunk) => chunk.type); +} + +export function reencodePixelsOnlyPng(bytes: Uint8Array): Uint8Array { + const decoded = decodePngToRgba(bytes); + return encodePixelsOnlyPng(decoded); +} + +function stripJpegProvenance(bytes: Uint8Array): Uint8Array { + if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + throw new Error("X JPEG attachment is not a complete JPEG"); + } + const kept: number[] = [0xff, 0xd8]; + let offset = 2; + while (offset + 1 < bytes.byteLength) { + if (bytes[offset] !== 0xff) { + throw new Error("X JPEG attachment left its marker structure"); + } + let marker = bytes[offset + 1] ?? 0; + while (marker === 0xff && offset + 2 < bytes.byteLength) { + offset += 1; + marker = bytes[offset + 1] ?? 0; + } + if (marker === 0xd9) { + kept.push(0xff, 0xd9); + break; + } + if (marker === 0xda) { + kept.push(0xff, 0xda); + offset += 2; + const remaining = bytes.subarray(offset); + const eoi = remaining.findIndex((value, index) => + value === 0xff && remaining[index + 1] === 0xd9 && remaining[index - 1] !== 0x00); + if (eoi < 0) throw new Error("X JPEG attachment omitted EOI"); + kept.push(...remaining.subarray(0, eoi), 0xff, 0xd9); + break; + } + if (offset + 4 > bytes.byteLength) throw new Error("X JPEG attachment contained a truncated marker"); + const length = ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0); + if (length < 2 || offset + 2 + length > bytes.byteLength) { + throw new Error("X JPEG attachment contained a truncated marker"); + } + const drop = marker >= 0xe0 && marker <= 0xef || marker === 0xfe; + if (!drop) kept.push(...bytes.subarray(offset, offset + 2 + length)); + offset += 2 + length; + } + if (kept.length < 4 || kept[kept.length - 2] !== 0xff || kept[kept.length - 1] !== 0xd9) { + throw new Error("X JPEG attachment omitted EOI"); + } + return Uint8Array.from(kept); +} + +export function imageBytesContainProvenance(bytes: Uint8Array): boolean { + return IMAGE_PROVENANCE_MARKER.test(Buffer.from(bytes).toString("latin1")); +} + +export function rejectImageProvenanceMarkers(bytes: Uint8Array, label: string): void { + if (imageBytesContainProvenance(bytes)) { + throw new Error(`${label} still contained provenance after pixel-only re-encoding`); + } +} + +export function scrubXUploadImage( + bytes: Uint8Array, + mediaType: "image/png" | "image/jpeg", +): Uint8Array { + const scrubbed = mediaType === "image/png" + ? reencodePixelsOnlyPng(bytes) + : stripJpegProvenance(bytes); + rejectImageProvenanceMarkers(scrubbed, "X upload image"); + if (mediaType === "image/png") { + const types = pngChunkTypes(scrubbed); + if (types.some((type) => type !== "IHDR" && type !== "IDAT" && type !== "IEND")) { + throw new Error("X upload image retained ancillary PNG chunks"); + } + } + return scrubbed; +} + +export function rejectGifProvenanceMarkers(bytes: Uint8Array): void { + if (imageBytesContainProvenance(bytes)) { + throw new Error("X GIF attachment contained provenance markers"); + } +} + +export function minimalPngBytes(): Uint8Array { + return encodePixelsOnlyPng({ + width: 1, + height: 1, + rgba: Uint8Array.of(200, 40, 40, 255), + }); +} diff --git a/src/providers/x-made-with-ai.test.ts b/src/providers/x-made-with-ai.test.ts new file mode 100644 index 0000000..7f79635 --- /dev/null +++ b/src/providers/x-made-with-ai.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import { + rejectXTweetMadeWithAiLabel, + X_UNLABELED_COPY_POLICY_ERROR, + XUnlabeledCopyPolicyError, + xTweetHasMadeWithAiLabel, +} from "./x-made-with-ai"; + +const unlabeled = { + rest_id: "2091626299513041128", + legacy: { + full_text: "I used AI as a tool. This copy is mine.", + user_id_str: "1", + }, + note_tweet: { + note_tweet_results: { + result: { text: "Made with AI appears only in the supplied copy." }, + }, + }, +}; + +describe("X Made with AI tweet label detection", () => { + test("ignores user-authored text that mentions AI", () => { + expect(xTweetHasMadeWithAiLabel(unlabeled)).toBeFalse(); + expect(() => rejectXTweetMadeWithAiLabel(unlabeled)).not.toThrow(); + }); + + test("detects the live-UI disclosure fields used by TweetDetail and CreateTweet", () => { + const labeled = [ + { ...unlabeled, content_disclosure: { label: "Made with AI" } }, + { ...unlabeled, made_with_ai: true }, + { ...unlabeled, ai_generated: true }, + { ...unlabeled, tweet_interstitial: { display_type: "AIGeneratedContent", text: { text: "Made with AI" } } }, + { ...unlabeled, semantic_annotations: [{ name: "trainedAlgorithmicMedia" }] }, + { ...unlabeled, card: { legacy: { name: "ai_generated_disclosure", binding_values: [] } } }, + { ...unlabeled, legacy: { ...unlabeled.legacy, ai_highlight_label: "Made with Grok" } }, + ]; + for (const tweet of labeled) { + expect(xTweetHasMadeWithAiLabel(tweet)).toBeTrue(); + } + }); + + test("throws the unlabeled-copy policy error without deleting", () => { + expect(() => rejectXTweetMadeWithAiLabel( + { made_with_ai: true }, + { id: "2091626299513041128", url: "https://x.com/i/status/2091626299513041128" }, + )).toThrow(XUnlabeledCopyPolicyError); + try { + rejectXTweetMadeWithAiLabel({ content_disclosure: "Made with AI" }, { + id: "1", + url: "https://x.com/i/status/1", + }); + } catch (error) { + expect(error).toBeInstanceOf(XUnlabeledCopyPolicyError); + expect((error as XUnlabeledCopyPolicyError).message).toBe(X_UNLABELED_COPY_POLICY_ERROR); + expect((error as XUnlabeledCopyPolicyError).post).toEqual({ + id: "1", + url: "https://x.com/i/status/1", + }); + } + }); +}); diff --git a/src/providers/x-made-with-ai.ts b/src/providers/x-made-with-ai.ts new file mode 100644 index 0000000..64966c7 --- /dev/null +++ b/src/providers/x-made-with-ai.ts @@ -0,0 +1,106 @@ +/** + * Fail-closed detection of X's Made with AI / sparkle disclosure. + * + * A live label is a terminal unlabeled-copy failure. The post may already + * exist; do not delete it. The run journal forbids `failed` after dispatch + * starts, so x-web reports `indeterminate` with this error. + */ + +export const X_UNLABELED_COPY_POLICY_ERROR = + "X applied Made with AI label; publish failed for unlabeled-copy policy"; + +const DISCLOSURE_KEY = /^(made_with_ai|content_disclosure|ai_generated_disclosure|ai_generated|is_ai_generated|has_ai_generated_media|ai_highlight(?:_label|_info)?|grok_generated|trained_algorithmic_media|digital_source_type)$/iu; +const DISCLOSURE_TEXT = /made with (?:ai|grok)|ai-generated content|trainedalgorithmicmedia|digitalsourcetype/iu; +const AUTHOR_TEXT_KEYS = new Set([ + "full_text", + "description", + "screen_name", + "location", + "username", +]); + +export class XUnlabeledCopyPolicyError extends Error { + readonly post: { readonly id: string; readonly url: string } | undefined; + + constructor(post?: { readonly id: string; readonly url: string }) { + super(X_UNLABELED_COPY_POLICY_ERROR); + this.name = "XUnlabeledCopyPolicyError"; + this.post = post; + } +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function skipAuthorText(key: string, ancestors: readonly string[]): boolean { + if (AUTHOR_TEXT_KEYS.has(key)) return true; + if (key === "name") { + return ancestors.includes("user_results") || ancestors.includes("core") || ancestors.includes("user"); + } + if (key !== "text") return false; + return ancestors.includes("legacy") || ancestors.includes("note_tweet"); +} + +function annotationLooksLikeAi(value: unknown): boolean { + if (typeof value === "string") return DISCLOSURE_TEXT.test(value) || DISCLOSURE_KEY.test(value); + if (!isRecord(value)) return false; + for (const [key, item] of Object.entries(value)) { + if (DISCLOSURE_KEY.test(key)) return true; + if (typeof item === "string" && (DISCLOSURE_TEXT.test(item) || DISCLOSURE_KEY.test(item))) { + return true; + } + } + return false; +} + +function walkTweet( + value: unknown, + ancestors: readonly string[], +): boolean { + if (typeof value === "string") { + const key = ancestors.at(-1) ?? ""; + if (skipAuthorText(key, ancestors.slice(0, -1))) return false; + return DISCLOSURE_TEXT.test(value) || DISCLOSURE_KEY.test(value); + } + if (typeof value === "boolean") { + const key = ancestors.at(-1) ?? ""; + return value === true && DISCLOSURE_KEY.test(key); + } + if (Array.isArray(value)) { + const key = ancestors.at(-1) ?? ""; + if ( + (key === "semantic_annotations" || key === "semantic_annotation_ids") + && value.some(annotationLooksLikeAi) + ) { + return true; + } + return value.some((item) => walkTweet(item, ancestors)); + } + if (!isRecord(value)) return false; + for (const [key, item] of Object.entries(value)) { + if (DISCLOSURE_KEY.test(key)) { + if (item === true) return true; + if (typeof item === "string" && item.length > 0 && item !== "false") return true; + if (isRecord(item) || Array.isArray(item)) { + if (walkTweet(item, [...ancestors, key])) return true; + } + continue; + } + if (walkTweet(item, [...ancestors, key])) return true; + } + return false; +} + +export function xTweetHasMadeWithAiLabel(value: unknown): boolean { + return walkTweet(value, []); +} + +export function rejectXTweetMadeWithAiLabel( + value: unknown, + post?: { readonly id: string; readonly url: string }, +): void { + if (xTweetHasMadeWithAiLabel(value)) { + throw new XUnlabeledCopyPolicyError(post); + } +} diff --git a/src/providers/x-web-runtime.internal.test.ts b/src/providers/x-web-runtime.internal.test.ts index 39c4366..2d4641f 100644 --- a/src/providers/x-web-runtime.internal.test.ts +++ b/src/providers/x-web-runtime.internal.test.ts @@ -14,6 +14,8 @@ import type { BrowserSession } from "../browser"; import { canonicalJson } from "../canonical-json"; import type { WebSessionRecipe } from "../model"; import type { WebSessionDispatchEvent } from "../web-session"; +import { embedPngChunk, encodePixelsOnlyPng, scrubXUploadImage } from "./x-image-provenance"; +import { X_UNLABELED_COPY_POLICY_ERROR } from "./x-made-with-ai"; import { buildXWebRichArticleContentState, executeXWebOperation, @@ -56,6 +58,7 @@ type CapturedRequest = { readonly method: string; readonly headers: Headers; readonly body: string | null; + readonly rawBody: Uint8Array | null; }; function strictCookie(name: string, value: string): StrictCookie { @@ -226,6 +229,7 @@ function createTweetResponse(options: { readonly quote?: string | null; readonly mediaId?: string | null; readonly mediaType?: "photo" | "video"; + readonly extra?: Readonly>; }): unknown { const result = publishedTweetResult(options); return { @@ -246,6 +250,7 @@ function publishedTweetResult(options: { readonly quote?: string | null; readonly mediaId?: string | null; readonly mediaType?: "photo" | "video"; + readonly extra?: Readonly>; }): unknown { const media = options.mediaId === undefined || options.mediaId === null ? {} @@ -266,6 +271,7 @@ function publishedTweetResult(options: { : { quoted_status_id_str: options.quote }), ...media, }, + ...(options.extra ?? {}), }; } @@ -298,6 +304,11 @@ function dependencies( method: init?.method ?? "GET", headers: new Headers(init?.headers), body: typeof body === "string" ? body : null, + rawBody: body instanceof Uint8Array + ? body + : body instanceof ArrayBuffer + ? new Uint8Array(body) + : null, }; calls.push(request); return handler(request); @@ -1985,11 +1996,55 @@ describe("X authenticated internal-API runtime", () => { expect(videoCalls.every((request) => request.method === "GET")).toBeTrue(); }); + test("does not reconcile a labeled X post as present", async () => { + const calls: CapturedRequest[] = []; + const body = "Reconciled labeled X post"; + const identifier = canonicalJson({ postId: CREATED_POST_ID, mediaId: null }); + await expect(readXWebPublishedMutationTarget( + xRecipe("posts.publish", 3), + { body }, + xAuth, + identifier, + { + dependencies: dependencies(calls, (request) => { + if (request.url.href === "https://x.com/home") { + return new Response(homeHtml(), { headers: { "content-type": "text/html" } }); + } + if (request.url.href === MAIN_URL) { + return new Response(mainBundle( + descriptor("Viewer", "u4ni7JqpqdAQxWQfkLsdUQ", "query"), + descriptor("TweetResultByRestId", "4hhGRbehkcUVTKf8n0f0xw", "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + if (request.url.pathname.endsWith("/TweetResultByRestId")) { + return jsonResponse(publishedTweetReadback({ + text: body, + extra: { made_with_ai: true }, + })); + } + throw new Error(`unexpected labeled X reconciliation request ${request.url.href}`); + }), + }, + )).rejects.toThrow(X_UNLABELED_COPY_POLICY_ERROR); + expect(calls.every((request) => request.method === "GET")).toBeTrue(); + }); + test("uploads one plan-bound PNG before CreateTweet and independently binds the returned photo", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-x-publish-")); chmodSync(root, 0o700); const imagePath = join(root, "fixture.png"); - const imageBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const pixels = encodePixelsOnlyPng({ + width: 1, + height: 1, + rgba: Uint8Array.of(12, 34, 56, 255), + }); + const imageBytes = embedPngChunk( + pixels, + "caBX", + Buffer.from("c2pa trainedAlgorithmicMedia digitalSourceType OpenAI", "utf8"), + ); + const scrubbedBytes = scrubXUploadImage(imageBytes, "image/png"); writeFileSync(imagePath, imageBytes, { mode: 0o600 }); const body = "Exact X image post"; const mediaId = "12345"; @@ -2031,7 +2086,7 @@ describe("X authenticated internal-API runtime", () => { expect(request.headers.get("x-csrf-token")).toBe("csrf_token_0123456789abcdef"); const command = request.url.searchParams.get("command"); if (command === "INIT") { - expect(request.url.searchParams.get("total_bytes")).toBe(String(imageBytes.byteLength)); + expect(request.url.searchParams.get("total_bytes")).toBe(String(scrubbedBytes.byteLength)); expect(request.url.searchParams.get("media_type")).toBe("image/png"); expect(request.url.searchParams.get("media_category")).toBe("tweet_image"); return jsonResponse({ @@ -2047,6 +2102,11 @@ describe("X authenticated internal-API runtime", () => { expect(request.headers.get("content-type")).toMatch( /^multipart\/form-data; boundary=wrench-x-media-[a-f0-9]{32}$/u, ); + expect(request.rawBody).not.toBeNull(); + const uploaded = Buffer.from(request.rawBody ?? []); + expect(uploaded.includes(Buffer.from("caBX"))).toBeFalse(); + expect(uploaded.includes(Buffer.from("c2pa"))).toBeFalse(); + expect(uploaded.includes(Buffer.from(scrubbedBytes))).toBeTrue(); return new Response(null, { status: 204 }); } if (command === "FINALIZE") { @@ -2055,7 +2115,7 @@ describe("X authenticated internal-API runtime", () => { media_id: 12345, media_id_string: mediaId, media_key: `3_${mediaId}`, - size: imageBytes.byteLength, + size: scrubbedBytes.byteLength, image: { h: 1, image_type: "image/png", w: 1 }, }); } @@ -2095,6 +2155,113 @@ describe("X authenticated internal-API runtime", () => { } }); + test("rejects x-web AI disclosure inputs before CreateTweet", async () => { + const calls: CapturedRequest[] = []; + await expect(executeXWebOperation( + xRecipe("posts.publish"), + { body: "Exact post", made_with_ai: false }, + xAuth, + { dependencies: dependencies(calls, () => new Response("unused")) }, + )).rejects.toThrow("made_with_ai is outside the reviewed CreateTweet contract"); + await expect(executeXWebOperation( + xRecipe("posts.publish"), + { body: "Exact post", content_disclosure: false }, + xAuth, + { dependencies: dependencies(calls, () => new Response("unused")) }, + )).rejects.toThrow("content_disclosure is outside the reviewed CreateTweet contract"); + await expect(executeXWebOperation( + xRecipe("posts.publish"), + { body: "Exact post", semantic_annotation_ids: ["ai-label"] }, + xAuth, + { dependencies: dependencies(calls, () => new Response("unused")) }, + )).rejects.toThrow("semantic_annotation_ids is outside the reviewed CreateTweet contract"); + expect(calls).toEqual([]); + }); + + test("fails closed when independent TweetResultByRestId readback shows Made with AI", async () => { + const calls: CapturedRequest[] = []; + const body = "Exact unlabeled copy"; + const result = await executeXWebOperation( + xRecipe("posts.publish"), + { body }, + xAuth, + { + dependencies: dependencies(calls, (request) => { + if (request.url.href === "https://x.com/home") { + return new Response(homeHtml(), { headers: { "content-type": "text/html" } }); + } + if (request.url.href === MAIN_URL) { + return new Response(mainBundle( + descriptor("Viewer", "u4ni7JqpqdAQxWQfkLsdUQ", "query"), + descriptor("CreateTweet", "WXTdKnLddrQOunD6MhWi3g", "mutation"), + descriptor("TweetResultByRestId", "4hhGRbehkcUVTKf8n0f0xw", "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + if (request.url.pathname.endsWith("/CreateTweet")) { + return jsonResponse(createTweetResponse({ text: body })); + } + if (request.url.pathname.endsWith("/TweetResultByRestId")) { + return jsonResponse(publishedTweetReadback({ + text: body, + extra: { content_disclosure: { label: "Made with AI" } }, + })); + } + throw new Error(`unexpected labeled X readback request ${request.url.href}`); + }), + }, + ); + expect(result).toMatchObject({ + status: "indeterminate", + dispatchStarted: true, + dispatch: { planned: 1, started: 1, verified: 0 }, + error: X_UNLABELED_COPY_POLICY_ERROR, + output: { posts: [{ id: CREATED_POST_ID, url: `https://x.com/i/status/${CREATED_POST_ID}` }] }, + }); + expect(calls.some((call) => call.url.pathname.endsWith("/CreateTweet"))).toBeTrue(); + expect(calls.some((call) => call.url.pathname.endsWith("/TweetResultByRestId"))).toBeTrue(); + }); + + test("fails closed when CreateTweet itself returns the sparkle disclosure", async () => { + const calls: CapturedRequest[] = []; + const body = "Exact unlabeled copy"; + const result = await executeXWebOperation( + xRecipe("posts.publish"), + { body }, + xAuth, + { + dependencies: dependencies(calls, (request) => { + if (request.url.href === "https://x.com/home") { + return new Response(homeHtml(), { headers: { "content-type": "text/html" } }); + } + if (request.url.href === MAIN_URL) { + return new Response(mainBundle( + descriptor("Viewer", "u4ni7JqpqdAQxWQfkLsdUQ", "query"), + descriptor("CreateTweet", "WXTdKnLddrQOunD6MhWi3g", "mutation"), + descriptor("TweetResultByRestId", "4hhGRbehkcUVTKf8n0f0xw", "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + if (request.url.pathname.endsWith("/CreateTweet")) { + return jsonResponse(createTweetResponse({ + text: body, + extra: { tweet_interstitial: { text: { text: "Made with AI" } } }, + })); + } + throw new Error(`unexpected labeled CreateTweet follow-up ${request.url.href}`); + }), + }, + ); + expect(result).toMatchObject({ + status: "indeterminate", + dispatchStarted: true, + dispatch: { planned: 1, started: 1, verified: 0 }, + error: X_UNLABELED_COPY_POLICY_ERROR, + output: { posts: [{ id: CREATED_POST_ID, url: `https://x.com/i/status/${CREATED_POST_ID}` }] }, + }); + expect(calls.some((call) => call.url.pathname.endsWith("/TweetResultByRestId"))).toBeFalse(); + }); + test("uploads one plan-bound MP4, polls STATUS, then independently binds the returned video", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-x-video-publish-")); chmodSync(root, 0o700); @@ -2339,7 +2506,11 @@ describe("X authenticated internal-API runtime", () => { const root = mkdtempSync(join(tmpdir(), "wrench-x-upload-failure-")); chmodSync(root, 0o700); const imagePath = join(root, "fixture.png"); - writeFileSync(imagePath, new Uint8Array([137, 80, 78, 71]), { mode: 0o600 }); + writeFileSync(imagePath, encodePixelsOnlyPng({ + width: 1, + height: 1, + rgba: Uint8Array.of(1, 2, 3, 255), + }), { mode: 0o600 }); const calls: CapturedRequest[] = []; let admissions = 0; try { diff --git a/src/providers/x-web-runtime.ts b/src/providers/x-web-runtime.ts index 7b191aa..9d0cb85 100644 --- a/src/providers/x-web-runtime.ts +++ b/src/providers/x-web-runtime.ts @@ -39,6 +39,12 @@ import { type WebSessionOperationDeadline, type WebSessionProviderAcceptedMutationTargetEvent, } from "../web-session-execution"; +import { scrubXUploadImage } from "./x-image-provenance"; +import { + rejectXTweetMadeWithAiLabel, + X_UNLABELED_COPY_POLICY_ERROR, + XUnlabeledCopyPolicyError, +} from "./x-made-with-ai"; import { generateXClientTransactionId, type XTransactionBrowserDependencies, @@ -1366,8 +1372,9 @@ async function readBoundXMedia( || after.size !== before.size || bytes.byteLength !== before.size ) throw new Error("X media changed while it was materialized"); + const materialized = new Uint8Array(bytes); return Object.freeze({ - bytes: new Uint8Array(bytes), + bytes: mediaType === "image/png" ? scrubXUploadImage(materialized, "image/png") : materialized, mediaType, }); } finally { @@ -2080,7 +2087,9 @@ function assertTweetBinding( (expectedMediaId === null && mediaIds.length !== 0) || (expectedMediaId !== null && (mediaIds.length !== 1 || mediaIds[0] !== expectedMediaId)) ) throw new Error("X created post response did not bind the confirmed media upload"); - return { id, url: `${X_ORIGIN}/i/status/${id}` }; + const bound = { id, url: `${X_ORIGIN}/i/status/${id}` }; + rejectXTweetMadeWithAiLabel(result, bound); + return bound; } function createdTweet( @@ -2678,6 +2687,11 @@ async function publishOne( } function rejectUnsupportedPostBranches(input: OperationInput): void { + for (const field of ["made_with_ai", "content_disclosure", "ai_generated_disclosure", "semantic_annotation_ids"] as const) { + if (input[field] !== undefined) { + throw new Error(`X posts.publish ${field} is outside the reviewed CreateTweet contract`); + } + } if (input.root_media !== undefined) { throw new Error("X internal root_media upload requires a separately reviewed thread contract"); } @@ -2796,7 +2810,18 @@ async function executePublish( dispatchStarted: started > 0, dispatch: { planned, started, verified }, }; - } catch { + } catch (error) { + if (error instanceof XUnlabeledCopyPolicyError) { + const labeled = error.post === undefined ? posts : [...posts, error.post]; + return { + status: "indeterminate", + output: labeled.length === 0 ? null : { posts: labeled }, + finalUrl: error.post?.url ?? posts.at(-1)?.url ?? null, + dispatchStarted: started > 0, + dispatch: { planned, started, verified }, + error: X_UNLABELED_COPY_POLICY_ERROR, + }; + } const status = started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed"; return { status, @@ -3141,6 +3166,14 @@ export async function executeXWebOperation( readonly dependencies?: XWebRuntimeDependencies; } = {}, ): Promise { + if ( + recipe.action === "posts.publish" + || recipe.action === "threads.publish" + || recipe.action === "replies.create" + || recipe.action === "posts.quote" + ) { + rejectUnsupportedPostBranches(input); + } const bootstrap = await bootstrapX( auth, recipe, diff --git a/src/providers/x.test.ts b/src/providers/x.test.ts index 75e1eef..d44a51b 100644 --- a/src/providers/x.test.ts +++ b/src/providers/x.test.ts @@ -17,6 +17,8 @@ import type { ProviderContract } from "../provider-contract-definitions"; import { xProviderContractDefinitions } from "../provider-contract-definitions-x"; import { ProviderHttpClient, type OAuthTokenAuth, type ProviderFetch } from "../provider-http"; import { executeXProvider } from "./x"; +import { embedPngChunk, encodePixelsOnlyPng, minimalPngBytes, scrubXUploadImage } from "./x-image-provenance"; +import { X_UNLABELED_COPY_POLICY_ERROR } from "./x-made-with-ai"; type XAction = (typeof xProviderContractDefinitions)[number]["operation"]; @@ -1210,7 +1212,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-image-unlabeled-")); try { const path = join(directory, "image.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); for (const madeWithAi of [undefined, false] as const) { const captured = captureFetch([ @@ -1236,11 +1238,53 @@ describe("official X media and failure bounds", () => { } }); + test("scrubs C2PA PNG provenance before official media upload and fails a labeled create", async () => { + const directory = mkdtempSync(join(tmpdir(), "wrench-x-image-scrub-")); + try { + const path = join(directory, "image.png"); + const tainted = embedPngChunk( + encodePixelsOnlyPng({ + width: 1, + height: 1, + rgba: Uint8Array.of(9, 8, 7, 255), + }), + "caBX", + Buffer.from("c2pa trainedAlgorithmicMedia", "utf8"), + ); + const scrubbed = scrubXUploadImage(tainted, "image/png"); + writeFileSync(path, tainted); + const file = fixtureFile(path, "image/png"); + const captured = captureFetch([ + json({ data: { id: "42", username: "me" } }), + json({ data: { id: "961", media_key: "3_961" } }), + json({ data: { id: "962", text: "scrubbed", made_with_ai: true } }, 201), + ]); + const harness = createHarness("posts.publish", { + body: "scrubbed", + media: [{ kind: "file", reference: "test-fixture" }], + }, captured.fetch, { files: { media: [file] }, subject: "42" }); + + await expectRejectedWith(executeXProvider(harness.context), X_UNLABELED_COPY_POLICY_ERROR); + + const multipart = requestMultipartText(captured.requests[1] as RequestCapture); + expect(multipart).not.toContain("caBX"); + expect(multipart).not.toContain("c2pa"); + expect(Buffer.from(multipart, "latin1").includes(Buffer.from(scrubbed))).toBeTrue(); + expect(captured.requests.map((request) => request.url.pathname)).toEqual([ + "/2/users/me", + "/2/media/upload", + "/2/tweets", + ]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + test("uploads static images once, requests media.write, and never leaks local filenames", async () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-image-")); try { const path = join(directory, "private-user-photo.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), @@ -1289,7 +1333,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-image-poll-")); try { const path = join(directory, "image.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), @@ -1321,7 +1365,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-processing-state-")); try { const path = join(directory, "image.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const values: readonly { readonly processingInfo: unknown; readonly message: string }[] = [ { processingInfo: [], message: "invalid processing_info" }, @@ -1350,7 +1394,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-processing-deadline-")); try { const path = join(directory, "image.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), @@ -1375,7 +1419,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-dm-media-")); try { const path = join(directory, "message.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), @@ -1473,7 +1517,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-thread-media-")); try { const path = join(directory, "second.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), @@ -1549,7 +1593,7 @@ describe("official X media and failure bounds", () => { const directory = mkdtempSync(join(tmpdir(), "wrench-x-alt-identity-")); try { const path = join(directory, "image.png"); - writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path, minimalPngBytes()); const file = fixtureFile(path, "image/png"); const captured = captureFetch([ json({ data: { id: "42", username: "me" } }), diff --git a/src/providers/x.ts b/src/providers/x.ts index 94a0f59..b494723 100644 --- a/src/providers/x.ts +++ b/src/providers/x.ts @@ -12,6 +12,8 @@ import { isAbsolute } from "node:path"; import { isXAccountSubject } from "../provider-subject"; import { bearerHeaders, type ProviderResponse } from "../provider-http"; import type { ProviderActionContext, ProviderFile } from "../provider-context"; +import { rejectGifProvenanceMarkers, scrubXUploadImage } from "./x-image-provenance"; +import { rejectXTweetMadeWithAiLabel } from "./x-made-with-ai"; const X_API_ORIGIN = "https://api.x.com"; const X_ALLOWED_HOSTS = ["api.x.com"] as const; @@ -1179,11 +1181,15 @@ async function uploadOneShotImage( const { file } = prepared; const bytes = verifiedProviderRead(file, prepared.preflightIdentity, true).bytes; if (bytes === null) throw new Error("official X image could not be materialized safely"); + if (file.mediaType !== "image/jpeg" && file.mediaType !== "image/png") { + throw new Error("official X one-shot upload supports only JPEG or PNG"); + } + const uploadBytes = scrubXUploadImage(new Uint8Array(bytes), file.mediaType); const multipart = xMultipartPayload([ { name: "media_category", value: category }, { name: "media_type", value: file.mediaType }, ], { - bytes, + bytes: uploadBytes, mediaType: file.mediaType, filename: genericUploadName(file.mediaType), }); @@ -1259,6 +1265,11 @@ async function uploadChunkedMedia( category: string, ): Promise { const { file } = prepared; + if (file.mediaType === "image/gif") { + const bytes = verifiedProviderRead(file, prepared.preflightIdentity, true).bytes; + if (bytes === null) throw new Error("official X GIF could not be materialized safely"); + rejectGifProvenanceMarkers(bytes); + } const opened = openProviderFile(file, prepared.preflightIdentity); let id: string; try { @@ -1459,11 +1470,15 @@ async function executePostsPublish(context: ProviderActionContext): Promise { expect(disclosure).toContain("explicitly authorized `made_with_ai: true`"); expect(disclosure).toContain("outside this workflow"); expect(disclosure).toContain("Do not report success"); + expect(disclosure).toContain("pixels-only"); + expect(disclosure).toContain("caBX"); + expect(disclosure).toContain("locked"); + expect(disclosure).toContain("auto-label"); const crossPost = readSkill("references/cross-posting.md"); expect(crossPost).toContain("x-ai-disclosure.md"); diff --git a/src/web-session-contract-definitions.ts b/src/web-session-contract-definitions.ts index e017603..f27ab99 100644 --- a/src/web-session-contract-definitions.ts +++ b/src/web-session-contract-definitions.ts @@ -601,7 +601,7 @@ const x = { "articles.read": contract("x", "articles.read", "R1", "capture-required", "native article detail requires entitlement-specific reviewed capture"), "articles.draft.save": contract("x", "articles.draft.save", "R2", "observed", "current bounded media INIT/APPEND/FINALIZE plus Article entity create/title/content mutations save one response-bound private rich-text-and-image draft and never call ArticleEntityPublish", 2), "messaging.send": contract("x", "messaging.send", "R3", "capture-required", "DM send requires exact current mutation and target binding"), - "posts.publish": contract("x", "posts.publish", "R3", "observed", "current optional single-PNG or MP4 upload plus strict CreateTweet response, durable accepted-target evidence, and bounded independent TweetResultByRestId readback binding", 4), + "posts.publish": contract("x", "posts.publish", "R3", "observed", "current optional single-PNG or MP4 upload with pixels-only provenance scrub, strict CreateTweet response, durable accepted-target evidence, and fail-closed TweetResultByRestId readback for Made with AI labels", 4), "threads.publish": contract("x", "threads.publish", "R3", "capture-required", "ordered CreateTweet root/self-reply dispatch needs an authorized live fixture and reviewed transaction-header behavior"), "replies.create": contract("x", "replies.create", "R3", "capture-required", "CreateTweet reply needs an authorized live fixture and reviewed transaction-header behavior"), "posts.repost": contract("x", "posts.repost", "R3", "capture-required", "repost desired-state mutation needs an authorized live fixture and reviewed transaction-header behavior"), From 3dd3707f517882bcc9d12fd15460e4ed6fc10e87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:39:05 +0000 Subject: [PATCH 2/5] Accept digest mismatch when GIF bytes change after preflight The GIF provenance scan now rematerializes the file before INIT. A same-second rewrite can match inode identity and still fail the digest check. Both outcomes reject the upload before any media request. Co-authored-by: ben <0thernet@users.noreply.github.com> --- src/providers/x.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/providers/x.test.ts b/src/providers/x.test.ts index d44a51b..571e412 100644 --- a/src/providers/x.test.ts +++ b/src/providers/x.test.ts @@ -1767,7 +1767,11 @@ describe("official X media and failure bounds", () => { }, }; - await expectRejectedWith(executeXProvider(context), "identity changed after its provider preflight"); + const error = await expectRejectedWith(executeXProvider(context), "official X attachment"); + expect( + error.message.includes("identity changed after its provider preflight") + || error.message.includes("no longer matches its confirmed digest"), + ).toBeTrue(); expect(captured.requests.map((request) => request.url.pathname)).toEqual(["/2/users/me"]); } finally { rmSync(directory, { recursive: true, force: true }); From ef318cff0d2a5aef33ead31b22394751dc223089 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:40:13 +0000 Subject: [PATCH 3/5] Update x-web contract semantic identity after unlabeled readback The posts.publish implementation string now includes pixels-only scrub and fail-closed Made with AI readback, so the pinned contract hash moves with that reviewed description. Co-authored-by: ben <0thernet@users.noreply.github.com> --- src/plugins/x-web/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/x-web/plugin.ts b/src/plugins/x-web/plugin.ts index c793334..a82898c 100644 --- a/src/plugins/x-web/plugin.ts +++ b/src/plugins/x-web/plugin.ts @@ -151,7 +151,7 @@ function xArticleDraftV2Dispatches( const currentOperations = webSessionContractOperations( Object.values(webSessionContractDefinitions.x), - "a2f3dc3e766c3ce33033485ec5393070ed3407bff5c729e25eb4d3d12e76379c", + "6a58c1120d9bd3f9db125aa40381699ff8e28ec3a3fae596914ad82ffdc063ae", { "likes.set": [1], }, From f437fceb5c68a1fcf2891d2df7648d790f216c25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 23:56:09 +0000 Subject: [PATCH 4/5] Refresh x writer identities after unlabeled scrub and readback Current x-web and official x closures now include pixels-only image re-encoding and Made with AI label detection, so the durable contract inventory hashes move with those sources. Co-authored-by: ben <0thernet@users.noreply.github.com> --- src/provider-contract-inventory.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/provider-contract-inventory.test.ts b/src/provider-contract-inventory.test.ts index a269276..2b3f15c 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -4,15 +4,15 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; const predecessorDefaultInventorySha256 = - "8b31d4375d89a0421f73b8342b3cfe23a3c642faae6cd999511efc8cf0131de4"; + "8faea4bfd80c8f4a6a6ebdd453a1ce7ac4018f98989b20fc62429c77120ab658"; const predecessorLegacyInventorySha256 = [ - "c3026bbfb57dec86760e27873508c583706500d2a3fa4a025f0d134eb0a65612", - "415bc8e2bed9b36ddf7b0660ac66eb748eeae5bf190c54c61a1bbad70f4ca8cc", - "3d95388ae3382bba19102662385ac3957f32e70330f53e985a221c7a8a2c157e", - "75b8956b70aabdbd3c8bf54212622eda24a1983198792c2f0c964098e47cc9db", - "5866c4d375b8a5f059738eb08601696c99d761554999c255d5e94c134f2070a9", - "338b5807d7804d1dc4ee3c9f9b3fe399d8d281e8c569d54406816d5acfe3420b", - "d608471810222a16a94f9b804bd545eadfdd7aba82049dbd7b584614279b2295", + "3a93d4561bafcf7aff2d11fe4557c910480f64ca7ca1c118f4687df0b60788c3", + "8dfab9b00519b25f51408563f4d3e72fc5be1b5ea6e27eca19550d6ea1f21e41", + "9e638e1bf728130f46d6fd4e5421ffc21f6dc11e864634c15f8a231c4df5e0d0", + "e02128e3f1fd906dc8ef37d370eb60aec316fee00208d3486b0ad6870d58f620", + "c0246bf5ae8df9d632176216ed4a00879233e8b421d2adfc7c5b77902de54f7b", + "74d1f8fbdcb05a000b21aafe5c74462224354b4544322751757e8e597f61c1b7", + "968c3fa6845549731db7df26d31a098d0d1d2df47ae9d86042531191d5f5dfd2", "690d083413be239249da53f38be004a9ffdb2cd31a862a92f749c688a74261b3", "9707875cde9b23a8600d8d86dbc13df634585d458f6071b63a9da961f7620c73", "a4c7ccb37d286c55ff83b17614fbecd31f9f34d921d5487b92624a91af0f7f95", From 52a5cb3d67a44e4ed6de8271289cb94c6c9f05b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 00:24:23 +0000 Subject: [PATCH 5/5] Map mid-read admission rewrites to the live-read discard After live I/O, an optional admission file that changes while it is read is the same fail-closed outcome as an auth-realm mismatch. Remap that helper error so Linux cannot publish the discarded result under a lower-level open failure. Co-authored-by: ben <0thernet@users.noreply.github.com> --- CHANGELOG.md | 2 ++ src/read-client.ts | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b432a..3d61963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Scrub JPEG and PNG provenance before X media upload, and fail closed when CreateTweet or independent TweetResultByRestId readback shows a Made with AI sparkle on user-supplied copy. +- Treat a mid-read optional admission rewrite after live I/O as an auth-changed + discard so the live result cannot be published. ## 0.13.2 - 2026-08-23 diff --git a/src/read-client.ts b/src/read-client.ts index 243753c..829a98c 100644 --- a/src/read-client.ts +++ b/src/read-client.ts @@ -133,6 +133,22 @@ function withInvocationAuthorityAdmission( ); } +function liveReadDiscardedError( + invocation: PreparedInvocation, + cause?: unknown, +): Error { + return new Error( + `auth locator ${invocation.auth.id} changed while the live read was running; its result was discarded`, + cause === undefined ? undefined : { cause }, + ); +} + +function isOptionalAdmissionRewrite(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.message.includes("could not safely open optional read projection admission") + && error.message.includes("state file changed while it was read"); +} + type AuthRealmState = "matches" | "missing" | "changed"; function authRealmState( @@ -323,14 +339,13 @@ export async function revalidatePreparedCapability( ...(options.signal === undefined ? {} : { signal: options.signal }), }, ); - return withInvocationAuthorityAdmission( - invocation, - environment, - () => { + try { + return withInvocationAuthorityAdmission( + invocation, + environment, + () => { if (authRealmState(invocation, query, environment) !== "matches") { - throw new Error( - `auth locator ${invocation.auth.id} changed while the live read was running; its result was discarded`, - ); + throw liveReadDiscardedError(invocation); } if (query === null) { return Object.freeze({ @@ -442,8 +457,14 @@ export async function revalidatePreparedCapability( }), }); }, - { maximumWaitMs: READ_PROJECTION_TRANSITION_SETTLEMENT_WAIT_MS }, - ); + { maximumWaitMs: READ_PROJECTION_TRANSITION_SETTLEMENT_WAIT_MS }, + ); + } catch (error) { + if (isOptionalAdmissionRewrite(error)) { + throw liveReadDiscardedError(invocation, error); + } + throw error; + } } export async function revalidateCapability(