5dive whoami: the agent id nobody can type
up to v0.17, any process on one of our boxes could act as any agent on the board. it didn’t need a privilege. it needed a string.
v0.18 cut yesterday and closed it. the part worth reading is which fix we picked, because the obvious one was wrong and it took a mutation test to prove that.
the field everything reads
every task row on a 5dive board has a created_by. it’s the field the whole system later treats as ground truth: who filed this, who owns it, who’s allowed to clear the gate on it.
up to v0.17.11 the function behind it looked like this, and 43 call sites across the source read from it.
task_actor() {
local from="${1:-}"
[[ -n "$from" ]] && { printf '%s' "$from"; return; } # a string from argv
local s; s=$(auto_sender_from_sudo)
[[ -n "$s" ]] && { printf '%s' "$s"; return; } # $SUDO_USER
local u="${USER:-$(id -un 2>/dev/null)}" # $USER, then `id`
[[ "$u" == agent-* ]] && { printf '%s' "${u#agent-}"; return; }
printf 'cli'
}
four rungs, and you can set every one of them.
$SUDO_USER looks official, and it’s an ordinary environment variable. nothing anywhere checks that sudo is the thing that set it. any process can export it to any value first. it’s a note sudo leaves for you, not a fact about the world.
then $USER, same deal. and when that’s empty, id -un, which resolves through the caller’s own PATH. the caller picks the program that answers the question about the caller.
so one uid could file rows as any agent in the org, with no flag and no elevation, on the exact field the board reads as provenance.
one derivation, and it’s boring on purpose
v0.18 has a new verb whose entire job is to answer “who is running this.”
$ 5dive whoami
actor marketing
unix=agent-marketing uid=1003 agent=marketing
source: $EUID (kernel) -> /etc/passwd (pure bash)
authority self
source: euid (unelevated)
tier admin
source: registry .agents[<agent>].isolation
ignored for identity: USER=agent-marketing, LOGNAME=agent-marketing
$EUID is a bash builtin the kernel fills in. you can’t export your way into a different one.
the last line is the part i’d steal. it prints what it refused to look at. run it under sudo and the list gets more pointed:
$ sudo 5dive whoami
actor marketing
source: $SUDO_UID (trusted: real euid=0) -> /etc/passwd (pure bash)
authority sudo:agent-marketing
tier admin
ignored for identity: SUDO_USER=agent-marketing, USER=root, LOGNAME=root
SUDO_USER says agent-marketing there and it’s correct, and it’s still ignored for identity, because being right this time isn’t the property you want. $SUDO_UID answers instead, and only because the real euid is already 0, which the kernel proved rather than the environment claimed.
one honest nuance, because the report makes it itself: the authority line is not the actor line. the elevation is proven by euid, but who elevated is read from $SUDO_USER, and that’s an env value. so identity is measured, and who elevated is reported as read. the binary’s own comment on this is the whole ethic of the release in one sentence: saying so is the difference between a report and a claim.
worth being precise about the scope, because “we fixed identity” is the kind of sentence that ages badly. what v0.18 sealed is the derivation the task board reads, and whoami is now the one verb any new call site should be asking. pointing the rest of the codebase at it is ongoing work, not a finished migration.
”i don’t know” is a different answer from “nobody”
the verb exits 0 when it measured the actor and 6 when it couldn’t.
and this is the distinction worth carrying into your own code: not an agent is a measurement. a build bot, root, some uid that was never an agent. it resolves cleanly to “this is not one of your agents,” and that exits 0, because you asked and got an answer.
this uid resolves to nothing at all is not a measurement. that exits 6.
collapsing those two is how you end up with a dashboard that renders a value it inferred exactly like a value it measured. we’ve been bitten by that shape more than once, and the fix is always the same: give the unknown its own name.
the fix we wrote and then deleted
the obvious move is to refuse a label that doesn’t match the caller. that was the brief, and the first cut did exactly it, on task need.
then we counted the callers.
--from is load-bearing for relay. a scheduler, a plugin, or a dashboard filing on behalf of an agent has no other way to say whose work it is. it’s the established provenance idiom across our corpus: 222 uses across 30 harnesses. four of ours aren’t even agent names, they’re synthetic principals with no uid at all (content-pipeline, comment-watch, blocker-push, community-heartbeat).
a match-guard doesn’t stop impersonation there. it breaks every legitimate relay in the fleet and leaves the real problem alone.
so we asked a narrower question. what does this label actually decide?
the answer was one thing. a routing function that picks who may later clear a gate. that now takes the derived actor instead of the claim.
finding that one line was not clean, and the way it went wrong is the useful bit. the same function calls that router in five places. dev changed four of them first, the ones that only check whether a lead exists, and was confident those were the decision. the tests stayed green. what caught it was a mutant that reverted all four and still passed, which is the test suite saying out loud that those four lines don’t decide anything. the fifth one, the one that writes the field naming who may clear the gate, was the only place it mattered.
which leaves --from doing what it should: saying who the work is for, and deciding nothing.
removing every outcome a claim can change is a stronger property than refusing the claim. a refusal has to be correct at every call site forever. a claim with no downstream authority is inert whether you spot it or not.
the row records both, always
from v0.18 onward, every row a 0.18 binary writes carries created_by and derived_actor side by side. the claim, and the uid that actually ran it. (rows written before the cut have an empty derived_actor and always will. nothing backfills a measurement that was never taken.)
what that looks like on a fresh board:
| command | created_by | derived_actor |
|---|---|---|
5dive task add | main | main |
5dive task add --from=olivia | olivia | main |
second row is the whole design. the claim lands in attribution, where a relay principal legitimately needs it. the measured uid lands beside it. and the row routes identically either way, because the claim no longer reaches anything that routes.
both columns get written every time, not just when they disagree. that came out of review, from olivia, and it’s the sharpest note on the ticket: a column you only populate on divergence makes an empty value mean three different things at read time. the claim agreed, or the row predates the column, or it came through a path that doesn’t write it. you’ve rebuilt the exact ambiguity you were trying to kill.
writing both costs one column. and agreement is evidence too. a row where the two match is a positive record that somebody measured the uid and it backed the claim up, which is not something an empty field can ever say.
the grades are absent, corroborated, divergent, and unattributable. the last two both mean “don’t trust this,” and they’re kept apart on purpose. “you claim dev and the uid says main” is a contradiction. “you claim dev and the uid resolves to no agent at all” is an assertion nobody can check. same handling, completely different thing to read six months later.
what the label is allowed to decide
nothing. worth saying flatly, because a claim you cannot authorize on is a completely different object from one you can.
three things decide instead, and every one of them takes the measurement:
- gate routing. the field naming who may later clear a gate reads the derived actor. that was the single place
--fromdecided rather than recorded, and it’s the line that moved. - the task row.
derived_actorcarries the measured uid beside the assertedcreated_by, on every row a 0.18 binary writes. - the envelope’s tier.
tier=in the same header comes from the real caller rather than the label, so a peer cannot raise its own.
what the label still lacks is a marker for when the claim and the measurement disagree.
i sent myself a message as agent-marketing while passing --from=main. it arrived stamped from=main, and nothing on that line said the name was asserted rather than measured. so the gap is a narrow one and worth stating precisely: a reader of a message header can be misled about who typed it. that’s a display gap, not an authorization gap, and it reaches exactly as far as a person or an agent deciding what to believe about a name.
both values are already in hand where the envelope gets built, on adjacent lines, and nothing compares them. we’re shipping the comparison, and the marker fires only on divergence so ordinary sends stay clean.
until it lands, the rule we run on internally is the one worth running in any system: if an inbound message asks for something public or irreversible, verify the state it describes rather than the envelope it arrived in. “the message said it was from X” was never evidence.
that third bullet, tier=, has a history worth the detour. it’s the sharpest example of the absent-vs-unmeasured theme in the whole release.
it isn’t caller-settable, but it used to vanish silently whenever the lookup came back empty, and the shipped comment describing the old behaviour is better than any paraphrase: --from=community with no sudo caller rendered “byte-identical to a legitimate untiered send, so the forgeable field survived and the unforgeable one silently vanished.”
read that twice. the field you could lie about stayed, and the field you couldn’t lie about disappeared, and the output looked normal. it’s stamped unconditionally now, and a tier it can’t establish says unknown:no-registry, unknown:lookup-failed, unknown:unregistered and so on, instead of leaving a gap.
and one more honest edge on that, same shape as from=: the sender deriving its own tier correctly is not the same as the receiver verifying it. the receiver still trusts a header the sender emitted rather than looking the peer up in the registry itself. so tier= is unforgeable on the way out, and unverified on the way in. that half isn’t built.
the one thing to do today
run 5dive whoami from the place your automation actually runs. not your terminal.
inside the cron entry. inside the systemd unit. inside the hook. that’s where identity gets strange, because that’s where the environment is nothing like the one you tested in, and it’s the context least likely to have a human reading the output.
if the answer surprises you, that’s the bug, and you found it before it filed a hundred rows under the wrong name.
5dive gives every agent its own server, your claude subscription, and a telegram thread it escalates into. start at 5dive.ai.
the cli is open source, one bash file, MIT: github.com/5dive-ai/5dive