Skip to content
Merged
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
17 changes: 17 additions & 0 deletions scripts/package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ async function verifyPackagedSkill(consumer: string): Promise<void> {
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);

Expand Down
1 change: 1 addition & 0 deletions skills/wrench/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 2 additions & 1 deletion skills/wrench/references/cross-posting-video.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions skills/wrench/references/cross-posting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion skills/wrench/references/social-platform-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Use this as routing guidance only. Always inspect `wrench capabilities <adapter>
| 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 |
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions skills/wrench/references/social-video-platform-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions skills/wrench/references/x-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions skills/wrench/references/x-ai-disclosure.md
Original file line number Diff line number Diff line change
@@ -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 <digest>`.

## 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.
3 changes: 3 additions & 0 deletions src/providers/x-web-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2113,6 +2113,9 @@ function createTweetVariables(
quote: string | null,
mediaId: string | null,
): Readonly<Record<string, unknown>> {
// 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,
Expand Down
37 changes: 37 additions & 0 deletions src/providers/x-web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>) => ({
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<Record<string, unknown>>) =>
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: [
Expand Down
20 changes: 20 additions & 0 deletions src/providers/x-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/providers/x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion src/providers/x.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -1443,7 +1448,7 @@ async function executePostsPublish(context: ProviderActionContext): Promise<void
}
if (replySettings !== undefined && replySettings !== "everyone") payload.reply_settings = replySettings;
if (communityId !== undefined) payload.community_id = communityId;
if (madeWithAi === true) {
if (officialMadeWithAiLabel(madeWithAi) === true) {
payload.made_with_ai = true;
}
const response = await request(
Expand Down
36 changes: 36 additions & 0 deletions src/skill-x-ai-disclosure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const skillRoot = join(import.meta.dir, "..", "skills", "wrench");

function readSkill(relativePath: string): string {
return readFileSync(join(skillRoot, relativePath), "utf8");
}

describe("packaged Wrench skill X AI disclosure", () => {
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");
});
});