Skip to content

Add singleton GitHub publication command - #14

Merged
raghubetina merged 1 commit into
mainfrom
codex/plan-publish
Aug 1, 2026
Merged

Add singleton GitHub publication command#14
raghubetina merged 1 commit into
mainfrom
codex/plan-publish

Conversation

@raghubetina

Copy link
Copy Markdown
Contributor

Summary

  • add zero-flag firstdraft plan publish bound to exact locally pinned Plan bytes and strong ETag
  • safely create or replay one GitHub Publication singleton, reconcile ambiguous mutation outcomes through one GET, and poll its complete identity and lifecycle projection
  • print only the validated private GitHub repository URL on success and add stable recovery errors
  • cover source and package fixtures, installed-tarball smoke, and agent-facing docs

Provisional server contract

The server route is intentionally not landed yet. This client centralizes the provisional exact { project, compilation, publication } response in one module and test fixture. compilation.head_source_sha256 binds the Publication to the saved Plan digest. The Plan ETag in If-Match is an application precondition on the Project head, not the validator of the absent Publication representation.

This PR does not change plan compile --output, publish npm, touch a live GitHub repository, or claim a live server smoke.

Verification

  • PATH="/Users/sandbox2/.asdf/shims:$PATH" npm run check
  • TypeScript, ESLint, and Prettier passed
  • 130 tests passed
  • package allowlist and installed-tarball smoke passed

Bind singleton publication to the exact saved Plan bytes and strong ETag.

Reconcile ambiguous mutation outcomes without duplicating the request, and
validate the complete publication identity before exposing its repository URL.
@raghubetina

Copy link
Copy Markdown
Contributor Author

Technical review

Cloned and ran it: npm run check exits 0, 130 tests pass. No findings.

The thing I went looking for is the one claim that involves showing a user something a server said.

The printed URL is reconstructed, not inspected

plan publish succeeds by printing a repository URL that came from a response body. There is exactly one write:

stdout.write(`${repository.html_url}\n`);

Which is safe only because repository survived this:

value.html_url === `https://github.com/${value.full_name}`

The URL is not checked for looking correct. It is rebuilt from full_name and required to be equal. And full_name is itself constrained: it must begin with owner.login + "/", and both halves must match

const GITHUB_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;

within byte limits. So the only string that can ever reach the terminal is https://github.com/<name>/<name> over a 65-character alphabet. No alternate host, no credentials in the authority, no carriage return, no ANSI escape, no bidirectional-override character.

That is the correct shape for this, and it is stricter than the new URL()-and-check-the-host approach that most code uses.

The rest of the projection is equally tight. hasExactKeySet rejects unknown fields, value.private !== true fails anything not exactly true rather than merely truthy, owner.type must be "User", and

(value.tree_sha === null) !== (value.commit_sha === null)

rejects a half-populated repository rather than carrying an inconsistent state forward.

Ambiguity is resolved by asking, not by repeating

PublicationRequestOutcomeUnknownError is a separate type from PublicationStartRejectedError, and an ambiguous mutation is reconciled with one GET rather than a retried POST. For a singleton resource that is the right move: repeating the mutation risks a second one, while asking is free and definitive.

samePublication then guards the poll loop against identity drift, comparing project, graph version, head digest, compilation, and analysis run across responses, so a server that changed its mind mid-poll fails rather than being followed.

This is the same three-outcome discipline as firstdraft#279 and #281, which is worth noting because the three landed independently and agree.

The new command did not miss authentication

cli#13 added bearer auth to three commands. A fourth arriving separately is exactly where that gets forgotten:

$ grep -c "authenticatedFetch(fetchFunction, apiToken)" src/cli.js
4

Covered.

What the green suite does and does not show

Worth stating plainly, since the body is already candid about it:

The server route is intentionally not landed yet.

So 130 passing tests demonstrate that the client is internally consistent against a contract this same pull request defines. They do not demonstrate interoperation, because there is nothing on the other end yet. Every fixture is a statement of intent rather than an observation.

Centralizing the provisional shape in one module and one fixture is the right mitigation, since it gives the eventual server exactly one place to disagree with. The thing to avoid is reading this suite later as evidence that publication works end to end. On current form that seems unlikely to happen here, given how carefully #265 and #263 separated harness proof from journey proof.

No findings

@raghubetina

Copy link
Copy Markdown
Contributor Author

Lesson: your terminal is an untrusted output sink

puts response["repository_url"]

One line, prints a URL that came back from an API. It looks like the least dangerous line of code you will write today.

Terminals are not passive. They interpret control characters, and a string arriving from a server can contain them.

Carriage return moves the cursor to the start of the line, so everything after it overwrites what came before:

"https://github.com/you/safe-repo\rhttps://evil.example/                    "

The user sees only the second URL. Scrollback contains both, but nobody scrolls back.

ANSI escape sequences move the cursor anywhere, set colours, and clear regions. \e[2K erases the line. \e[1A goes up one. With a few bytes you can rewrite output your program already printed, including output from a previous command.

Bidirectional overrides reorder glyphs without changing bytes, so github.com/evil can display as something else entirely. This is the same trick behind the Trojan Source paper about source code that reads differently to humans and compilers.

And that is before the user copies the "URL" and pastes it into a shell.

Two ways to defend, and one of them is better

Sanitize: strip or escape the dangerous characters.

puts url.gsub(/[[:cntrl:]]/, "")

This works only as well as your blocklist. Did you get bidi overrides? Zero-width joiners? Whatever gets added to Unicode next?

Reconstruct: never print the string you were given. Build it from validated pieces and require the original to match.

That is what this PR does:

value.html_url === `https://github.com/${value.full_name}`

full_name is separately constrained to /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/ on both halves. So the printable output is drawn from a 65-character alphabet, and there is no need to enumerate what is dangerous, because nothing outside the allowed set can appear.

Blocklists need you to think of every attack. Allowlists need you to think of every legitimate value. The second list is shorter, and you already know it, because you wrote the format.

Where this shows up in Rails

Views are mostly handled for you. ERB escapes by default, and html_safe is the documented way to opt out, which at least makes the risk greppable.

The places that are not handled:

puts "Imported #{row['name']}"          # rake task, CSV from a customer
logger.info("callback: #{params[:state]}")   # log file someone will `cat`
system("open #{url}")                    # shell, a different injection
File.write(path, "# #{user_supplied}\n") # a file someone will read in a terminal

Rake tasks and scripts are where this concentrates, because they print external data to a terminal with no framework in between, and they are usually run by someone with credentials loaded.

The habit

For anything you print that did not originate in your own process, ask what it can contain. If the answer is "arbitrary bytes from a server," the fix is not to clean it up. The fix is to derive what you print from values you have validated, and to treat any mismatch as an error rather than as something to repair.

The compressed version: do not print what they sent you. Print what you can prove, and check that it matches.

@raghubetina
raghubetina merged commit 2d792f2 into main Aug 1, 2026
4 checks passed
@raghubetina
raghubetina deleted the codex/plan-publish branch August 1, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant