1.0 blockers: auth-by-default listener, stdio scrub, harness hardening, compatibility promise, docs accuracy #37

Merged
pti merged 19 commits from feat/release-1-0-blockers into main 2026-09-02 22:50:11 +02:00
Owner

Lands the five remaining 1.0 blockers from design/release-1-0.md §2. B3: auth-by-default sse listener with Origin and Host guard plus a shared token provisioner across serve, up and init. B4: stdio removed from every user-visible surface. B7: harness configs written 0600 with refuse-to-clobber and git-exclude. B1: the compatibility promise in docs/releases.qmd. B8: two docs accuracy passes over every claim of protection. Each package was agent-implemented in an isolated worktree and reviewed. B3 was re-verified after its fix round. A /simplify pass and a final whole-branch review closed out, including the up --no-server tokenless seam. Deferred and tracked in the note: connect.sh in-container write lacks 0600 and git-exclude. Details per merge commit. Generated with Claude Code. https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc

Lands the five remaining 1.0 blockers from design/release-1-0.md §2. B3: auth-by-default sse listener with Origin and Host guard plus a shared token provisioner across serve, up and init. B4: stdio removed from every user-visible surface. B7: harness configs written 0600 with refuse-to-clobber and git-exclude. B1: the compatibility promise in docs/releases.qmd. B8: two docs accuracy passes over every claim of protection. Each package was agent-implemented in an isolated worktree and reviewed. B3 was re-verified after its fix round. A /simplify pass and a final whole-branch review closed out, including the up --no-server tokenless seam. Deferred and tracked in the note: connect.sh in-container write lacks 0600 and git-exclude. Details per merge commit. Generated with Claude Code. https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
pti added 18 commits 2026-09-02 22:14:24 +02:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
wraptool's own stdio MCP transport is tests-only (CLAUDE.md hard rule) but
`wraptool init --help` showed a worked example using it, and typo-ing
`transport:` surfaced it by name in two error strings. `wraptool up` rejects
a stdio config outright, so the worked example walked a user into a dead
end (design/release-1-0.md B4). The transport itself still parses and
still works for tests; only the advertisement is removed.
The default configuration and the flagship `wraptool up --runtime=guix` path
both started an unauthenticated SSE listener, reachable from any web page the
developer visits. Three facts composed: authWrapped returned the handler
unwrapped with no token file, Validate required a token only off-loopback and
the scaffold ships auth_token_file commented out; adjustServerBindForRuntime
provisioned a token for the devcontainer runtime only; and mcp-go answers the
SSE stream with Access-Control-Allow-Origin: *, so a hostile page can read the
session endpoint and POST tool calls as a CORS simple request that needs no
preflight. The result was drive-by execution of every whitelisted host command.

serve now provisions a token at the default path (the same helper `up` uses,
0600) when the transport is sse and none is configured, and logs where it went
— auto-provisioning rather than a hard validation error, so a first run still
comes up. adjustServerBindForRuntime provisions on the guix path too; the bind
widening stays devcontainer-only, and the token reaches the container through
the harness config both runtimes already write.

browserGuard wraps the sse listener ahead of the Bearer check and rejects a
non-loopback (or null) Origin and an unexpected Host with 403, on both /sse and
/mcp. That is the layer that survives a leaked token, and the Host rule
mitigates DNS rebinding. It is deliberately not applied to the unix transport:
no browser can reach a filesystem socket, and its clients pick an arbitrary
Host for the dialled path.

design/release-1-0.md §2 blocker B3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
The "Known limitations" entry disclosed that a TCP listener without
auth_token_file is unauthenticated, which is no longer true; a reader also
reasonably took "loopback" to mean "local processes only", and the browser is a
local process. Replace it with the limitation that actually remains: the Origin
and Host checks bound the browser, since only a browser is obliged to send
those headers honestly.

The "Auth on network transports" section now states that the token is
unconditional on sse, why a loopback bind is not a boundary against the
browser, and what the two header checks do and do not cover. Adds the upgrade
note that a client configured against a tokenless listener must be regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
internal/harness's generated MCP configs carry an Authorization: Bearer
token whenever one is set and land in the project worktree — 0644 made
them world-readable, and a merge failure against an unparseable existing
file (notably ~/.claude.json for `init --global`) silently overwrote it
with just the wraptool stanza instead of failing loudly. Also append the
written path to the repo's .git/info/exclude (resolving linked-worktree/
submodule commondir indirection) so it isn't one `git add -A` away from a
public remote.

design/release-1-0.md §2 blocker B7.
1.0 declares a stability contract but nothing in docs/ states one yet
(design/release-1-0.md §1, blocker B1). Add a Compatibility section to
docs/releases.qmd — already in the sidebar, cheaper than a new page —
covering the version-bump table, the frozen list, the explicitly
excluded surfaces (mcp_servers, experimental harness subcommands, the
stdio server transport, legacy pool trees, deprecated features, and
more), the /api/v1 path-versioning sentence, and the one reserved
security-narrowing exception. Link the existing design/ callout into
the new section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
The front-page and comparison docs claimed guarantees the code doesn't
back: scrubbing described sk-/pk_-prefix patterns that don't exist,
"secrets never enter the agent context/container" ignored the MCP token
and pool home/ auth state that legitimately do, the review UI's agent
reachability claim ignored the Guix runtime's shared netns, the flagship
git policy example allowed checkout/switch (the exact re-opened
code-execution path git-hardening.qmd calls out), and both the default
config's smuggle-proofing comment and configuration.qmd's deny-rule
description overstated what wraptool actually enforces. Correct each
claim to match current behavior; docs/security.qmd's "Known limitations"
section (audited separately) is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Review of the B3 branch found `wraptool init` still wrote a tokenless harness
config: runInit derives its token from cfg.MCP.AuthTokenFile, and nothing
persists the auto-provisioned default to config.yaml — so the upgrade remedy
docs/security.qmd points at ("re-run wraptool init") generated another 401.

Extract the provisioning decision into provisionDefaultMCPToken and route all
three callers through it. init may run before any server has, so the helper
creates the file rather than merely naming it.

ensureTokenFile is now create-exclusive. Its read-then-write was reachable
outside the runtime flock (a service-managed serve racing a concurrent up), and
a lost race means one side hands a container a token the server never loaded.
Once the file holds a value that value never changes; the one case O_EXCL alone
would leave broken — a file created by an interrupted earlier run, still empty
— is repaired explicitly.

browserGuard now logs each rejection with the offending header and the
configured bind: a 403 with no server-side trace leaves an operator guessing.

Replaces the runServe boot test, which leaked the server goroutine AND its
signal handler — signal.Notify plus os.Exit(0) turns a SIGTERM-killed test run
into a false green — with a direct unit test of the shared helper (default path
chosen only when unset and sse, file created 0600, an existing token never
rewritten, a configured path untouched) plus an init test asserting the
generated config carries the Authorization header. The wire-behaviour tests
keep running against the real handler composition, now including /message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
The scaffold's auth_token_file comment still said a token is needed only for a
non-loopback bind and that `wraptool up` generates it; sse is now authenticated
whichever way the server was started, so the key only overrides the location.

security.qmd: qualify the generated token path as the Linux one, note that the
allowed loopback origins are accepted on any port (a local development server
clears the Origin check, and the token is what stops it), and reword the opening
so only sse reads as unconditional — unix keeps an optional token behind its
mode-0600 socket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
mcp-integration.qmd, getting-started.qmd, configuration.qmd, and
troubleshooting.qmd still described the sse Bearer token as optional or
described a tokenless listener state that shipped-B3 code no longer allows
(provisionDefaultMCPToken/browserGuard in cmd/serve.go, cmd/up.go). Also
add git to the isolated-environments.md example container so its
now-hardened policy (checkout/switch denied on the host) actually works
end to end, and note auth-by-default in CLAUDE.md's serve/up entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
- harness.Write: chmod 0600 after write — WriteFile applies mode only on
  creation, so a pre-existing 0644 config kept its old perms (with test)
- subcommandMatches: collapse the prefix-shaped fossil to slices.Equal,
  matching the exact-equality contract its comment pins
- cmd tests: one withIsolatedXDG helper replaces three copies of the
  env+xdg.Reload isolation fixture
- adjustServerBindForRuntime: fix stale comment (token now born in
  provisionDefaultMCPToken, not ensureServer)

Skipped deliberately: gitexclude's pure-Go discovery stays (works without
git on PATH, smoke-tested on a real linked worktree; git rev-parse swap is
a noted follow-up); loopbackNames stays string-exact (fail-closed by
design, reviewed as such).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
fix(up,docs): close final-review findings on the blockers branch
Some checks failed
Lint / lint (pull_request) Has been cancelled
65542379e1
- up: provision the sse token even with --no-server — the running server
  requires it, so a header-less harness config 401s with no diagnostic
  (reviewer-verified failure; call hoisted out of the noServer gate)
- security.qmd: name the six keywords the env-assignment scrub pattern
  actually matches; document the bounded help-value smuggle in Known
  limitations (validates the scaffold comment's cross-reference); document
  literal Host matching for specific-address binds
- getting-started.qmd: header written when a config exists, not 'always'
- isolated-environments.md: token checklist line matches auto-provisioning
- CLAUDE.md: extend init.go entry (provisioning, 0600, no-clobber, exclude)

Deferred, tracked: connect.sh writes the in-container .mcp.json without
0600/git-exclude (feature-side, next feature release); ensureGitExclude
fail-hard and dotfiles-repo behavior kept as reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Author
Owner

cmd/init.go:L77: 🔴 bug: config-load errors—including a missing first-run config—are swallowed, producing a tokenless SSE harness config that will 401 once serve provisions auth. Scaffold/load the default config or return the load error before writing harness configs.

cmd/init.go:L78: 🔴 bug: default SSE token provisioning makes --harness all reach Roo’s auth rejection only after six earlier harness configs were written. Validate the expanded harness set before any writes, and define whether all excludes auth-incompatible harnesses.

cmd/up.go:L517: 🟡 risk: the empty-file repair defeats O_EXCL race protection; a second process can observe the newly created but unwritten file, truncate/write it, then have the first writer overwrite that token. Serialize repair or use an atomic fully-written-file publication scheme.

cmd/init.go:L77: 🔴 bug: config-load errors—including a missing first-run config—are swallowed, producing a tokenless SSE harness config that will 401 once `serve` provisions auth. Scaffold/load the default config or return the load error before writing harness configs. cmd/init.go:L78: 🔴 bug: default SSE token provisioning makes `--harness all` reach Roo’s auth rejection only after six earlier harness configs were written. Validate the expanded harness set before any writes, and define whether `all` excludes auth-incompatible harnesses. cmd/up.go:L517: 🟡 risk: the empty-file repair defeats `O_EXCL` race protection; a second process can observe the newly created but unwritten file, truncate/write it, then have the first writer overwrite that token. Serialize repair or use an atomic fully-written-file publication scheme.
fix(init,up,harness): close PR #37 review findings
All checks were successful
Lint / lint (pull_request) Successful in 7m22s
b082b35219
Finding 1: init silently ignored a config-load failure and wrote a
tokenless harness config that 401s against the auto-provisioned server.
init now scaffolds the config when absent (config.EnsureConfigFile, same
as serve/up) and a load error is fatal.

Finding 2: with a token always present on sse, 'init --harness all' died
on the first auth-unverified harness (roo) after six configs were already
written. harness.Write is split into Prepare/Commit: init validates every
target before writing any (no partial sets), and 'all' skips
auth-unverified harnesses with a notice — an explicitly named harness
still gets the hard error. The error text also loses its stdio mention.

Finding 3: the empty-file repair reopened the two-writer token race that
O_EXCL closed. ensureTokenFile now serializes create, read-back, and
repair under a sidecar flock (the existing cross-platform lockFile
helpers), so first-writer-wins holds in every path; an unreadable
existing file is now an error instead of silent success.

Tests: first-run init scaffolds + writes the Bearer header; --harness all
skips roo and writes nothing when any target fails validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
pti merged commit 6b4f0c7bc6 into main 2026-09-02 22:50:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pti/wraptool!37
No description provided.