Skip to content

feat: stele, a CLI that holds the credential so the agent never sees it - #1

Merged
ProJedi1234 merged 6 commits into
mainfrom
feat/stele-cli
Aug 5, 2026
Merged

feat: stele, a CLI that holds the credential so the agent never sees it#1
ProJedi1234 merged 6 commits into
mainfrom
feat/stele-cli

Conversation

@ProJedi1234

Copy link
Copy Markdown
Owner

The initial implementation of stele, the client for stele-pages. Pairs with ProJedi1234/stele-pages#19, which adds the per-client credential system this talks to.

Why a CLI at all

Not ergonomics — custody. The server's skill document used to tell an agent to hold a bearer token and run curl, which put a secret in the same context window as a document about to be published at a guessable URL. The only thing standing between them was a prose rule ("never write it into a page you publish"), which is a rule you only write down because the failure is plausible.

Now the agent runs stele publish page.html and the token lives in a 0600 file it never reads. The credential enters once, through a human running stele auth login.

The rules that make that real

  • The token is never an argument. auth login reads from a TTY and refuses a pipe, because argv is visible in ps and lands in shell history — and shell history is something an agent reads.
  • Loose permissions are fatal, not a warning. A group- or world-readable credential file is refused the way ssh refuses one.
  • No subcommand prints the token — not auth status, not --json, not any error path. Token.secret is private, the type conforms to neither Encodable nor round-trippable Decodable, and description/debugDescription/customMirror are all closed. There are exactly four plaintext reads in the codebase: the Authorization header, the 0600 file, and the two deliberate ones in admin clients create, where the token exists for the only moment it ever will.
  • No environment variable configures the credential, the token or the host. Host selection comes from the credential file — single stored host, a default marker when there are several, --host to override.

TokenLeakTests produces every error case through the real code path from a hostile server that echoes credentials back, renders each four ways (interpolation, String(reflecting:), dump, JSONEncoder) and asserts the secret appears nowhere — with an anti-vacuity check that the canary really is present in the 0600 file.

Structure

Follows cbx-cli: a dependency-free SteleKit that returns plain data and prints nothing, and a thin stele executable that owns all rendering. Tests target the library, so nothing needs a spawned process. The Makefile keeps two details worth not simplifying away — the install-to-temp-then-mv that avoids ETXTBSY when overwriting a running binary, and the completion-directory probe that picks oh-my-zsh's custom/completions when it exists and falls back to ~/.zfunc.

An honest note on how this was caught

The first version of this CLI passed its whole suite while being wrong about four things: it called a route the server never implemented, used the wrong verb for revoke, decoded a bare array where the server sends an envelope, and sent expiresInSeconds where the server reads expiresIn — so --expires-in was silently dropped and minted credentials that never expired.

All four survived because the tests asserted against a fake transport built from this repo's own assumptions. Two green suites, zero integration. scripts/integration-smoke.sh is the fix: it drives the real binary against a real server and a real database through the whole lifecycle, and fails loudly. The expiry case is checked against a control credential minted without the flag, so a fabricated value can't pass.

Verification

  • swift test75 tests in 12 suites, passing
  • Full lifecycle verified end to end against a live server: bootstrap, login over a real pty, status, publish, update, create-with-expiry, list, revoke, revoked-credential rejection, publish-only 403s on admin routes, and a direct SQL check that attribution follows the writer.

Known gaps

  • The smoke script asserts auth login's pipe refusal but seeds the credential file rather than driving the interactive prompt; the interactive path was verified manually over a pty. Automating it needs a pty driver.
  • Prompt's non-TTY guard now has coverage, but the executable target's test story is thinner than the library's.

🤖 Generated with Claude Code

https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb

ProJedi1234 and others added 5 commits August 5, 2026 06:09
Two targets after cbx-cli: SteleKit holds the decisions and depends on nothing,
the stele executable holds ArgumentParser and every print. That split is what
lets the suite test behaviour as pure functions instead of spawning a process,
and it is why CI needs no service container.

The Makefile is cbx's with the names changed, keeping both hard-won details and
their reasons: install-to-a-temp-name-then-rename (writing over a running binary
is ETXTBSY, and agents reinstall while another invocation may be mid-publish),
and the ZSHCOMP probe for oh-my-zsh's custom/completions with ~/.zfunc as the
fallback.

The version lives in SteleKit rather than in CommandConfiguration because
`--version` and the User-Agent the server version-gates on must be one string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb
The logic layer, returning plain data and printing nothing: the executable target
owns every `print`, which is what makes `--json` a rendering choice rather than a
second path through the API and what lets these tests run without a process or a
network.

Token custody is structural rather than a convention. `Token`'s plaintext is
reachable only through two `internal` members — the `Authorization` header and the
credential file — so the executable has no expression that yields it; the three
reflection escapes (`description`, `debugDescription`, `customMirror`) are closed;
and the type conforms to neither `Encodable` nor `Decodable`, so it cannot fall into
a `--json` shape by someone adding a field. `MintedToken` is the single deliberate
exception, because a freshly minted credential has no other delivery path, and it
still requires an explicit `.secret` at the call site. Server-supplied messages pass
through `Redaction.scrub` before they can reach an error description, so the promise
does not rest on the other repository's behaviour.

The credential file is written `0600` inside a `0700` directory via an atomic
rename, and a group- or world-accessible file is refused the way ssh refuses a
private key. Host resolution has no environment-variable step anywhere in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb
Wires SteleKit up to ArgumentParser: auth login/status/logout, publish, update,
skill, and admin clients create/list/revoke. The executable holds every `print`
and the library holds every decision, so `--json` is a rendering choice rather
than a second code path.

Three things this commit is really about:

The token enters through a TTY and nowhere else. `auth login` prompts with echo
off, and refuses a non-TTY stdin instead of reading it — `echo $TOKEN | stele
auth login` would put the credential back into the environment and the shell
history the credential file exists to keep it out of, which would make every
other precaution decorative. There is no --token flag for anyone to reach for.

No command prints the token, and the enforcement is access control rather than
care: Token's plaintext accessors are internal to SteleKit, so this target has
no expression that yields one. `admin clients create` is the single exception,
because the server keeps only a SHA-256 and cannot reissue what it minted; that
goes through MintedToken.secret, spelled out at the call site, and appears in
the --json payload too — omitting it there would make --json a quiet way to lose
a credential you just created.

Exit codes are the agent-facing API. Outcomes with different next steps get
different codes — 2 "ask a human", 5 "pick another slug", 8 "reinstall and retry
once", 9 "retryable" — rendered into `stele --help` from the same constants the
mapping uses, so a code cannot leave its documentation behind.

Two decisions the command line hands straight to the library land in SteleKit
where they can be tested as pure functions: content type inferred from the file
extension (a hint, not a copy of the server's allowlist), and `--expires-in 90d`
parsed into seconds, with a bare number refused because its unit is only obvious
to whoever typed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb
Follows cbx's strategy rather than spawning the binary: every decision is a pure
function in SteleKit taking its world as a parameter — the home directory into
CredentialStore, the transport into SteleClient — so the suite covers behaviour
without a process, a server or a network.

The centrepiece is TokenLeakTests. It renders values every way a program can
render one — interpolation, String(reflecting:), dump and JSONEncoder, which are
four different doors and a type can leave any one of them open — and asserts
neither of two canary credentials comes out. Both error types are covered
exhaustively through a switch that stops the file compiling when a case is added,
and the errors are produced the way production produces them, through the real
client from a hostile response, rather than by constructing an enum with a
payload the test chose: whether the type *can* hold a token is not the question.

That found three genuine leaks, fixed here at construction so no payload holds a
credential in the first place:

- SteleHost rejecting a value echoed it back. The likely way to land there is a
  human pasting the token at login's *host* prompt, one line above the token
  prompt, which put a live credential on the terminal and into any transcript.
- The credential file's decode errors quote the file's own bytes — a key, a
  parser's description of what it choked on — and those bytes are credentials.
- transportFailure repeated a reason from SteleTransport, which is a public
  protocol, so that string can come from a conformer this library did not write.

Also pins the status table as identity rather than as prose (a status mapped to a
plausible-but-wrong case still gives confident advice for the wrong problem, and
the exit code comes off the case), and the request-construction rules the CLI now
owns on the agent's behalf.

The README follows cbx's structure, with install matching what the server's skill
document tells an agent to run — clone, make install, make install-completions —
and both traps named: ~/.local/bin on PATH, and LD_LIBRARY_PATH for a swiftly
toolchain in a non-interactive shell, whose linker error looks nothing like a
missing environment variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb
…ould have caught it

Both suites were green and four contracts were broken. The unit tests assert
against a FakeTransport whose expectations were written from this repository's
code, so a field spelled differently from the server's is wrong in the client
and wrong in the test that checks the client, and the two agree with each other
right up to the first live request.

Corrected against the server's routes, which are the source of truth:

- `expiresIn`, not `expiresInSeconds`. The server ignores keys it does not
  know, so the old spelling earned a cheerful 201 and a credential that never
  expired — the worst failure shape available, because nothing reports it.
- `GET /admin/clients` returns `{"clients": [...]}`, not a bare array. Decoded
  through an envelope struct and unwrapped so callers still see a list.
- Revocation is `DELETE /admin/clients/:name`, not
  `POST /admin/clients/:name/revoke`.
- `Path.whoami`'s doc note now says the trap out loud: it sits under `/admin`
  but is deliberately excluded from the admin scope group, because `auth login`
  runs it holding a publish-only credential.

Prompt moves to SteleKit behind a `Console` seam and a typed `PromptError`, so
the custody rule is testable without a terminal. The refusal is unchanged and
now asserted directly: a non-TTY stdin is refused for the secret *and* the host
line, and is never read. `Exit` splits the two prompt failures that used to
share a code — `notATerminal` keeps `noCredential` ("stop and ask a person"),
while EOF at a real terminal is the generic "try again", since an agent that
saw `1` for a refused pipe would retry the pipe.

`scripts/integration-smoke.sh` drives the real binary against a real server and
walks the documented lifecycle end to end. It is the only thing that can catch
this class of bug; the README says plainly what it does not cover.

The token-leak suite is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018anMA6iMBG1Ekv7rGtSXXb
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ProJedi1234, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 945a8149-c85b-498d-8b73-6174aa600a5a

📥 Commits

Reviewing files that changed from the base of the PR and between e3b35e4 and 6d81ed1.

⛔ Files ignored due to path filters (1)
  • Package.resolved is excluded by !**/Package.resolved
📒 Files selected for processing (29)
  • .github/workflows/ci.yml
  • .gitignore
  • Makefile
  • Package.swift
  • README.md
  • Sources/SteleKit/Credentials.swift
  • Sources/SteleKit/Inputs.swift
  • Sources/SteleKit/Models.swift
  • Sources/SteleKit/Prompt.swift
  • Sources/SteleKit/SteleClient.swift
  • Sources/SteleKit/SteleError.swift
  • Sources/SteleKit/Token.swift
  • Sources/SteleKit/Transport.swift
  • Sources/SteleKit/Version.swift
  • Sources/stele/AdminCommands.swift
  • Sources/stele/AuthCommands.swift
  • Sources/stele/Completions.swift
  • Sources/stele/Exit.swift
  • Sources/stele/PageCommands.swift
  • Sources/stele/Stele.swift
  • Sources/stele/Style.swift
  • Tests/SteleKitTests/CredentialsTests.swift
  • Tests/SteleKitTests/InputsTests.swift
  • Tests/SteleKitTests/PromptTests.swift
  • Tests/SteleKitTests/SecrecyTests.swift
  • Tests/SteleKitTests/SteleClientTests.swift
  • Tests/SteleKitTests/TokenLeakTests.swift
  • Tests/SteleKitTests/VersionTests.swift
  • scripts/integration-smoke.sh

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands.

…pping on a bad duration

Five findings from a review of the branch. The first is the only one that
matters much, and it is the one the leak suite could not see.

`SteleClient` documents that "a token is only ever sent to the deployment it
was filed under". That was true of how URLs were built and false of what went
out on the wire: `URLSession` follows a 3xx by itself and copies the request's
headers — `Authorization` among them — onto the redirected request. Verified
with two loopback servers: host A answers `302 Location: http://host-B/`, host
B receives `Authorization: Bearer <token>`, and the caller sees an ordinary
200. Anything able to answer for the configured host could collect the
credential that way.

`URLSessionTransport` now builds its own session with a redirect policy and no
longer accepts one, since a `session:` parameter is a way to pass a session
without the policy. A 3xx is followed only within the same scheme, host and
port; anything else stops and becomes `RedirectRefused`, which the client turns
into `SteleError.redirected`. That is a separate case from `transportFailure`,
whose message promises the credential was never sent — here it was sent, to the
right host, which then pointed somewhere else.

`TransportTests` is the first test in the suite to speak TCP, and it has to:
redirect following is a decision `URLSession` makes rather than one this code
makes, so a fake transport tests the seam and not the thing. Two real servers
on loopback ports, differing only in port — a policy comparing hostnames alone
would pass while leaving the same hole open. With the delegate removed the
cross-origin test fails and the same-origin one still passes, so it is the
policy under test and not redirect handling in general.

The other four:

- `--expires-in 999999999999999d` multiplied out past `Int`'s range, and
  `Int(Double)` out of range is a runtime trap, not an error — a crash and a
  backtrace where every other bad duration gets a sentence. `ExpiryDuration`
  now has a ceiling and checked arithmetic, with `.tooLong` as its own case
  because the syntax was right and the number was not. `SteleClient` clamps
  besides, since `createClient` takes a `TimeInterval` from any caller.
- `Redaction.scrub` recognised a credential by the `stele_pat_` prefix, which
  is exactly what the token most likely to be pasted at the host prompt does
  not carry: `Token.init` accepts an unprefixed token on purpose, because until
  the server's shared upload token is demoted that is what an operator logs in
  with. `scrubbedEcho` withholds a whole typed value that is one unbroken run
  of credential characters. A mistyped host is still quoted back — a message
  that withholds the typo cannot be acted on.
- `attributesOfItem` does not follow symlinks, so a credential file symlinked
  out of a dotfiles repository was judged by the link's own 0777 and refused
  forever: `chmod` follows the link and changes the target, the link stays
  0777, and the printed remedy loops. Resolved before the stat, and only when
  the last component is actually a link.
- Two spellings of one deployment in the credential file (`https://h` and
  `https://h:443`, or two casings) normalise onto one key, and `[String: Any]`
  has no order — so the credential used could differ between two runs over
  identical bytes. Refused now, naming both spellings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZwrF9ZtAUQHDGzCoJRMQi
@ProJedi1234
ProJedi1234 merged commit 96ae4e6 into main Aug 5, 2026
1 check passed
@ProJedi1234
ProJedi1234 deleted the feat/stele-cli branch August 5, 2026 13:57
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