Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 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.
- 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

- Publish exact Threads text through `posts.publish@5` without requiring a
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions scripts/package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ async function verifyPackagedSkill(consumer: string): Promise<void> {
"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(
Expand Down
2 changes: 1 addition & 1 deletion skills/wrench/references/cross-posting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion skills/wrench/references/platform-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion skills/wrench/references/x-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 26 additions & 6 deletions skills/wrench/references/x-ai-disclosure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -28,15 +29,28 @@ body.
Keep R3 preview and confirm unchanged. Review the digest, then run the printed
`wrench confirm <digest>`.

## 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
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.

Expand All @@ -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.
2 changes: 1 addition & 1 deletion src/assets/adapters/x/wrench-web-adapter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/assets/adapters/x/wrench-web-adapter.v1.10.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/x-web/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function xArticleDraftV2Dispatches(

const currentOperations = webSessionContractOperations(
Object.values(webSessionContractDefinitions.x),
"a2f3dc3e766c3ce33033485ec5393070ed3407bff5c729e25eb4d3d12e76379c",
"6a58c1120d9bd3f9db125aa40381699ff8e28ec3a3fae596914ad82ffdc063ae",
{
"likes.set": [1],
},
Expand Down
16 changes: 8 additions & 8 deletions src/provider-contract-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions src/providers/x-image-provenance.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading