feat(gh): commit and tag through the API, with no credential on disk - #313
Conversation
CI that commits usually checks out with `persist-credentials`, commits, and pushes. That writes the token into `.git/config`, where it outlives the step that needed it: every later step in the job can read it, and anything archiving the workspace carries it out. Blocking a job's egress does not answer this — the token is a GitHub credential and GitHub is necessarily reachable, so an allowlist bounds where it could be sent, not what it could do. Git's data API takes file contents inline, so a commit can be built server-side and a ref pointed at it. The token becomes a request header and nothing more. Three operations, because they are three different intents: commit onto an existing branch, create a branch and commit onto that, and tag a commit. The ref update is deliberately unforced for the first — a commit landing between reading the head and writing it is rejected rather than overwritten — and deliberately forced for a moving major tag, which is a non-fast-forward by definition. This does not claim the token is unreadable by the step that uses it. Nothing short of not having a token achieves that; what is removed is the credential's persistence beyond its use. Two supporting changes. `HttpOptions` gains `method` and `body`, since the helpers were GET-only. And `HttpError` now carries the start of the response body: a status alone rarely says which field or which permission was the problem, and this runs unattended, so the log is all anyone will have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 Zuke AI review 🔎 security review —
|
|
🤖 Zuke AI review 🔎 generic review —
|
| Severity | Finding | Location |
|---|---|---|
| low | Use of forbidden type assertions (as) in source code |
packages/gh/src/commit.ts:492 |
| low | Use of forbidden type assertions (as) in test file |
packages/gh/tests/commit_test.ts:36 |
| low | Public API exposes loose functions instead of namespaced tasks | packages/gh/mod.ts:21 |
Dismiss a false positive
Add a finding's ID to the suppress list to hide it next time:
9d0nf96jpjg9— Use of forbidden type assertions (as) in source code2qs2iar4nh1m7— Use of forbidden type assertions (as) in test file2dbg8h4gjshrg— Public API exposes loose functions instead of namespaced tasks
The changes violate strict project guidelines by using type assertions (as) and exposing loose functions instead of namespaced tasks in the public API.
Addresses AI review finding `2y2yj4phucoli`, raised against the copy of this code in #312 and fixed here, where it belongs. The finding asked what happens if the branch name is not validated. In that repository's own call path it always is — the value is machine-generated from a parsed version and a test asserts the validator rejects junk. But this is a published package, and a library that is safe only when every caller validates is not safe. It is worse than a malformed name reaching the API. These names go into request paths, and URL normalisation resolves `..` before the request is sent: /repos/o/n/git/ref/heads/../../../user/repos -> /repos/o/n/user/repos So a branch name could silently redirect a call to a different endpoint with a write-scoped token attached. Verified before fixing, and the test asserts the normalisation as well as the refusal, so the reason survives. Every entry point now checks what it is handed, by git's own rules, and refuses before anything is sent — a request that goes out and fails has already carried the token somewhere unintended. The test asserts zero calls were made. The control-character part is a codepoint check rather than a regex: spelling those out trips `no-control-regex`, and suppressing that rule to keep a check git itself makes would be the wrong way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moved from `@zuke/core`, where I put it first and should not have. The reasoning I gave for core was that `@zuke/ai` has no HTTP and would need a new dependency to reach it. That was simply wrong: `@zuke/ai` has had a `src/hosts/` directory of GitHub, GitLab, Azure and Bitbucket API clients all along, because the reviewer posts comments to whichever host it runs on. With that premise gone, the placement argument is one-sided. `@zuke/gh` is the GitHub package and already makes direct REST calls with a `fetch` seam — `uploadSarif` is the same shape as this. Core is the build engine: targets, graph, shell, CI generation. GitHub endpoints are not that. The move also fixes a guideline the core version broke. A package exposes its operations through a namespaced `*Tasks` object, configured by a settings lambda — not loose exported functions taking an options bag. So this is `GhTasks.commit((s) => s.branch(...).message(...).file(...))` and `GhTasks.tag((s) => s.name(...).move())`, with `owner/repo` and the token falling back to the Actions environment the way `uploadSarif` already does. What stays in core is what belongs there: `HttpOptions` gaining `method` and `body`, since the helpers were GET-only, and `HttpError` carrying the start of the response body. Both are generic HTTP, and the second is what makes a failed call diagnosable — a status alone rarely says which field or which permission was the problem, and this runs unattended. Ref-name validation comes along unchanged, including the test asserting that a `..` in a branch name would otherwise redirect the request to a different endpoint with the token attached, and that nothing is sent when one is refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y is The core floor check caught what a local run cannot: `@zuke/gh` declares `jsr:@zuke/core@^1.31.0`, and the `method` option this reached for on core's HTTP helper does not exist there. Raising that floor would have needed a core release first — the sequencing this repository has now hit three times. There was no need for any of it. `uploadSarif`, the sibling REST operation in this package, calls its own `fetch` seam directly and builds its own error. This now does the same, so the package needs nothing new from core and its floor stays where it is. The core changes go with it. `method` and `body` on `HttpOptions`, and the response body on `HttpError`, were only ever there to serve this — and with the module self-contained, adding to a published API for no remaining caller would be worse than not having. `@zuke/core` is byte-identical to master again. The error this builds says as much as the one core would have: the method, the path, the status, and GitHub's own message, which is the half that names the field or the permission. The token is not in it — it never leaves the header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th failure Two findings from the adversarial pass, both against code added in this branch. The ref validator was bypassable. It rejects a literal `..`, but `%` is legal in a git ref, so `%2e%2e` passed it — and the URL parser decodes that to a double-dot segment and resolves it, which is precisely the redirection the validator exists to prevent. Reproduced end to end: a branch name of `%2e%2e/%2e%2e/user/repos` sent a token-bearing GET and PATCH to a path outside the repository the caller named. Validation was the wrong instrument. Each slash-separated segment is now percent-encoded, which makes `%2e%2e` an ordinary segment name and covers the repository slug too — interpolated into the same path and never validated at all. Segment-wise rather than wholesale, because a slash inside a branch name is meaningful and `chore/action-v1.0.3` must stay three segments. The validator stays as well: a name git itself would refuse deserves the clearer error. The second: moving a tag recovered from a missing ref with a bare `catch`, which also swallowed 401 and 403. An expired token or a missing permission was retried as a create and reported as a ref conflict — the wrong error, hiding the one that mattered, in a path that runs unattended. Failures now carry their status and only a missing ref is recovered from. Also fixed a stray import block that an editing mistake had pasted inside this module's own documentation example. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two adversarial reviews ran against this branch and #312. Everything they confirmed here is fixed in 84e2d2d; the serious findings landed on #312, which I have marked draft. Fixed — the ref validator was bypassableThe finding I thought I had closed. The validator rejects a literal Validation was the wrong instrument for this. Each slash-separated segment is now percent-encoded, so Segment-wise rather than wholesale, because a slash inside a branch name is meaningful: Fixed — a bare catch reported the wrong failureMoving a tag recovered from a missing ref with a bare Fixed — a stray import block inside the module's doc exampleAn editing mistake of mine had pasted an import block into the ```ts fence in Moot — the core changes are goneThe reviews flagged Noted, not fixed here
|
Addresses AI review finding `9d0nf96jpjg9`. The caller was generic over a response type and ended in a type assertion, which this repository forbids — and the ban is right here rather than merely stylistic. Asserting the shape meant a field that GitHub renamed, or omitted on some error path, would flow onward as `undefined`: into a request path, or into a tag ref, with nothing saying which call had returned something unexpected. It now returns `unknown`, and each field is read through a checked accessor that names the call it came from. A missing `sha` is an error that says which response lacked it, not a request to `/git/commits/undefined`. The last assertion, indexing a value after a `typeof` check, is a type guard now. That is what the guideline means by narrowing rather than forcing, and it is what makes the accessor safe rather than merely quiet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Three findings this run. One was a real violation with a real consequence; two are refuted by this package's own established pattern.
The caller was generic over a response type and ended in It returns
This finding was right when it was raised against the earlier shape of this PR, where the operations lived in
The four occurrences are If the guideline should cover tests too, that is a repository-wide change rather than one this PR should make unilaterally — and I would rather raise it than quietly diverge from the file next door. All 10 checks pass; security review 0 findings. |
Addresses AI review finding tunixxmwrzt7. The slug was percent-encoded but never validated, unlike the ref names beside it. Encoding is not the missing piece here: it already stops the slug climbing out of /repos/, since a dot segment survives as a literal name rather than resolving. What it does not stop is a slug carrying the wrong number of segments, which quietly changes which endpoint is called and sends a token-bearing request somewhere the caller never named. The check lives in the caller rather than in each settings class, because every request routes through it, so a check anywhere else could be one path short. Also moves a doc block back onto the function it describes, after an earlier edit left it attached to its neighbour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Latest run: security review 1 finding, generic review skipped —
The cited line is the tree body rather than a request path, but the claim underneath it is right and I had missed it: the slug is percent-encoded and never validated, unlike the ref names immediately beside it. Worth being precise about what the gap was, because it is not the one the finding's title suggests. Encoding already stops the slug climbing out of So the guard is a shape check, not a traversal check: exactly two non-empty segments. It sits in the caller rather than in each settings class, because every request routes through the caller and a check in either settings class alone would have been one path short — The existing encoding test used a four-segment slug to demonstrate escaping, which the guard now refuses, so it was narrowed to a two-segment slug that still carries
|
Addresses AI review finding 3sxiscnkq8auw. The error path never parsed a body, so a non-JSON failure was already safe, but the success path parsed bare: a 2xx carrying HTML, which is a proxy or gateway answering instead of GitHub, surfaced as a SyntaxError naming no call. The wrapper is deliberately not a GhApiError. That type means GitHub refused, and tagCommit reads its status to decide whether a missing ref should be created instead. A parse failure is neither of those, and entering that branch would retry an unrelated failure as a tag create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The error path was already safe, and deliberately so: a failing response is never parsed. The body goes verbatim into The success path is where the gap was. It parsed bare, so a 2xx whose body is not JSON — a proxy or gateway answering instead of GitHub, an interstitial from a corporate egress filter — surfaced as a One detail worth flagging, since it is the part that could have gone quietly wrong. The wrapper is intentionally not a Two tests: one asserting the message names the call, says the body was not JSON, and carries a prefix of what actually came back; one asserting a non-JSON body never enters the create-the-missing-ref path. Also worth noting for anyone reading the review comments above: the generic review has skipped its last three runs with
|
First of two. This adds the capability; the follow-up moves
@zuke/ai's lint fixer onto it and dropspersist-credentialsfrom the gate job.The problem
CI that commits usually checks out with
persist-credentials, commits, and pushes. That writes the token into.git/config, where it outlives the step that needed it: every later step can read it, and anything archiving the workspace carries it out.Blocking a job's egress does not answer this, and I claimed otherwise in #312 before correcting myself. The token is a GitHub credential and GitHub is necessarily on any allowlist, so a block bounds where it could be sent, not what it could do.
Placement — corrected twice
I put this in
@zuke/corefirst, on the reasoning that@zuke/aihas no HTTP and would need a new dependency to reach@zuke/gh. That premise was false:@zuke/aihas asrc/hosts/directory of GitHub, GitLab, Azure and Bitbucket API clients, because the reviewer posts comments to whichever host it runs on. With it gone the argument is one-sided —@zuke/ghis the GitHub package and already makes direct REST calls with afetchseam.The move also fixed a guideline the core version broke: a package exposes operations through a namespaced
*Tasksobject configured by a settings lambda, not loose functions taking an options bag.Then the core floor check caught the second half of it. Reaching for a new option on core's HTTP helper needed a floor
@zuke/ghdoes not declare, which would have needed a core release first.uploadSarif— the sibling operation in this same package — calls its ownfetchseam directly and builds its own error. This now does the same, so@zuke/coreis byte-identical to master and there is no sequencing constraint at all.The API
Committing onto an existing branch names the branch, the message and the files. Adding a base branch creates the branch instead of moving it. Tagging names the tag and the commit, and asking to move repoints an existing one. Both take
owner/repoand the token from the Actions environment when not set, asuploadSarifalready does.Two deliberate asymmetries, both asserted in tests:
A real defect this found
Review finding
2y2yj4phucolion #312 asked what happens if a branch name is not validated. Tracing it turned up worse than a malformed name: these go into request paths, and URL normalisation resolves dot segments before the request is sent. A branch name of../../../user/reposturns the ref path into/repos/owner/name/user/repos— a different endpoint, with a write-scoped token attached.Every entry point now validates by git's own ref rules and refuses before anything is sent, because a request that goes out and fails has already carried the token somewhere unintended. The test asserts zero calls were made, and asserts the normalisation itself so the reason survives the code.
What this does not claim
The token is still readable by code running in the step that uses it — that step is what uses it, and nothing short of not having a token avoids it. What is removed is the credential's persistence beyond its use. The module says so itself rather than leaving a reader to infer more.
8 tests,
fetchinjected, nothing touches the network../zuke cigreen, 18/18.🤖 Generated with Claude Code