- Status: accepted — ruled on 2026-09-25, the design first and the code
written against it on
view-launch-token - Shipped: 0.21.0
- Blocked: the 0.21.0 tag, until Alessandro had clicked the printed
address in a real browser and pressed Make baseline — §8. Ruled
2026-09-25, and satisfied the same day: all five steps held, both
controls refused, and the walkthrough's own gaps went into
RELEASING.md - Date: 2026-09-25
- Amended: 2026-09-25, before merge, answering CodeQL alert 89 on #133. The
hand-over's
Locationbecomes the constant/, so?locale=itno longer survives it: §2 says why, keeps the reasoning that made the old value safe, and §10 names the class. No other decision moves - Amended: 2026-09-27, for 0.21.1, after the 0.21.0 delta-pass (K-1). The key
is spent on its first hand-over, and the cookie carries a second secret
minted there — §11, which says why spending the key alone would have left
half of the defect open. §5's first bullet is narrowed: reading a plaintext
browser-history file is not attacking the user boundary, and the key went
there on its own. The cookie is now read without
SimpleCookie, and the 403 says what the request carried rather than what the browser did (K-3). The consequence "another browser … needs the printed address copied into it" is withdrawn: it now needs a restart. No other decision moves - Amends: ADR 0032, in its Consequences only — the bullet saying the record "does not authenticate the view". That bullet was true of 0032 and is amended in place with a pointer here. No decision in 0032 §1–§8 moves: the default still refuses, the flag still enables, and the hook still watches the flag
- Opens: nothing. No schema, no document field, no wire key.
SCHEMA_VERSION,OUTPUT_VERSION,REGISTER_VERSIONandJOURNAL_VERSIONstay where they are. What moves is how the flaggeddigline viewanswers a POST, and the address its startup line prints - Assumes: ADR 0002 §1 (the approval is a person's); ADR 0011 §the shape of the API is the argument; ADR 0032 §1 (the flag), §2 (the absence, and the 404) and §8 (a guard that keys on a name)
- Touches: nothing in
CLAUDE.md's fixed section. It is deliberately scoped to world 1, and §6 says why — one of the three reasons is that the world-2 version of the question reaches decision 2 - Turns into surface:
digline view --allow-promote's startup line (the address carries the key) and its--help;POST /promoteon the flagged server (a new 403);docs/view.md;CHANGELOG.md - Credit: piekwerk, on the Reddit thread under the four-ways post, who argued that a flag lives in the agent's environment and a token does not
- Number: 0033. Swept on 2026-09-25 across every ref and every sibling worktree; 0032 was the highest claimed
Context¶
ADR 0032 made digline view refuse promotion unless it was started with
--allow-promote, and made the plugin's hook ask a person before any command
carrying that flag. The flag answers one question: may this server write?
It cannot answer the second one: who is asking?
And the flagged server did not ask. _allowed_origin admits a request with no
Origin header on purpose — "that is a curl or an old browser, neither of
which is the attack" — and nothing else stood behind it. So once a person had
started digline view --allow-promote in their own terminal, any process of
that user could move the baseline with one POST. That includes an agent's
shell. The hook never fired, because nobody typed the flag in the agent's
shell. The person did, in theirs.
What was measured¶
The same script against both trees, and not read off the source. It uses
a store with two runs, the first promoted. digline view --allow-promote is
started on an ephemeral port, and one POST is sent with no Origin header
and no cookie:
== origin/main cfcf47b
startup: digline view on http://127.0.0.1:49319/ — promotion enabled — ctrl-c to stop
POST /promote, no Origin, no cookie -> 200; baseline moved: True
== this record's branch
startup: digline view on http://127.0.0.1:49322/?launch=<key> — promotion enabled, from the browser that opens this address — ctrl-c to stop
POST /promote, no Origin, no cookie -> 403; baseline moved: False
The argument that decided it¶
It came from a reader, and it is the sharper form of 0032 §8. A flag lives in
the agent's environment. A key does not. The flag is a word on a command
line, and a command line is something an agent composes. The hook can ask about
a word before it runs, and that is all it can do. The hook is a preference, not
a wall (0032 §3), and one curl against a server somebody else started never
passes through it at all. A check that the caller holds something only the
person's browser was given does not depend on what the agent read, typed or
was told. It makes the act impossible, where the hook only makes it
deliberate. That is the difference between the two kinds of guard this project
has been naming all month: the wall stands outside the surface the caller
configures.
Decision¶
1. The key has no home¶
digline view --allow-promote mints a launch key when it starts:
secrets.token_urlsafe(32), 256 bits, held in the server's memory. It is never
written to disk, never read from the environment, and no option or parameter
supplies it — serve() has none.
The absence of a home is the design, not a simplification of it. Every home that was considered is readable by the caller the key exists to refuse:
- A file the person types from. The agent runs as the same operating-system user, and reads files as that user. Only the agent's own sandbox could stop it, and that is exactly the surface the caller configures.
- An environment variable the server reads at start. Claude Code inherits the environment of the shell that launched it, so a variable in the person's profile is in the agent's shell too.
- An option,
--token. Then it is a word on a command line again, which is where this started, and §4 refuses it for a second reason.
Minted per start and held in memory, the key has nowhere to be read from. It is shown exactly once: in the process's output, on the startup line, as the query of the address the person opens. Where that output goes is decided by whoever started the process. Usually that is a person's terminal, which the agent does not read. When an agent started the server, the output is the agent's pipe, and the plugin's hook has asked a person before that could happen (0032 §3).
2. How it reaches a browser, and the one thing it must never reach¶
The printed address is http://HOST:PORT/?launch=KEY. A GET carrying the
right key is answered with a 303 to /, and sets the key as a cookie:
HttpOnly, SameSite=Strict, Path=/. POST /promote compares the cookie
with the key, using hmac.compare_digest so a comparison that stops early does
not reveal how much was right, and refuses without it.
Amended 2026-09-27, for 0.21.1: the cookie no longer carries the key. The hand-over mints a second secret and sets that, and it spends the key. §11 says why the first half is what makes the second half mean anything.
Amended 2026-09-25, before merge. Until then this paragraph ended "Other
query parameters survive the redirect, so ?locale=it stays." They no longer
survive: Location is the
constant /, whatever the request's path or query was. The reason is a
property, not the defect that prompted it: no header this server sends
carries anything the request said. That is a sentence a test can assert over
every route (test_no_response_header_carries_anything_the_request_said), where
the old value was safe only through a set of escaping facts somebody would have
had to keep true. The cost is nil in practice: the printed address is always
/?launch=…, so there was never another path or parameter to carry, except one
a person typed onto it by hand.
Why the old value was ever safe, recorded here because the code that needed
the reasoning is gone. Location was the request's path plus its re-encoded
query. CodeQL raised it as response splitting (alert 89). Measured on Python
3.12.13 and 3.13.11 with raw sockets, a CR or LF could not reach it, through
three facts about code this project does not own:
http.serverreads the request line up to the LF, strips trailing CR/LF, and splits on whitespace. A CR in the middle makes an extra word, which is refused as400 Bad request syntaxbefore any handler runs.urlparse(...).pathdoes not decode, so/p%0D%0A…went out still encoded.parse_qsdoes decode%0D%0Ainto a real CR/LF, andurlencodere-encodes it withquote_pluson the way out.
send_header itself checks nothing: on both versions it formats
"%s: %s\r\n" and encodes to latin-1. So the safety lived entirely in those
three places, none of them in this file. And against a meaning, as opposed to
a byte, the old value was not safe: /\evil.example went back out verbatim, and a
browser reads that backslash as a slash, so //evil.example is a redirect off
the machine. It was reachable only with the right key, and whoever holds the key
can promote anyway, so it gave nobody anything. That is why it is not the
reason for the constant, only the demonstration that the escaping rules had
already failed to cover what the header meant.
The cookie's name carries the port — digline-view-7373. A browser keeps
cookies by host and not by port, so two views on one machine, one per suite,
would otherwise overwrite each other's key. The first person would then find
their server refusing them the moment the second one started.
The key never appears in anything the server renders. Not in a page, a
form, a hidden field, a link or a Location header. This is the half that is
easy to lose in a later change, so it has its own test that walks every route.
The reading routes answer anybody with a shell, and must: they are how a
person and an agent both read a result. So a key written into a page would be
handed to exactly the caller it exists to refuse, by the server itself, on
request.
The Origin check stays, and runs first. It stops a page the developer
has open, and the key stops a process. Leaving no Origin allowed, for
curl, was safe only as long as something else refused curl on the route that
writes. Before this record nothing did. Now the key does, and _allowed_origin's
docstring says so.
3. On this server the refusal is a 403, and on the default server it is still a 404¶
ADR 0032 §2 made a POST to /promote on the default server a 404, the same
answer as any unknown path, and refused a 403 in so many words: a 403 says you
may not, which implies a someone who may. That is a policy, and on the default
server there is none. Nobody may promote there, so the route is absent.
On the flagged server somebody may — the person who started it and opened the address it printed. So a caller without the key is refused, not told there is nothing here: 403, with a sentence that says what to do. A 404 here would be the false statement, because the route plainly exists.
The same fact, two servers, two truthful answers. It is 0032 §2's reasoning, not an exception to it: that record asked which statement is true, and the answer depends on which server is being asked.
An address carrying a key from an earlier start, such as a tab left open across a restart, is refused with a 403 as well. The sentence names what happened, and no cookie is set. On the default server the parameter is ignored: there is nothing to hand over, and the header already says what that server is.
4. The key is not a second control¶
There is no --token, no way to turn the key off, and nothing to configure.
The person operates one control, the flag. The key is how the flag's
decision stays with the person who made it. It is not a second decision beside
the first.
This holds by construction as well as by documentation. ViewHandler takes a
launch_key and derives allow_promote from it; it has no allow_promote to
set. A server that promotes without a key cannot be constructed, and serve()
has no parameter through which a key could be supplied.
Why it matters enough to be a decision. Two controls for one act are how one of them stops being read, and that is this month's whole family. 0032 §8 records four guards that each named the act by one spelling while the act arrived by another. A configurable key would be the same failure one level up: a guard beside a guard, where whoever turns one off for a reason — "just for CI", "just for the demo" — has removed the only one that was a wall, and nothing anywhere says so. A key that cannot be configured cannot be configured away.
5. What it does not do¶
Said so the record is not over-read.
- It is not a wall against an adversarial process of the same user. The key sits in the server's memory and in the browser's cookie store. A process determined to reach either, by reading a browser profile or attaching to a process where the platform allows it, is attacking the operating system's user boundary, and no key held under that user survives that.
Narrowed 2026-09-27, for 0.21.1 (§11). "Reading a browser profile" covered
two different acts, and this bullet priced them as one. Reaching the
cookie means Chrome's cookie store, encrypted under a Keychain key, or
the server's memory. That stays out of scope. Reaching the printed
address meant one SQLite query against History, a plaintext file, where
the browser had written the address when it exited. Measured: the process
that read it promoted. Nobody attacked a boundary there: the key went to a
file every process of the user reads, and it stayed valid for the server's
life. The mechanism exists for the agent on the same machine, and that is
exactly the caller it failed against. So "same user, therefore nothing to
gain" is not the reasoning that decides this, and this bullet no longer
covers it. What the key
closes is the caller acting on inference, the one ADR 0032 §1 argues
from: an agent that reads a result, concludes a baseline should move, and
finds one POST enough. It is now not enough.
- A server an agent starts itself is the agent's. If the agent launches
view --allow-promote through a spelling the hook does not read (0032 §8
concedes bash -c, eval, aliases and variables), it holds both the server
and the key, exactly as it could run digline promote itself under the same
spellings. The key protects a person's server from the agent. It was never
going to protect the agent's own.
- A redirected startup line writes the key. digline view --allow-promote >
log puts the key in log. That is the launcher choosing a home for it, and
it lasts only as long as that start does.
- Any server on localhost receives the cookie. Cookies are scoped by
host, not by port, so a browser sends digline-view-7373 to every port on
the same host. Exploiting it needs another local server and the person
visiting it. That is a same-user process again, the first bullet, not a
new door.
6. World 2 stays out¶
The question came with a second one: if the store could live outside the
repository, in a client's perimeter, view would run where the data is and the
agent would not be on that machine at all. That does not make this record
unnecessary there. It makes it insufficient there, for three reasons, and each
belongs to a different record:
- The network becomes the caller. Off loopback, "no
Originis allowed" admits anything that can reach the port. A per-start key still serves one operator. Several operators need a person's identity: revocable, and perhaps recorded on the promotion. That is authentication, and it belongs to the--hostrecord that 0032 §4 filed under decision 9.
Noted 2026-11-03 (ADR 0061
§4), and the bullet above kept as written. No --host record follows:
digline view takes no --host, so it is never off loopback, and
authentication on a network is not a question it asks.
- The reviewed diff moves. 0032 §4 treats the committed baselines/ diff
as the wall a promotion ends at. If the store sits in a client's perimeter,
the baseline is not in the developer's reviewed diff, and where a world-2
promotion is reviewed has to be answered before who may make one.
- A store outside the repository reaches fixed decision 2, which puts
everything in .digline/<tenant>/ inside the user's repository. That needs
its own ADR first. A reconnaissance of it was under way in another session
when this was ruled, and this record builds nothing that presumes its answer.
7. How it is held¶
- The measurement in §What was measured is a test:
test_a_shell_without_the_key_cannot_promote_on_a_flagged_serverreads the store on both sides of a keyless POST and a forged one, then of the same POST with the cookie, which is the control that proves the server can promote at all. - §3 is
test_the_refusal_on_the_flagged_server_is_a_403_not_the_404, beside the unchanged default-server tests that still require the 404 and the unknown-path sentence. - §2's rule is
test_no_page_the_flagged_server_renders_contains_the_key. - §2's amendment is
test_no_response_header_carries_anything_the_request_saidandtest_a_path_that_means_another_host_is_not_sent_back, which assertsLocation: /rather than the absence of the host: an absence also passes when the server mangles the path for some other reason. Two more mutations, each caught: the oldLocationrestored (3 failures) and a header echoing a request header (1). - Every existing promotion test now sends the cookie, and that is not bookkeeping. Without it the key refuses first, and the origin test, the rebinding tests and the walk over every refusal type would all have gone on passing with the guard they name deleted: a test does not fail when it stops testing, which is the lesson 0032's code already paid for once. Each file says so where it sends the cookie.
- Three mutations were run against the finished code, and each was caught: the key check removed (2 failures), the origin check removed (2, including the rebinding control), and the key rendered into every page (1).
8. A person uses it in a browser before the tag, and the tag waits¶
Everything in §7 is verified header by header: the 303, the cookie's
attributes, the POST that carries it. Nobody has done the thing every user
will do first: click the address the startup line prints and press Make
baseline. A browser's own handling of a SameSite=Strict cookie set by a
redirect, on a page opened from a terminal, is exactly the kind of fact an HTTP
test assumes rather than observes. A control proven at the HTTP level and never
seen working in a browser is a control nobody has used.
So this is not listed as owed. It blocks the 0.21.0 tag. Alessandro does it
before the release, not after, by the steps in RELEASING.md §Before the tag:
a control a person meets first in a browser. If the browser refuses the
person who opened the printed address, the release is wrong and not the
browser, and this record is reopened before anything ships.
9. The tests the key would have emptied¶
The key sits in front of three guards that already had tests: the Origin
check, the rebinding guards and the walk over every refusal type. Each of
those tests sends a POST, and without the cookie the key refuses it first.
They would have gone on passing with the guard they name deleted, because a 403
from the key looks like a 403 from the origin check. The mutations in §7 are
what show they did not: each file sends the cookie, and says why where it does.
This is the fourth instance of that family in a week, and the first one found
in the same commit that created it rather than afterwards. The construction
rule it adds is written where the next control gets written, in
CONTRIBUTING.md: a new guard in front of an old one empties the old
one's tests. Give those tests what the new guard asks for, then mutate the
old guard away and watch them fail.
10. Request data in a header, trusted for the shape we believed it had¶
§2's amendment is the week's family in a fourth costume. 0032 §8 records
guards that named an act by one spelling while the act arrived by another. The
plugin's hook believed a command starts with digline, and it arrived as
uv tool run digline or python -m digline.cli.main. This header believed a
path cannot contain a backslash that means something, and a browser gives
/\ the meaning of //. It is the same error on two surfaces: a check of
the shape of data the caller controls, standing in for a statement about what
the data will be taken to mean by whoever reads it next.
The repair has the same construction as 0032 §8's first way: make the thing
the guard was protecting stop depending on the caller at all. 0.20.1 made the
full flag word the only spelling view accepts. Here, Location does not
read the request, so there is no shape left to believe in. And the test asks
the accepting side, the way 0032 §8's does. It does not check an escaping rule;
it sends a marker in every part of a request a caller controls, on every route,
and requires that no response header contain it.
Consequences¶
- A script that POSTs to a flagged server stops working. That is the point,
and it is why this is not a patch. A script that means to promote runs
digline promote, which is a command the hook reads and a person can be asked about. - For a person in world 1, the ordinary case, the cost is one click. The
address on the startup line is the one to open, and terminals make it
clickable. Opening the bare
http://127.0.0.1:7373/still shows every page, but its buttons answer 403 with a sentence that names the address to use. - A restart invalidates open tabs and bookmarks, because the key is per start. An old tab is refused with a sentence naming why.
localhostand127.0.0.1are two hosts to a browser. A cookie set under one is not sent to the other, so a person who opens the printed address and then types the other name has the pages and not the button's permission. The 403 sentence tells them what to do. The printed address uses the host the server bound to.- ~~Another browser or profile needs the printed address copied into it.~~ Withdrawn 2026-09-27 (§11): the address works once. Another browser, or the same one after a mistake, needs a restart and the new address.
- The tag waits for a person to use it — §8.
11. The key is spent on its first hand-over (amended 2026-09-27, for 0.21.1)¶
What went wrong. The 0.21.0 delta-pass (private/delta-pass-0.21.0.md,
K-1) measured it with a throwaway Chromium profile:
- The printed address was written, key included, into
History(a plaintext SQLiteurlsrow) and into theSessionsfiles when the browser exited. - The key was reusable for the server's whole life: a second
?launch=was answered with a second cookie. - A separate process, given only the profile directory, read the row, handed
itself over, read
runandreplacingfrom/, and POSTed without anOrigin: 200, and the baseline moved.
The published sentence "promotes only from the browser that opens the address it prints" was false as of 0.21.0, and the 0.21.1 changelog corrects it.
The defect had two halves, and the ruling has to close both. 0.21.0's key was a credential in two places at once:
- The key was reusable. The address that carries it worked for the server's whole life, so the address a browser writes into its history stayed a way in.
- The cookie was the key. Whatever holds the key holds the cookie, so it
can skip the address and send
Cookie: digline-view-PORT=KEYdirectly.
Spending the key closes the first half and leaves the second wide open. A
spent key in History is still a valid cookie, and the process that reads the
file promotes exactly as before, one request shorter. The ruling as first given
on 2026-09-27 was "consume the key on its first hand-over", and that ruling
was incomplete in exactly this way. It named the half that is visible in the
measurement, and not the half that makes the measurement's repair mean
anything. It is recorded here because the gap is the kind that survives
review: a fix for the first half passes every test that re-opens the address,
and fails only the test that never opens it.
The ruling, both halves:
- The key is spent on its first successful hand-over. That closes half 1,
and it works because of when the address reaches disk. The same measurement
found the key not on disk at 8, 15, 30, 45 or 60 seconds while the
browser ran:
Historywas locked, and the row appeared at exit. The hand-over happens the moment the address is opened, milliseconds after the page is requested. So by the time any file holds the key, the key opens nothing. The repair rests on that ordering, and the ordering is measured rather than assumed. - The cookie carries a second secret, minted at the hand-over, and never the
key. That closes half 2. The session comes from the same generator
(
secrets.token_urlsafe(32)), and it is what/promotecompares against. After its first use, the key is not a credential in any header. The two secrets name different things: the key names a start, the session names a browser. Only the second is ever worth sending again. - Once, under a lock.
ThreadingHTTPServeranswers requests on separate threads, so two hand-overs racing for one key must not both win. The check and the spend happen under one lock. - A spent key is refused by name. "this address has already been opened … start digline view again for a new one". That is distinct from a key from another start, which keeps its sentence. Neither sets a cookie.
What it costs, said so it is not discovered.
- A second browser cannot be added by pasting the address. It needs a restart and the new address. The Consequences bullet that said otherwise is withdrawn.
- Opening the address in the wrong browser first uses it up. Same remedy.
- Anything that fetches the address before the person does takes it. A link-preview or prefetch in the terminal, or an omnibox prediction, would spend the key and leave the person refused. That fails closed: the person gets a refusal, not a stolen promotion, unless the prefetcher is itself the hostile process, and a process that can read the terminal already holds the address (§5, a redirected startup line). Not measured in any terminal.
K-3, carried in the same change: the refusal said the wrong cause. The
cookie header was read with http.cookies.SimpleCookie. That parser stops at
the first value it cannot parse, for example a JSON value or a space in a
cookie some other app on 127.0.0.1 had set. After such a cookie, digline's
own cookie was silently not read. The person who had opened the address was
then told promotes only from the browser that opened the address, a sentence
asserting the one cause that was false.
The cookie is now read by splitting the header on ; and matching the name
exactly, pair by pair, ignoring pairs it cannot read. The refusal now says what
the request carried:
- no cookie of this server's, or
- one that this start did not issue.
It never says what the browser did, because the server cannot know that. This is the week's family again (§10): a message that answered a question other than the one the request asked.
Still out of scope, and still in §5: the cookie store, the server's memory, and K-2. The session secret travels to every port on the host exactly as the key did. It is a same-user process that must also get the person's browser to visit it, and §5's fourth bullet keeps it there.
How it is held. New tests, each read against a mutation:
test_the_launch_key_opens_one_browser_and_then_nothing: a second hand-over with the same key is refused and sets no cookie.test_the_key_itself_is_not_a_cookie: the key sent as the cookie is refused, before the hand-over and after it. This is half 2, and it is the only test here that never re-opens the address.test_two_hand_overs_racing_for_one_key_give_one_cookie, over HTTP. It does not catch the lock's removal: measured, all passed with the lock deleted, because four requests rarely land inside a window of microseconds. So there is alsotest_the_spend_is_one_step_under_the_lock. It slows the minting of the session on purpose, which widens the window, and it fails without the lock.test_a_foreign_cookie_before_ours_does_not_hide_it: K-3.test_the_refusal_says_what_the_request_carried.
Mutations run against the finished code, each caught:
| Mutation | Tests that failed |
|---|---|
| The key not spent | 2 |
| The cookie carrying the key again | 2 |
| A foreign cookie hiding ours | 1 |
| The lock removed | 1, the deterministic test only |