diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index f359393..1f8a0cb 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -101,6 +101,23 @@ async function verifyPackagedSkill(consumer: string): Promise { if (!references.includes("references/install.md")) { throw new Error("Packed Wrench skill must route missing-CLI work to references/install.md."); } + if (!references.includes("references/x-ai-disclosure.md")) { + throw new Error("Packed Wrench skill must route X AI disclosure to references/x-ai-disclosure.md."); + } + const disclosure = await readFile(join(skillRoot, "references", "x-ai-disclosure.md"), "utf8"); + for (const required of [ + "Made with AI", + "Content disclosure", + "live permalink", + "the publish failed", + "Do not delete or repost unless the user asks", + ] as const) { + if (!disclosure.includes(required)) { + throw new Error( + `Packed Wrench skill must fail closed on X AI disclosure; missing ${JSON.stringify(required)}.`, + ); + } + } for (const reference of references) await access(join(skillRoot, reference)); await verifyLocalMarkdownLinks(skillRoot); diff --git a/skills/wrench/SKILL.md b/skills/wrench/SKILL.md index b2e0231..4e70748 100644 --- a/skills/wrench/SKILL.md +++ b/skills/wrench/SKILL.md @@ -44,6 +44,7 @@ automation. - Save one private native article draft, including supported plan-bound covers, inline images, and destination-safe source-post references: inspect `articles.draft.save`, then follow [native article drafts](references/article-drafts.md). Keep a provider cover outside the body document; on an exact LinkedIn replacement, omit it only to preserve the independently read existing banner. Never substitute `articles.publish`. - Cross-post one exact text and optional ordered-image package: inspect every installed target schema, then follow [social cross-posting](references/cross-posting.md). - Cross-post one exact video package: require an observed video-capable operation for every selected target, then follow [video social cross-posting](references/cross-posting-video.md). +- When a cross-post package uses user-supplied copy, never mark it as AI-generated. Follow [X AI disclosure](references/x-ai-disclosure.md): leave official `x` `made_with_ai` unset or `false`, prefer a Wrench transport over the X composer, and treat a live sparkle Made with AI label as a failed publish. An explicitly authorized AI-media label outside that workflow remains a separate provider input choice. - Add a provider without changing Wrench source: author a portable plugin. - Derive a reviewed first-party contract from authorized HAR evidence: follow [the derivation guide](references/derivation.md). diff --git a/skills/wrench/references/cross-posting-video.md b/skills/wrench/references/cross-posting-video.md index dedf63e..4ff3d62 100644 --- a/skills/wrench/references/cross-posting-video.md +++ b/skills/wrench/references/cross-posting-video.md @@ -38,7 +38,8 @@ Encoding may use a local encoder such as ffmpeg. Posting may not. - If the user provided a caption, use it exactly. Do not rewrite it to pass a provider limit; ask for a shorter shared caption or an explicit - platform-specific variant. + platform-specific variant. Never mark that user-supplied caption as + AI-generated; follow [X AI disclosure](x-ai-disclosure.md). - If no caption was provided, write one short caption from the video's actual subject: about one sentence, under 120 characters. Do not invent a long marketing paragraph. diff --git a/skills/wrench/references/cross-posting.md b/skills/wrench/references/cross-posting.md index f61a22c..a93f047 100644 --- a/skills/wrench/references/cross-posting.md +++ b/skills/wrench/references/cross-posting.md @@ -17,6 +17,15 @@ If alt text is absent, draft a short factual description from visible image cont Give the package a task-local identity. Preserve image order through planning, settlement, duplicate accounting, and any cleanup assessment. +## Keep user-supplied copy unlabeled + +When the user supplied the cross-post copy, never mark that package as AI-generated. This rule applies to X and every other platform in this workflow. On X, follow [X AI disclosure](x-ai-disclosure.md). + +- 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. + ## Preflight every target 1. Run `wrench --help`; if it is unavailable, follow [installation and diagnostics](install.md). diff --git a/skills/wrench/references/social-platform-routing.md b/skills/wrench/references/social-platform-routing.md index 4d8da38..d013bc8 100644 --- a/skills/wrench/references/social-platform-routing.md +++ b/skills/wrench/references/social-platform-routing.md @@ -5,7 +5,7 @@ Use this as routing guidance only. Always inspect `wrench capabilities | Surface | Candidate adapter | Signed-in realm | Reference post shape | Meaning | | --- | --- | --- | --- | --- | | X | `x-web` | Browser cookies/profile | `body`; optional image fields when exposed | Consumer X post | -| X | `x` | Official OAuth | `body`, optional `media` and aligned alt-text fields | Documented API post | +| X | `x` | Official OAuth | `body`, optional `media` and aligned alt-text fields; leave `made_with_ai` unset or `false` for user-supplied cross-post copy | Documented API post | | LinkedIn | `linkedin-web` | Browser cookies/profile | `body`, `visibility`, optional image and accessibility fields | Member or explicitly bound organization post | | LinkedIn | `linkedin` | Official OAuth | Inspect installed schema | Documented API post | | Bluesky | `bluesky-web` | Profile-backed Bluesky web session | `body`, optional image, media type, and alt text | AT Protocol feed post | @@ -29,6 +29,8 @@ unless their installed canonical capability independently says otherwise. - Preserve input image order. Require alt-text arrays to align one-to-one when the schema exposes them. - Re-check capabilities before every new preview. A plan or run remains governed by its bound contract identity when installed support later changes. - A provider may reject an otherwise valid image for dimensions, animation, color profile, or account entitlement. Report that provider-owned failure without converting the file unless the user asks for a derivative. +- For user-supplied cross-post copy, never mark the package as AI-generated. Follow [X AI disclosure](x-ai-disclosure.md). Leave official `x` `made_with_ai` unset or `false`. `x-web` `posts.publish` has no such input field. +- Prefer a Wrench transport over the X composer. If the user explicitly requested a composer fallback, require Content disclosure Made with AI OFF before Post, then open the live permalink. A sparkle label means that target failed. ## Common invocation shape diff --git a/skills/wrench/references/social-video-platform-routing.md b/skills/wrench/references/social-video-platform-routing.md index 12204df..237b532 100644 --- a/skills/wrench/references/social-video-platform-routing.md +++ b/skills/wrench/references/social-video-platform-routing.md @@ -49,6 +49,10 @@ Treat them as unavailable until the installed capability independently says - Tags are an explicit platform-specific variant only for TikTok, Instagram, and YouTube Shorts. Leave X, LinkedIn, Substack, Bluesky, Threads, and Reddit tag-free. +- For a user-supplied cross-post caption, never mark the video as AI-generated. + Follow [X AI disclosure](x-ai-disclosure.md). Official `x` `posts.publish` + exposes `made_with_ai`; leave it unset or `false` for this workflow. Do not + invent a disclosure field on another adapter. ## Common invocation shape diff --git a/skills/wrench/references/x-adapter.md b/skills/wrench/references/x-adapter.md index c591488..d2181f7 100644 --- a/skills/wrench/references/x-adapter.md +++ b/skills/wrench/references/x-adapter.md @@ -177,13 +177,20 @@ Current `x-client-transaction-id` generation is code-owned: wrench resolves the `likes.set` and `content.save` (`R2`) bind the exact account and post, select only the matching create/delete mutation for the confirmed desired state, validate the operation-specific `Done` response, and independently read the same post through TweetResultByRestId before marking the dispatch verified. Separate reversible live fixtures proved bookmark false → true → false and like false → true → false, including both independent reads and restoration of the original false state. `articles.draft.save` is the separate observed private structured-text-and-inline-image contract above. -`posts.publish@3` is the separate observed R3 post contract. It accepts exact -text and at most one plan-bound PNG, binds the account and uploaded media ID, +`posts.publish@4` is the separate observed R3 post contract. It accepts exact +text and at most one plan-bound PNG or MP4, binds the account and uploaded media ID, admits one CreateTweet dispatch, durably retains the response-bound post/media target before readback, and polls only that exact post through TweetResultByRestId. Threads, replies, reposts, quotes, DMs, and Article publishing remain capture-required. +CreateTweet sends empty `semantic_annotation_ids` and no AI or +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). + 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. Treat repost, like, and bookmark as desired state only when both create and delete mutations are reviewed and response-bound. A state mismatch is not permission to issue another mutation blindly. diff --git a/skills/wrench/references/x-ai-disclosure.md b/skills/wrench/references/x-ai-disclosure.md new file mode 100644 index 0000000..bae9c12 --- /dev/null +++ b/skills/wrench/references/x-ai-disclosure.md @@ -0,0 +1,51 @@ +# Keep user-supplied X cross-posts unlabeled + +Wrench's cross-post workflow must not label user-supplied X copy as Made with +AI or Made with Grok. A live sparkle label means the publish failed, even if X +accepted the post. + +The rule applies only to the user-supplied cross-post package. The official +provider input still accepts an explicitly authorized `made_with_ai: true` for +attached media outside this workflow. + +## Prefer a Wrench transport + +Use an installed Wrench `posts.publish` transport. Do not open the X composer +to compensate for a missing, `capture-required`, or failed contract. + +- Official `x` `posts.publish` exposes optional `made_with_ai`. Leave it unset + or `false` for user-supplied cross-post copy. Set `true` only as a separate, + 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. +- Inspect every other installed schema for a comparable flag and leave it unset + or `false` for the user-supplied package. + +Do not add "Made with AI", "Made with Grok", or similar disclosure text to the +body. + +Keep R3 preview and confirm unchanged. Review the digest, then run the printed +`wrench confirm `. + +## 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. +4. Close the dialog and confirm the composer no longer shows a Made with AI + disclosure before clicking Post. + +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. + +A labeled post is a failed publish. Leave it in place unless the user asks for +cleanup through an installed `content.delete` capability. diff --git a/src/providers/x-web-runtime.ts b/src/providers/x-web-runtime.ts index 5765ede..7b191aa 100644 --- a/src/providers/x-web-runtime.ts +++ b/src/providers/x-web-runtime.ts @@ -2113,6 +2113,9 @@ function createTweetVariables( quote: string | null, mediaId: string | null, ): Readonly> { + // The reviewed CreateTweet contract has no made_with_ai or content_disclosure + // input. Keep semantic_annotation_ids empty so the Wrench-owned transport + // cannot inherit a composer AI annotation. return { tweet_text: text, dark_request: false, diff --git a/src/providers/x-web.test.ts b/src/providers/x-web.test.ts index 81fa96f..98801c0 100644 --- a/src/providers/x-web.test.ts +++ b/src/providers/x-web.test.ts @@ -558,6 +558,43 @@ describe("strict GraphQL operation/path/query-ID binding", () => { })).toThrow("text-only"); }); + test("rejects CreateTweet AI or content-disclosure fields and nonempty annotations", () => { + const resolved = descriptor("CreateTweet"); + const features = Object.fromEntries(resolved.metadata.featureSwitches.map((name) => [name, false])); + const fieldToggles = Object.fromEntries(resolved.metadata.fieldToggles.map((name) => [name, false])); + const body = (variables: Readonly>) => ({ + variables: { + tweet_text: "Exact post", + dark_request: false, + media: { media_entities: [], possibly_sensitive: false }, + semantic_annotation_ids: [], + ...variables, + }, + features, + ...(resolved.metadata.fieldToggles.length === 0 ? {} : { fieldToggles }), + queryId: resolved.queryId, + }); + const request = (variables: Readonly>) => + authorizeXWebMutationRequest("posts.publish", { + method: "POST", + url: graphqlUrl(resolved), + descriptor: resolved, + body: body(variables), + }); + expect(() => request({ made_with_ai: false })).toThrow( + "made_with_ai is outside the reviewed CreateTweet contract", + ); + expect(() => request({ content_disclosure: false })).toThrow( + "content_disclosure is outside the reviewed CreateTweet contract", + ); + expect(() => request({ ai_generated_disclosure: false })).toThrow( + "ai_generated_disclosure is outside the reviewed CreateTweet contract", + ); + expect(() => request({ semantic_annotation_ids: ["ai-label"] })).toThrow( + "semantic annotations are outside the reviewed contract", + ); + }); + test("authorizes exact native Article links, styles, and inline MEDIA entities", () => { const contentState = { blocks: [ diff --git a/src/providers/x-web.ts b/src/providers/x-web.ts index 913d6b7..bb3d07a 100644 --- a/src/providers/x-web.ts +++ b/src/providers/x-web.ts @@ -658,7 +658,27 @@ function exactMutationText(value: unknown): void { } } +const CREATE_TWEET_AI_DISCLOSURE_FIELDS = [ + "made_with_ai", + "content_disclosure", + "ai_generated_disclosure", +] as const; + +function rejectCreateTweetAiDisclosureFields( + variables: JsonRecord, + operationId: XWebMutationOperationId, +): void { + for (const field of CREATE_TWEET_AI_DISCLOSURE_FIELDS) { + if (Object.hasOwn(variables, field)) { + throw new Error( + `X ${operationId} ${field} is outside the reviewed CreateTweet contract`, + ); + } + } +} + function validateCreateTweetVariables(operationId: XWebMutationOperationId, variables: JsonRecord): void { + rejectCreateTweetAiDisclosureFields(variables, operationId); const relationKey = operationId === "replies.create" || operationId === "threads.reply" ? "reply" : operationId === "posts.quote" ? "attachment_url" : null; diff --git a/src/providers/x.test.ts b/src/providers/x.test.ts index 02de4ee..75e1eef 100644 --- a/src/providers/x.test.ts +++ b/src/providers/x.test.ts @@ -820,6 +820,21 @@ describe("official X writes", () => { expect(harness.dispatches()).toBe(0); }); + test("omits made_with_ai unless the caller explicitly sets true", async () => { + for (const input of [ + { body: "Unlabeled by default" }, + { body: "Explicitly unlabeled", made_with_ai: false }, + ] as const) { + const captured = captureFetch([ + json({ data: { id: "42", username: "me" } }), + json({ data: { id: "404", text: input.body } }, 201), + ]); + const harness = createHarness("posts.publish", input, captured.fetch, { subject: "42" }); + await executeXProvider(harness.context); + expect(requestJson(captured.requests[1] as RequestCapture)).toEqual({ text: input.body }); + } + }); + test("requires both automated-reply attestations before dispatch", async () => { const captured = captureFetch([]); const harness = createHarness("replies.create", { @@ -1191,6 +1206,36 @@ describe("official X writes", () => { }); describe("official X media and failure bounds", () => { + test("omits made_with_ai on reviewed media unless the caller explicitly sets true", async () => { + const directory = mkdtempSync(join(tmpdir(), "wrench-x-image-unlabeled-")); + try { + const path = join(directory, "image.png"); + writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const file = fixtureFile(path, "image/png"); + for (const madeWithAi of [undefined, false] as const) { + const captured = captureFetch([ + json({ data: { id: "42", username: "me" } }), + json({ data: { id: "911", media_key: "3_911" } }), + json({ data: { id: "912", text: "unlabeled image" } }, 201), + ]); + const harness = createHarness("posts.publish", { + body: "unlabeled image", + media: [{ kind: "file", reference: "test-fixture" }], + ...(madeWithAi === undefined ? {} : { made_with_ai: madeWithAi }), + }, captured.fetch, { files: { media: [file] }, subject: "42" }); + + await executeXProvider(harness.context); + + expect(requestJson(captured.requests[2] as RequestCapture)).toEqual({ + text: "unlabeled image", + media: { media_ids: ["911"] }, + }); + } + } 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 { diff --git a/src/providers/x.ts b/src/providers/x.ts index b9bf2ca..94a0f59 100644 --- a/src/providers/x.ts +++ b/src/providers/x.ts @@ -260,6 +260,11 @@ function optionalInputBoolean(context: ProviderActionContext, name: string): boo return value; } +/** Preserve the provider's explicitly authorized true value and omit false or unset. */ +function officialMadeWithAiLabel(madeWithAi: boolean | undefined): true | undefined { + return madeWithAi === true ? true : undefined; +} + function inputStrings(context: ProviderActionContext, name: string): readonly string[] { const value = context.input[name]; if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { @@ -1443,7 +1448,7 @@ async function executePostsPublish(context: ProviderActionContext): Promise { + test("routes user-supplied cross-post copy through a fail-closed unlabeled rule", () => { + const skill = readSkill("SKILL.md"); + expect(skill).toContain("references/x-ai-disclosure.md"); + expect(skill).toContain("made_with_ai"); + expect(skill).toContain("sparkle Made with AI"); + + const disclosure = readSkill("references/x-ai-disclosure.md"); + expect(disclosure).toContain("Made with AI"); + expect(disclosure).toContain("Made with Grok"); + expect(disclosure).toContain("Content disclosure"); + expect(disclosure).toContain("live permalink"); + expect(disclosure).toContain("the publish failed"); + expect(disclosure).toContain("Do not delete or repost unless the user asks"); + expect(disclosure).toContain("Prefer a Wrench transport"); + expect(disclosure).toContain("semantic_annotation_ids"); + expect(disclosure).toContain("explicitly authorized `made_with_ai: true`"); + expect(disclosure).toContain("outside this workflow"); + expect(disclosure).toContain("Do not report success"); + + const crossPost = readSkill("references/cross-posting.md"); + expect(crossPost).toContain("x-ai-disclosure.md"); + expect(crossPost).toContain("sparkle Made with AI"); + expect(crossPost).toContain("Do not click the X composer"); + }); +});