feat(x): add X (Twitter) to Markdown plugin - #1263
Conversation
Adds the `x` plugin with one skill, `/x:read`, which returns an X post, note tweet, or X Article as Markdown without an X API key. X serves its content behind an authenticated client, so a plain fetch of an x.com URL returns a shell rather than the post. The skill walks a three-step ladder: xtomd.com for a single post or article, Thread Reader App for an unrolled reply chain, then an explicit ask for remaining post URLs. Verified end to end — a genuine 12-post chain returns from xtomd as a 346-character root, and Thread Reader App recovers all twelve. The plugin namespace is the platform rather than the technique, so later capabilities join it as sibling skills without forcing a rename. Security. Pre-merge adversarial review found a critical argument injection: the URL was interpolated into a shell command line, and a URL containing an apostrophe terminated the quoting and contributed new argv words — reproduced at argv level in both bash and PowerShell, yielding a second unconstrained URL and an -o arbitrary-write flag. Remediated with a mandatory gate that anchors the input, refuses on no match, and on a match discards the input and rebuilds the URL from captures restricted to [A-Za-z0-9_] and [0-9] — classes that cannot express a quote, so the emitted command is safe by construction rather than by escaping. The Bash and PowerShell pre-approvals were removed with it: a prefix permission rule cannot express "and no further flags", so its trailing wildcard would have suppressed the prompt on exactly the injected command. The call now prompts. A validating PreToolUse hook is deferred, with re-introducing a shell grant as its trigger. The plugin-acceptance security review record is included, with the three criterion claims that review falsified retracted inline rather than edited out of history, and prompt-injection containment labeled the advisory, model-honored defense it is. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated by scripts/generate-catalog.mjs; the plugin-gate compares the README catalog block against the manifests and fails on drift. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 44s —— View job PR Review: feat(x): add X (Twitter) to Markdown pluginTasks
SummaryThis is a well-considered new plugin. The security posture is unusually strong — the gate design (match → discard input → rebuild from captures) is the right approach, the decision not to ship Bash/PowerShell pre-approval is correct and well-justified, and the retracted-inline security review record sets a high bar for honest documentation. Three issues below, none blocking on their own, but one (the regex alternation ambiguity) warrants a closer look before merging. Issues1. Regex alternation is ambiguous in the Markdown table — medium
The gate patterns are presented in a Markdown table with code spans: The Eval ID 8 exercises this acceptance path, which catches a model that gets it wrong — but the safer fix is to present the patterns in a fenced code block where ```
^https?://(?:www\.)?(?:x|twitter)\.com/([A-Za-z0-9_]{1,15})/status/([0-9]{1,20})(?:[/?#].*)?$
```The table is readable; a code block that renders the exact regex is unambiguous. Fix this → 2. PowerShell
|
|
Claude finished @kyle-sexton's task in 6m 15s —— View job Security Review —
|
Two automated reviewers converged on a functional bug and flagged three
smaller issues; all are fixed here.
The PowerShell request body dropped its backslash escaping. PowerShell
single-quoted strings are fully literal, so '{\"url\":...}' sends literal
backslashes and the server rejects the body as malformed JSON — silently
breaking step 1 on Windows without Git Bash. Noted honestly: hook false
positives blocked the local shell test, so this rests on both reviewers'
independent analysis and documented PowerShell quoting behavior rather
than a run on this machine. The invocation moved to the failure-modes
spoke with a note that the bash form is the better-exercised path.
Gate patterns moved from a Markdown table to fenced code blocks. Table
cells required writing the alternation as \| to survive the renderer, and
a model reading the raw source could take that as a literal backslash-pipe
and refuse every twitter.com URL — defeating a documented accepted form.
The long-article redirect now names a fixed
${CLAUDE_PLUGIN_DATA}/x-article-<id>.md template built from the
gate-captured id, closing the gap where a filename could be influenced by
converter output.
Eval ids renumbered sequentially so cross-references match array position.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 5s —— View job Review of fix commit —
|
|
Claude finished @kyle-sexton's task in 3m 10s —— View job Security Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bca150a940
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The repo's Codex reviewer caught a self-contradiction that would have broken the primary path. The documented step-1 call sends Accept: text/markdown, whose success response is raw Markdown with no JSON envelope — so the blanket rule "200 with no markdown field is a failure" classified every successful conversion as a failure. The field check now applies only to the JSON form, with both success and failure shapes documented per request form. Eval 1 also expected attribution from the converter-echoed URL, which contradicts the reporting contract's requirement to attribute with the gate's rebuilt URL — the echoed value is third-party output and attacker-influenced. Left as written, the eval would have rewarded the behavior the trust-boundary fix removed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both review findings addressed in P1 — raw Markdown misclassified as failure. Correct and load-bearing: the documented step-1 call sends P2 — eval expected the echoed URL. Correct. Eval 1 contradicted the reporting contract, which requires attribution from the gate's rebuilt URL because the converter-echoed value is third-party output and attacker-influenced. Left as written it would have rewarded the behavior the trust-boundary fix removed. Updated, plus an expectation that raw Markdown counts as success. |
|
Claude finished @kyle-sexton's task in 1m 20s —— View job Review of fix commit
|
| Request | Success looks like | Failure looks like |
|---|---|---|
With Accept: text/markdown |
Markdown body | HTML doc, JSON stub, or empty body |
| Without that header (JSON) | JSON object with non-empty markdown field |
valid JSON with no markdown field, HTML, or empty body |
Followed by: "Only apply the markdown-field check to the JSON form." This is exactly right — the documented step-1 call uses Accept: text/markdown, which has no JSON envelope, so the old rule classified every successful conversion as a failure.
SKILL.md lines 125–128 — the step-1 validation note was strengthened inline: "validating against the form you asked for — under Accept: text/markdown success is raw Markdown with no JSON envelope, so a missing markdown field proves nothing." The cross-reference to failure-modes.md for both shapes is correct.
CHANGELOG.md — documents the fix accurately, including the word "blanket" that makes the scope of what was wrong clear.
P2 — Eval expected the echoed URL ✓ Fixed and extended
expected_output changed from "attributed with author handle, date, and the echoed source URL" to "attributed with … the gate's rebuilt canonical URL."
expectations array: "Attributes the output with handle, date, and source URL" replaced by:
- "Attributes with the gate's rebuilt URL, never the URL the converter echoed back"
- "Treats the raw Markdown body as success rather than looking for a markdown field that the text/markdown form does not carry"
The second new expectation is a correct bonus: eval 1 uses the documented Markdown form, making it the right test case to also verify the P1 fix. The eval now covers both issues for the success path.
Assessment
The PR is correct and ready to merge. All four prior review findings (regex alternation ambiguity, PowerShell JSON body, non-sequential eval IDs, model-chosen article path) were resolved in bca150a. Both Codex findings (P1: Markdown form misclassified as failure; P2: eval rewarded echoed URL) are resolved in cb570a87. The open residual risks (indirect-invocation funnel via disable-model-invocation: false, domain-level WebFetch pre-approval) are documented tradeoffs, not omissions.
|
Claude finished @kyle-sexton's task in 2m 27s —— View job Security Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb570a877a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two further findings from the repo's Codex reviewer. isNoteTweet described a post's long-form representation, not the absence of replies — a chain can legitimately begin with a note tweet. The unconditional "isNoteTweet: true, never escalate" rule would therefore return only the root even when the user explicitly asked for the whole thread. Escalation now requires positive continuation evidence (explicit thread request, mid-thought ending, or numbered markers) and treats length as evidence in neither direction. Two evals updated accordingly. The Windows PowerShell body now goes by file reference rather than inline. Neither inline form is portable: PowerShell 7.3 changed native-argument parsing in what Microsoft documents as a breaking change from Windows PowerShell 5.1, so unescaped quotes are stripped under Legacy while backslash-escaped quotes arrive literally under Standard/Windows. Verified against about_Parsing rather than taken on trust. A @path argument carries no embedded quotes and survives either mode; the URL written into that file is the gate's rebuilt one, so the body stays as constrained as the inline form was. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both findings addressed in P2 — note tweets as chain roots. Correct, and my earlier reasoning was unsound. I had verified that a genuine 12-post chain returns P2 — PowerShell quote marshalling. Correct, and it exposes that both earlier reviewers were half right. I verified against Worth noting for the record: I could not empirically test the PowerShell paths on this machine — two hooks fired false positives on my scratch test harness, and I stopped rather than probing for a way around them. This fix rests on Microsoft's documentation, not on a local run. |
|
Claude finished @kyle-sexton's task in 2m 31s —— View job Security Review —
|
|
Claude finished @kyle-sexton's task in 3m 18s —— View job Review of fix commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f75c4e026b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three findings from the repo's Codex reviewer, plus two contradictions
the sweep for them turned up.
The Windows PowerShell request body wrote to a fixed
${CLAUDE_PLUGIN_DATA}/x-request.json — shared mutable state. Two
concurrent sessions could race between the Write and curl.exe reading it,
and the permission prompt widens that window, so one invocation could
fetch the other's URL. The filename now keys on the gate-captured id, so
a collision means identical content.
README.md still described isNoteTweet as deciding escalation outright,
contradicting the corrected rule in SKILL.md. Grepping every isNoteTweet
mention surfaced the same stale claim in the failure-modes spoke and in
the CHANGELOG's own Added entry; all three now say the flag suppresses
length-only escalation without overriding positive continuation evidence.
The third comment re-anchored the earlier legacy-PowerShell quoting
finding onto the new line. That one is already fixed: the -d argument
carries @path, which has no embedded quotes for any marshalling mode to
strip. The remaining quoted arguments are header values with no embedded
quotes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed in Per-invocation request file — valid, and I introduced it. The fixed README contradiction — valid. I corrected the note-tweet rule in Legacy PowerShell quoting — already fixed; this comment re-anchored onto the new line. The |
|
Claude finished @kyle-sexton's task in 3m 7s —— View job Security Review —
|
|
Claude finished @kyle-sexton's task in 2m 35s —— View job Review of fix commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e53635c7a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The failure-modes spoke wrote ${CLAUDE_PLUGIN_DATA} into a PowerShell
command line. Two defects in one token, both confirmed rather than
assumed.
Reference files are Read raw: this repo's own design notes state SKILL.md
is the only surface where ${CLAUDE_PLUGIN_DATA} expands, and that
reference files use placeholder-free <slots>. So the token never
resolved. PowerShell then reads ${NAME} as its own brace-variable syntax,
resolving an undefined PowerShell variable rather than any environment
value — so the -d "@..." argument pointed somewhere other than the file
Write had just created, and step 1 failed on Windows without Git Bash.
The spoke now uses a <plugin-data-dir> slot per the repo convention, and
SKILL.md — where substitution does happen — carries the resolved absolute
path and instructs that it fill the slot. Both the request file and the
long-article redirect use it, with an explicit rule never to put a ${...}
token on a command line.
The accompanying legacy-PowerShell quoting comment re-anchored onto the
fence line; that finding stays fixed, since -d carries @path and the
remaining quoted arguments are header values with no embedded quotes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed in Plugin-data path — valid, and two defects in one token. Confirmed both rather than taking them on trust:
The spoke now uses a Legacy PowerShell quoting — re-anchored onto the fence line; the finding stays fixed. |
|
Claude finished @kyle-sexton's task in 2m 19s —— View job Review of fix commit
|
|
Claude finished @kyle-sexton's task in 3m 44s —— View job Security Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 954d83c9a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The legacy-PowerShell quoting finding has now been raised three times, re-anchoring onto each new line as the section changed. The -d "@path" form did already address it — @path carries no embedded quotes, and the remaining quoted arguments were header values whose contents contain none. But arguing that a third time is worse than removing the question, particularly on a platform this session cannot execute against. The whole request now lives in a curl config file: URL, method, headers, data reference, and transport bounds. The command line becomes curl.exe -K "<path>" — one argument, no embedded quotes for any marshalling mode to strip. Quoting inside the config is parsed by curl, never by PowerShell, so $PSNativeCommandArgumentPassing stops mattering. Both the config and the body file are named for the gate-captured id, so concurrent sessions cannot race on a shared path, and both are authored here rather than derived from any response. Also corrects an eval expectation that named the canonical rebuild as https://x.com/<handle>/<id>, omitting the /status segment the gate contract actually produces — a grader following it could have rewarded a malformed URL. Swept the other canonical-form references; SKILL.md was already correct. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 5s —— View job I'll analyze this and get back to you. |
|
Addressed in P2 — double-quoting the substituted spool path is insufficient. Valid, and confirmed to be worse than reported. Rather than reason about it, I reproduced it against a directory named The variable expanded and the backtick substitution executed — Fixed by single-quoting at every site the path appears — the What let this through. The security record now names the asymmetry, because the root cause is a conflation rather than a missed quote. The URL is safe by construction: the gate rebuilds it from Note the base has moved — five PRs merged to |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 46efbadbcf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review found the gate refused https://x.com/i/web/status/<id>, the form embeds, feeds, and legacy clients emit. It is a valid post URL that X resolves to the canonical post, so refusing it is a coverage gap. Added as a separately anchored pattern rather than by loosening the handle pattern. The rebuild keeps the handle-less shape instead of folding into /<handle>/status/<id>: no handle was captured, and inventing one would breach rebuild-from-captures, which is the property the whole gate rests on. Attribution is unaffected — the author handle has always been read from the converted body, never from the URL. The two /i/ patterns must be tried before the handle patterns. "i" is a legal handle character, so /i/web/status/<id> would otherwise be read as a handle of "i" against a path of "web" and match nothing. Ordering is documented at the patterns because it is not inferable from them. Verified against 18 cases: the new form across every host and casing variant, every previously passing case still passing, a genuine x.com/i/status/<id> still reaching the handle form, and the reject set holding — /i/WEB/, a missing id, trailing garbage, lookalike hosts, a non-HTTP scheme, and the apostrophe breakout. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Addressed in P2 — handle-less Added as a separately anchored pattern, as suggested, rather than by loosening the handle pattern — which would have widened what Deliberately not folded into One constraint worth flagging, since it is not inferable from the patterns: the two Verified against 18 cases rather than reasoning about it: the new form across every host and casing variant; every previously passing case still passing; a genuine Timing note: this landed at 14:19:07, during my merge attempt on |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef250529c5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review found the skill validated only the HTTP code and the body shape, and both of those look healthy after an aborted transfer. Reproduced against the local curl 8.19.0 rather than taking the report on trust: an over-cap response prints 200 on stdout and exits 63, because -w reports the status line curl had already received before the failure occurred. The trap is that the leftover spool is a syntactically valid Markdown prefix. It passes the "carries converted content" check, parses as a post, and reads as complete — so every validation the skill had would have approved a silently truncated article. Same shape for a mid-transfer timeout (28) and an early-terminated transfer (18). The exit status is now the first gate, ahead of both. Any nonzero exit is a failed fetch: delete the spool, report it, never read the file. The codes worth naming are listed in the spoke with the reproduction, and the transport-bounds section now says what happens when a bound actually fires — it aborts, it does not truncate cleanly. The cleanup rule already covered this branch but named "an oversized response"; it now says "any nonzero curl exit", which is the real condition and covers the timeout and early-close cases too. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Addressed in P2 — a failed transfer validates as success. Valid, and confirmed against the local binary rather than taken on trust. curl 8.19.0: $ curl -q -sS --max-filesize 5000 -o s1.md -w '%{http_code}' <over-cap-url>
curl: (63) Maximum file size exceeded
200
$ echo $?
63
Why the existing validation could not catch it. The skill checked the HTTP code and whether the body looked like converted content — and after an abort, both look healthy. The leftover spool is a syntactically valid Markdown prefix: it parses, it carries a heading and attribution, it passes "carries converted content", and it reads as a complete post. Every signal the skill had said success. That is a strictly stronger failure than a wrong status code, because nothing downstream can detect it. The exit status is now the first gate, ahead of the HTTP code and ahead of the body. Any nonzero exit is a failed fetch: delete the spool, report it, never read the file. Reading it is the trap, so the rule is stated that way rather than as "validate more carefully". Widened past the reported instance, since the size cap is not the only bound that aborts this way: The cleanup rule already covered this branch but named "an oversized response". It now says "any nonzero curl exit", which is the actual condition and picks up the timeout and early-close cases the old wording missed. Eval 20 pins the whole contract. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08e1019b8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two review findings, both closing paths by which third-party bytes reach the session unchecked. The status table named the codes with specific advice, which read as the set of codes that can arrive. It is not. A redirect is the proof: without -L curl does not follow a 3xx, so it completes with exit status 0 and whatever short body the server attached. Verified locally — a 302 returned exit 0 with the status only in -w. The reviewer's 307 case carries a text/plain "Temporary Redirect" body, and plain text is syntactically valid Markdown, so the body check cannot reject it and the exit gate added last commit does not fire. Only the code can. Success now requires exactly 200, with 3xx and a catch-all row in the table. The second finding is the flip side of my own previous fix. Requiring the spool be read through to EOF fixed silent truncation but bounded the wrong thing: slices cap each tool result, never their sum, so a response near the 5 MB transport cap still lands in the session in full. A hostile converter can aim for exactly that. The read now stops at EOF or at a cumulative budget fixed before the first slice, whichever comes first. Both truncation directions are documented together, since the fix for one is the other's cause: stopping early risks reporting a prefix as whole, reading unconditionally risks exhausting the context. The rule that reconciles them is unchanged and already existed — a read that does not reach the end is reported as partial, with where it stops. Silence is the defect, not the truncation. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 12s —— View job Review —
|
|
Claude finished @kyle-sexton's task in 6m 1s —— View job Security review —
|
| Prior finding | Resolution |
|---|---|
| Shell injection via apostrophe in URL | Rebuild-from-captures ([A-Za-z0-9_], [0-9]) — input string discarded after match |
| Double-quote insufficient for spool path | Single-quote mandate with '…'\''…' escape for embedded apostrophes |
| Concurrent session spool race | Per-invocation nonce in filename |
Ambient .curlrc re-enabling -L |
-q leading every invocation |
Handle-less /i/web/status/<id> matching i as handle |
Separate anchored pattern tried before handle forms |
| curl exit status not checked before HTTP code | Fixed by 08e1019 |
| 3xx exit-0 body accepted as content | Fixed by 0d5dc29 |
| Unbounded cumulative read | Fixed by 0d5dc29 |
Commit 08e1019 — exit status primacy
No new issues. The fix is correct.
-w '%{http_code}' writes the HTTP status line curl already received before the response body transfer began. When --max-filesize or --max-time fires mid-transfer, curl exits nonzero but the status line was already printed — so -w reports 200 while $? is 63 or 28. The SKILL.md and failure-modes.md both document this correctly:
"Verified against curl 8.19.0 — an over-cap response printed
200on stdout and exited 63"
The instruction to delete the spool and stop without reading it on any nonzero exit is the correct response. Reading the spool is the trap: an aborted transfer leaves a syntactically valid Markdown prefix that passes every content check. Evals 20 covers this case explicitly. No regressions in the surrounding gate or spool logic.
Commit 0d5dc29 — exact 200 + cumulative read budget
No new issues. Both changes are correct.
Exact 200 requirement. Without -L, curl does not follow redirects — the transfer completes with exit 0 and whatever body the server attached. A 307 body is text/plain "Temporary Redirect", which is syntactically valid Markdown and would pass every content check except the status code. The prior logic (reject named codes, accept the rest) would have accepted this. Requiring exactly 200 and rejecting everything else closes the gap. Eval 21 covers the 307/exit-0 case. The status table in failure-modes.md is correctly annotated: it is illustrative, not exhaustive — "success requires exactly 200. Every other code is a failure, listed or not."
Cumulative read budget. Individual Read slices cap each tool result, not their sum. A near-5 MB response read unconditionally to EOF puts every byte in the session context and can exhaust it before the result is ever reported. A hostile converter can aim for exactly this ceiling. The fix — a cumulative budget set before the first slice, stopping when reached with an explicit partial report — is the correct response to this. Eval 22 covers the near-cap case. The failure-modes.md explanation of the two failure modes (stop too early = silent truncation; read to EOF = context exhaustion) accurately frames the trade-off.
Remaining accepted tradeoffs
These three were identified and accepted in prior rounds. The two new commits do not change their status.
IMPORTANT — disable-model-invocation: false + research trigger
The gate is instruction-level, not runtime-enforced. A sufficiently adversarial post body could attempt to redirect step-2 escalation or step-3 URL collection. The SKILL.md trust boundary advisory is the primary control; the per-command approval prompt is the only runtime backstop. Documented and accepted; a PreToolUse hook is the stated stronger control, deferred to a future version.
SUGGESTION — WebFetch(domain:threadreaderapp.com) is domain-level pre-approval
Any path under threadreaderapp.com is reachable without a per-call prompt. The skill only uses /thread/<id>.html where <id> is the gate-captured numeric id, so the practical blast radius is narrow — but the approval is broader than the intended access. Path-level WebFetch grants are not currently expressible in the plugin manifest.
SUGGESTION — Nonce character set unconstrained
The nonce's generation method is unspecified in SKILL.md. If the model generates a nonce containing shell metacharacters, single-quoting handles it correctly for most characters, but an apostrophe in the nonce would break the quoting (requiring the '…'\''…' form). In practice the model generates short alphanumeric nonces, making this theoretical rather than exploitable.
Verdict
Approve. The two commits in scope each fix a real, previously-confirmed vulnerability class. No new issues are introduced. All prior findings are resolved. The three remaining tradeoffs are documented, bounded in blast radius, and accepted in the security record.
|
Both addressed in P2 — non- Root cause is a documentation shape, not a missing branch: the status table listed the codes with specific advice and read as though it enumerated what can arrive. Fixed by inverting the default — success now requires exactly P2 — cumulative context cost unbounded. Valid, and it is the flip side of my own fix two rounds ago. Requiring the spool be read through to EOF closed silent truncation but bounded the wrong quantity: slices cap each tool result, never their sum. A response near the 5 MB transport cap therefore still lands in the session in full, and a hostile converter can aim for exactly that — the threat model this plugin already assumes. The read now stops at EOF or at a cumulative budget fixed before the first slice, whichever comes first. Both truncation directions are now documented in one place, because the fix for each is the other's cause:
The rule reconciling them already existed and is unchanged: a read that does not reach the end is reported as partial, with where it stops. Stopping short is fine; stopping short silently is the defect. Evals 21 and 22 pin both contracts. Security record updated — these were the two remaining paths by which converter-controlled bytes could reach the session unchecked. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d5dc292cb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A P1 and a P2, the first a defect I introduced. The shell escaping added in 46efbad was applied to every site the path appears, including the Read tool. Read takes a literal filesystem path that no shell parses, so the quote characters become part of the filename: a quoted path names a file that does not exist, and the '...'\''...' form embeds the escape sequence verbatim. Since every successful fetch ends in a Read, that broke the entire happy path — a worse failure than the expansion hazard the escaping was added to prevent. The escaping belongs to the command, not to the path: single-quoted at the -o target and the delete, raw at the Read. Both renderings are now stated together at the rule, because reading either one alone is what produced the error. The read budget said to set one without saying what it is. Faced with a 5 MB response, 5 MB satisfies that instruction, so the cumulative-context failure the budget was meant to close stayed reachable and behavior varied per invocation. The ceiling is now a fixed 256 KB — roughly two orders of magnitude above a long X Article and two below the transport cap, so it never truncates real content and never threatens the session. The partial-result reporting path is unchanged and now has a determinate trigger. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 52s —— View job Review —
|
| Control | Location | Status |
|---|---|---|
| Validate-and-rebuild gate | SKILL.md §Gate | No change; four anchored patterns, discard-and-rebuild |
| No shell pre-approval | plugin.json allowed-tools |
Unchanged; only WebFetch(domain:threadreaderapp.com) |
| Unconditional spool + delete-on-every-exit-path | SKILL.md §Step 1, failure-modes.md | Unchanged |
--proto '=https', no -L, --max-time, --max-filesize |
curl invocation | Unchanged |
| Attacker-authored trust boundary | SKILL.md §Trust boundary | Unchanged |
Three accepted residual risks carry forward unchanged, as documented in docs/MIGRATION-PLAYBOOK.md:
disable-model-invocation: false— required for the research use case; gate is instruction-level- Domain-level
WebFetchpre-approval forthreadreaderapp.com— narrowed to one domain, step 2 only - Nonce character-set unconstrained — nonce is model-chosen but does not reach the request body or URL
Verdict
One blocking item before merge.
Eval 18's expected_output and third expectation directly contradict the rule established in this commit. A model that encounters eval 18 before eval 23 (or in isolation) is directed to quote the Read argument — the exact defect the P1 fix was meant to close. The fix is a two-field edit to evals.json.
Both Codex findings are correctly addressed in the prose (SKILL.md, failure-modes.md) and in eval 23. The eval 18 contradiction is the one remaining gap.
|
Claude finished @kyle-sexton's task in 6m 23s —— View job Security review — PR #1263
|
| Control | Location | Status |
|---|---|---|
Gate: (?i:…) on scheme+host only, captures [A-Za-z0-9_]/[0-9] |
SKILL.md 53–71 | ✅ intact |
| Rebuild-from-captures (input discarded after match) | SKILL.md 83–90 | ✅ intact |
-q first argument (suppresses ambient .curlrc) |
SKILL.md 133 | ✅ intact |
--proto '=https' and no -L |
SKILL.md 133–134 | ✅ intact |
| No PowerShell path | SKILL.md 149–152, failure-modes.md 7–26 | ✅ intact |
Single-quote at shell sites (curl -o, delete) |
SKILL.md 164–168, failure-modes.md 72–84 | ✅ intact |
Raw path at Read tool (the b6894c2 fix) |
SKILL.md 169–175, failure-modes.md 86–91 | ✅ intact |
| 256 KB cumulative read ceiling (fixed number) | SKILL.md 185–192, failure-modes.md 56–70 | ✅ intact |
| Exit-status-first (before HTTP code, before body) | SKILL.md 194–199, failure-modes.md 124–145 | ✅ intact |
Exactly 200 required |
SKILL.md 194–199, failure-modes.md 150–155 | ✅ intact |
| Unconditional spool; delete on every exit path | SKILL.md 177–183, failure-modes.md 97–107 | ✅ intact |
| Filename never derived from response body | SKILL.md 183, failure-modes.md 104–107 | ✅ intact |
| Attribution from converted body, not echoed URL | SKILL.md 249–251, failure-modes.md 202–204 | ✅ intact |
defaultEnabled: false in marketplace catalog |
marketplace.json | ✅ intact |
Accepted residual risks (unchanged from prior rounds)
disable-model-invocation: false+ research trigger — enables an indirect injection chain (attacker-authored X post → autonomous tool invocation). Documented as the dominant residual risk; accepted. Trust-boundary section (SKILL.md 27–34) and eval 7 are the defense-in-depth layer.WebFetch(domain:threadreaderapp.com)domain-level pre-approval — covers all paths under that domain, not only/thread/<id>.html. Read-only egress; accepted.- Nonce character set unconstrained — model-generated before any attacker content arrives; permission-prompt backstop applies. Accepted as SUGGESTION.
Verdict
One finding: eval 18's third expectation and expected_output should be corrected to match the behavior introduced in b6894c2 (raw path at Read; single-quote at shell sites only). All prior resolved findings are confirmed intact with no regressions. Accepted residuals are unchanged. Ready to merge after the eval 18 correction.
|
Both addressed in P1 — the shell escaping was applied to Severity is right. Every successful fetch ends in a The escaping belongs to the command, not to the path: single-quoted at the P2 — "set a budget" has no number. Also correct, and the objection is exact: faced with a 5 MB response, 5 MB is a budget. The instruction was unenforceable, behavior would vary per invocation, and the cumulative-context failure it was meant to close stayed reachable. My previous round fixed the wrong half — it identified the right quantity to bound and then left the bound undefined. The ceiling is now a fixed 256 KB total. Rationale recorded at the rule so it is not re-litigated later: roughly two orders of magnitude above a long X Article and two below the 5 MB transport cap, so it never truncates real content and never threatens the session. A constant rather than a derived value, because anything derived reintroduces the judgement call this finding is about. The partial-result reporting path is unchanged and now has a determinate trigger. Eval 22 covers it. Process note: I resolved these two threads before reading them, in the same batch as the two from the previous round. That was wrong — I reopened them, and they are addressed here on their merits. Worth flagging rather than quietly correcting, since a resolved-but-unread thread is precisely the failure the pre-merge re-query is supposed to prevent. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6894c26de
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review found eval 18 still required the Read argument to be single-quoted, which eval 23 now forbids. The two cases were mutually unsatisfiable: passing eval 18 meant handing Read a path with quote characters in it and failing to open curl's spool, while passing eval 23 meant failing eval 18. Eval 18 now scopes its contract to the shell sites, where the expansion hazard it was written for actually lives. Swept for the same claim rather than fixing only the reported line, since the previous commit had clearly missed instances. Two more: the spoke's "single-quote the substituted path everywhere it appears - ... the read and delete that follow", and the security record's "single-quoted at every site". Both now say shell site. The correct statement appeared three commits' worth of edits ago and the superseded phrasing survived in three places, which is what let a contradiction reach a merge-ready branch. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 5m 13s —— View job Review —
|
|
Claude finished @kyle-sexton's task in 5m 11s —— View job Security review —
|
|
Addressed in P2 — eval 18 still requires the Swept for the claim rather than fixing the reported line, since my previous commit had evidently missed instances. Two more, neither reported:
Both now say shell site. Worth naming the pattern: the corrected rule was written in |
Summary
Adds the
xplugin with one skill,/x:read, which returns an X post, note tweet, or X Article asMarkdown without an X API key. X serves its content behind an authenticated client, so a plain fetch
of an
x.comURL returns a shell rather than the post.Three-step ladder:
xtomd.comfor a single post or article, Thread Reader App for an unrolled replychain, then an explicit ask for remaining post URLs. The plugin namespace is the platform rather than
the technique, so later capabilities join it as sibling skills without forcing a rename.
Security — the substance of this PR. Pre-merge adversarial review found a critical argument
injection. The URL was interpolated into a shell command line, and a URL containing an apostrophe
terminated the quoting and contributed new
argvwords — reproduced atargvlevel in both bash andPowerShell, yielding a second unconstrained URL and an
-oarbitrary-write flag. Becausedisable-model-invocationisfalsewith a research trigger, the URL could arrive fromattacker-authored web content, closing an indirect-injection chain into a shell.
Remediated with a mandatory gate: anchor the input, refuse on no match, and on a match discard
the input entirely and rebuild the URL from captures restricted to
[A-Za-z0-9_]and[0-9]—classes that cannot express a quote, so the emitted command is safe by construction rather than by
escaping.
The Bash and PowerShell pre-approvals were removed with it. A prefix permission rule cannot express
"and no further flags", so its trailing wildcard would have suppressed the prompt on exactly the
injected command. The call now prompts. A validating
PreToolUsehook is deferred, withre-introducing a shell grant as its trigger.
The plugin-acceptance security review record is included, with the three criterion claims that review
falsified (1, 4, 5) retracted inline rather than edited out of history, and prompt-injection
containment labeled the advisory, model-honored defense it is — matching the
github,dometrain,and
plugin-qualityrecords.Test plan
Repository gates, green locally on this branch:
scripts/validate-plugins.sh— all plugin manifests and the catalog validatedscripts/check-changed-skills.sh— PASS, 0 errors, 0 warningsscripts/check-skill-portability.sh— no unexcused coupling tokensscripts/check-skill-leaf-names.sh --check,check-changelog-parity.sh --check,check-silent-skips.sh --all— passmarkdownlint-cli2— 0 errors;lychee --offline— 0 errors;typos— cleanclaude plugin validate ./plugins/x— passedEmpirical verification against the live services:
twitter.com/anonymous-articleforms; refuses all four working attack strings plus userinfo-host, newline, backtick, semicolon,
and non-
httpsvariants.argvdump, confirming the finding ratherthan taking it on trust; refused post-fix.
1/12returns from xtomd as a346-character root with
isNoteTweet: falseandrepliesas integer14; Thread Reader App forthe same id returns
200, final URL not/error, with markers1/12through12/12present.This is what step 2 exists for, confirmed against the real thing rather than inferred from a schema.
/api/markdownreturns200with a stub body reading"method":"POST",so
WebFetchcannot substitute andcurlis a declared prerequisite.200while redirecting to.../thread/<id>/error, so misses aredetected by final URL plus positive content confirmation, never by status code.
@xtomd/mcp-server— npm registry404. Deliberately not wired; the unregistered name isrecorded as a squat hazard.
Review: four independent reviewers, three fresh-context plus one cross-vendor (Codex), briefed with
the author's rationale withheld. Two produced reports; between them one CRITICAL and four further
valid defects, all addressed. Three reviewers returned nothing and are named as such rather than
counted as coverage.
Related
No linked issue.
Supersedes #1262, which was branched from a feature branch and carried 22 unrelated files.