Review-and-pin trust records behind a human gate. An agent can commit
anything to a repo you deploy from, or rewrite any file a gate reads;
nothing becomes trusted until a sudo ceremony says so. pinned review
shows the content root-side -- a repo's diff since the last approval
straight from the object store (scrubbed git environment: no user
config, no pager, no hooks), or a file's frozen bytes -- you approve,
and it writes the hash to a root-owned slot. Deploy tooling builds only
git+file://...?rev=<pinned hash>; file-gated consumers ask
pinned verify.
The design signature, and the test for every surface question: the
root-owned record declares; the live world must conform; anything
undeclared refuses. The slot declares the VCS (rev.git) and the
hash algorithm (pin.sha256) in its filenames -- never inferred from
attacker-writable content -- validation is exact per declaration, and
unknown declarations refuse outright.
Full reference: man pinned — installed by the nix module; in-repo:
man man/pinned.1.
Tab completion for fish and zsh lives in completions/, also installed by
the nix module; a manual install copies it onto fish_complete_path / fpath.
pinned setup [--yes] self-install + digest-pinned sudoers
pinned review <repo>... [--tag <tag>] [--trust] [--step]
[--messages] [--backward | --diverged]
human gate: review diff -> pin
(--tag: the tag's commit, not HEAD,
and the declared release name;
--trust: skip a first approval's
full-tree review, loudly;
--backward/--diverged: declare a pin
move that is not forward over the
commit graph -- the ceremony refuses
unless reality matches the
declaration, and says so loudly when
it does;
--step: reading aid for a large
delta -- walk the commits since the
pin oldest-first, one diff and one
confirmation each, pin advancing at
every yes, closing with a whole-run
summary; ends at HEAD or at --tag's
commit, declaring the name only if
reached; one whole-delta diff stays
the default;
--messages: print each commit's whole
message under its subject in the
commits listing, the body as a
document (tabs as layout, CRLF
stated once); the default is the
subject only)
pinned review <repo> [--tag <tag>] --words <w1-w2-w3-w4-w5-w6> --commits <n>
approve a forward move without the
diff when the commit's six words
match and <n> commits arrived since
the pin; no terminal, no y/N: the
words and the count are the answer
(man pinned, "Approving by words")
pinned review <repo> --signed-tag <tag> [--signed-tag <tag> ...] [--tag <tag>]
signature gate: verify each signed tag,
all naming one commit -> pin (--tag:
an unsigned name that must agree)
pinned review --file <path> [--baseline <copy>] [--ignore-json-key <key> ... | --ignore-none]
[--keep] [--store | --no-store] [--file <path> ...]
[--baseline-store <dir>] [--algo <name>]
file-pin ceremony: freeze, display
root-side, confirm, record the hash;
several --file share one sudo
(--ignore-json-key: keys whose later
drift verify tolerates, each of which
the ignorable policy must grant for
that path; --store keeps a copy of
the approved bytes in the slot;
both follow their --file; a slot
that keeps either today needs it
stated: kept, --keep (typed keys
add to the kept ones), or dropped
with --ignore-none / --no-store)
pinned verify <path> file-pin verdict for gates: 0 ok,
5 differs only in ignored keys,
10 no slot, 11 mismatch,
13 tombstoned-but-present,
20 missing, 30 mode
(--emit prints the verified bytes;
--frozen <copy> checks held bytes;
--baseline <copy> brings your own
witness for the 5 comparison, for a
slot that keeps none of its own)
pinned cat <path> the approved bytes from root custody,
on stdout -- the stored witness,
re-hashed against the record first;
nothing on stdout on any failure
(0 served, 10 no record,
13 tombstoned, 16 no stored copy,
20 live file missing,
30 slot invariant, 1 error)
pinned rev <repo> [--release] the pinned rev on stdout, from the
repo slot's record, validated
first; --release prints the
declared release name instead;
nothing on stdout on any failure
(0 printed, 10 no slot,
13 tombstoned, 16 no declaration,
17 record names another path,
30 record mode, 1 error)
pinned tombstone <path> [--yes] retire a pinned file that is gone
pinned rekey <old> <new> [--yes] re-key a record to the path its
content has moved to: the rev or
digest and every annotation travel
verbatim, the content at <new>
must already answer to the record
(repo: a work-tree root whose
object store holds the pinned
commit; file: a re-hash to the
recorded digest, or drift confined
to the keys the slot already
ignores), and <old> is tombstoned
pinned declare <repo> --release <name> | --no-release [--yes]
name the release a pinned rev
already is: the tag must already
resolve to the pinned rev (checked,
not taken -- the rev never moves),
and a deployer syncs the input's
ref= to it; --no-release clears the
declaration and the slot goes
rev-only; both state an end state
and are idempotent
pinned sign <repo> <tag> signed release tag at the pinned hash
pinned signer add|list|remove [--repo <path>] (--file <pubkey> | --key '<line>' [--yes])
allowed-signers ceremony:
fingerprint, confirm, write
pinned signer path [--repo <path>]
the allowed-signers file a signed
tag verifies against (per-repo
override if it exists, else the
user tier); nothing and exit 1
when neither exists
pinned ignorable add|remove <key> [--under <dir>] [--yes]
grant/withdraw a key that a ceremony
may declare ignored; --under scopes
the grant to a directory's subtree,
omitted means everywhere
--yes (on tombstone, rekey, declare,
ignorable, signer --key) answers
the root-side y/N in advance; whether
sudo authenticates is your system's
policy, never the confirmation
pinned ignorable list machine tier, user tier, and the
effective intersection, with scopes
pinned status <repo|file> record vs live state
pinned words <repo> <rev> the six words of <rev>, its full id,
the review --words command that
approves it, and the commits since
the pin (for whoever made the commit)
pinned show <file|repo> [--algo <name>] [--length <bits>]
trusted re-display, no record: a
file is one read, shown and hashed;
a repo is the tree at its pin, from
the object store
pinned diff <old> <new> the change from one file to another,
shown exactly as review --file shows
a file's change (rows, gate, escapes,
colors, pager, closing rule); reads
the two files and nothing else
pinned list [--repos|--files] [--stale] [--under <dir>]
[--format text|json]
your pins and their live state:
repos and files at a glance, json
for programs (every field of one
pin: status)
pinned slot <path> print the slot directory for any
path -- repo, file, existing or
not; a name, never an existence
answer (exit 1 only if the path
cannot be resolved)
pinned --version print the release version
review, setup, tombstone, rekey, declare, signer add/remove and
ignorable add/remove self-elevate via sudo (re-exec of the installed
root-owned binary).
sign, show, diff and cat
run as you: sign needs your SSH agent, show, diff and cat write nothing. The
verb triple: show rehearses (no record), review records, verify
answers -- humans review, machines verify; the record happens only in
the review ceremony. The triple covers both kinds: show <file>
rehearses a hash gate, and show <repo> re-displays the tree a pin
already names,
through the same hardened git path the ceremony used. cat is
custody's reader, and reads nothing else.
Approval history: log show --predicate 'eventMessage CONTAINS "pinned:"'
-
Pins live in per-user slot directories:
/var/db/pinned/<user>/slots/<encoded-path>/, each holding exactly one ofrev.<vcs>(repo pin, one full hash),pin.<algo>(file pin, one pure shasum-style check line carrying the live absolute path --shasum -a 256 -c pin.sha256verifies with no pinned involved) ortombstone(one ISO line; existence is the semantics), plus optional annotations (tag,ignored.<format>,approved,signers/allowed_signers). Two state files at once is a malformed slot: every consumer refuses.lsreads a slot's whole state at a glance.<user>/is 0750 root:_<user>-pinned-- the group is consumed from the system config, never created, and its absence fails closed to root-only 0700. -
The tier has exactly two wrapper dirs, and they are the selection menu:
slots/(what is pinned) andpolicy/(what may be trusted:allowed_signersandignorable.json). A lane's launch payload carries its own slot dirs andpolicy/, never<user>/itself, which would disclose every pinned path name. Two rules produced this shape, and both rule out loose files directly under<user>/:- Directories are the transfer unit, never files. Every write here is an atomic rename over a staged temp file, so the name gets a new inode. Any consumer that binds a file (a root-owned symlink, a hypothetical file mount) would pin the pre-ceremony inode forever; naming the enclosing dir re-reads the name every time. Payload snapshots copy whole dirs for the same reason: a dir is a complete, self-consistent slot state.
- One dir per disclosure class. Policy is small, boring and safe
to expose; the slot list is itself information. Flat files would
force a lane that needs the policy to carry the slot list with it.
Consumers that want a friendly path use a root-owned symlink:
sudo ln -s "$(pinned slot <repo>)" /etc/nix-darwin/pinned-rev. Slot names never leave pinned:pinned slot <path>resolves any path -- repo or file, existing or not -- to its slot directory, so a lane launcher selects its launch payload with it instead of reimplementing the encoding. It resolves a name and nothing more: it does not say whether the path is pinned (that isverify) or whether the directory exists (the caller's own-d).
-
Lanes get copies, not the ledger. A lane receives a launch-time snapshot of just the slot dirs it needs, installed at the verbatim host paths by the lane's own root -- so the records are genuinely root-owned where they are read, and verification is the same strict check everywhere.
pinnedhas no lane-conditional branch. -
First approval of a repo shows the full tree (diff from the empty tree) unless
--trustis passed, loudly. -
The ancestry lattice. A ceremony that moves a pin first establishes which way it is moving, over the commit graph -- never by parsing a version string, because tag and branch names are repo content and version sort is only a convention. Four classes:
equal(a no-op),forward(the candidate descends from the pin),backward(the candidate is an ancestor -- the commits between are being un-approved),diverged(neither -- the pinned line of history is being abandoned). Forward proceeds; the other two refuse unless the human declares them (--backward,--diverged), and a declaration reality contradicts refuses too, naming the class that actually holds -- declared, never inferred. A declared move shows its commits in the direction that makes them readable: the reversed range for backward, and the common ancestor (itsancestor:row) plus both sides for diverged (an emptypin..candidatelisting would say nothing about what is being withdrawn, which is the one thing a review may never do). Its full-caps alarm is a verdict, so it prints below the diff's closing rule, directly above the question. The floor holds for signature evidence too: a signature says who vouched, never which way the pin is moving, so a replayed signed release of an older version is exactly what it catches. It governs ceremonies only --verify,statusandrevanswer about a pin already recorded and are untouched. -
The file-pin ceremony (
review --file) takes no hash argument, ever. A hash handoff would let a caller in a poisoned environment feed root an opaque digest to record sight-unseen; instead the file is frozen and displayed exactly once, root-side, after sudo's environment reset, and the recorded hash is taken from the displayed buffer -- record == seen, by construction. A caller's own display (diffs, structural views) is pre-sudo orientation, never what the record binds to. The ceremony shows a diff instead of the full file when it has a copy that re-hashes to the previously recorded digest: the slot's own stored copy first, then the file's--baseline <copy>, then the--baseline-store <dir>entry at<dir>/<slot-name>/<digest>(one directory named once per ceremony; the slot name and digest are pinned's own). -
verifyis the one state table. Consumers never re-derive slot semantics; they read verify's exit code (0/5/10/11/13/20/30, stable API). The decade is the action class and the taxonomy is shared withcat(which adds 16, "no stored witness") andrev(the repo record's reader, where 16 is "no declared release name"); 12/14/15 are retired numbers that are never reused. The numbering rule, settled: renumber wholesale when coherence demands it (as the sweep into decade classes did), never backfill a retired slot piecemeal -- a retired number is one some deployed consumer still remembers, and giving it a new meaning makes a running gate misread a verdict it thinks it understands, silently, until that consumer is redeployed. Parsers use--emit(print the verified bytes, nothing on failure) or--frozen <copy>(verdict on caller-held bytes) so the bytes acted on are the bytes verified -- never verify-path-then- read-path. File modes are checked as an invariant (owner is the tier user, no group/other write), not pinned as a value: content addressing catches rewrites; the owner can always chmod back. -
Ignored keys: a settings file whose
modelandeffortLevelchurn hourly should not summon a ceremony hourly, and those keys carry no hardening. A slot may therefore declareignored.json-- a JSON array of jq key paths, e.g.[["model"],["statusLine","command"]]-- written only by the ceremony (review --file <path> --ignore-json-key model --ignore-json-key effortLevel). Semantics, in one sentence: the pin stays byte-exact and ignoring is a verify-side tolerance.pin.<algo>is still the hash of the approved bytes,shasum -a 256 -c pin.sha256still cross-checks it with stock tools, and nothing about what gets hashed changes. What changes is the answer to a mismatch: verify may compare the two documents with the declared keys projected out, and, if everything else is identical, answer 5 ("matches modulo declared ignored keys") instead of 11, naming each key that actually moved on stdout asignored-drift: <key>. Consumers treat 5 as permitted and unknown codes as refusal, exactly as before.- The format is declared by the suffix, like
rev.<vcs>andpin.<algo>:ignored.jsonis the shipped grammar, and anignored.toml/ignored.yaml/ anything else refuses rather than being guessed at. That refusal is also the extension point -- adding a format means adding a reader and a flag, never inferring one. The content must be an array of nonempty arrays of strings; anything else (including an empty array, which would tolerate nothing) refuses. - The record is real JSON because the
.jsonsuffix has to be truthful, because that array is exactly what jq'sdelpathstakes (so the comparison consumes the record with no translation step), and because a JSON object key may contain any character at all -- dots, spaces, parentheses ("Bash(git status:*)"is a real settings key). A dotted line cannot spell those. Generality lives in the storage, convenience in the human surface: the CLI still takes--ignore-json-key model(ora.b.c), displays join paths back with dots, and dot-splitting happens only at that surface. - CLI key grammar: dotted
[A-Za-z0-9_.-], no leading/trailing/doubled dot, keys only. No array subscripts: an index is a position, not a name, and a tolerated position silently moves when something is inserted before it. - Comparing needs the last-approved bytes -- a witness (see "The record
and its witnesses" below). Either the slot keeps one of its own
(
review --file <path> --store) or the caller brings one (verify --baseline <copy> <path>); with neither, verify stays at the byte-exact 11. A caller-brought copy must re-hash to the record before it is used -- the same self-verifying trick the ceremony's baseline diff uses, so a forged baseline can only make verify stricter. - If
approvedexists it must re-hash to the record beside it. A violation is a malformed slot (hard error, exit 1) rather than a degraded comparison -- what makes this harder than a declined witness is where the bytes are: root custody, which nothing unprivileged can have written, so root-owned bytes that are not the approved bytes are incoherent state, not weak evidence. Re-approve to reset it. - Parser differentials are the reason this path is so suspicious of its
input. A structural comparison is only as honest as the agreement
between the parser doing the comparing and the parser that will
actually read the file. So the tolerance path refuses -- loudly, back
to the byte-exact 11 -- on: duplicate object keys anywhere on either
side (jq keeps the last, other parsers differ, and guessing which one
a consumer keeps is exactly the uncertainty this tool exists to
avoid); more than one top-level JSON document (jq reads a concatenated
stream, everything else reads one value); NUL bytes (jq tolerates a
trailing one, other parsers do not); an unparseable side; a missing
baseline; a missing jq; and of course any difference outside the
declared keys. Duplicate detection is empirical, not assumed:
jq --streamemits one event per value occurrence while re-serializing through jq collapses duplicates to the last, so a differing event count proves a duplicate anywhere at any depth -- including duplicates whose values are objects, where the leaf paths differ and a path-multiset comparison would miss them. - The ceremony states the declaration prominently before the y/N, in
the attention color, and draws a change as
<from> → <to>on the same row (model → nonewhen it clears). The tag rule covers every extra a slot can hold: each is re-stated by every ceremony, and identical bytes are a no-op only when the declaration and the custody state are identical as well. - The restatement floor guards the tag rule. A review that leaves an
extra the slot keeps today unstated refuses before anything is shown:
a declaring slot needs
--ignore-json-key(keep) or--ignore-none(drop) on its--file, and a slot holding a copy needs--storeor--no-storeon its--file. Dropping is the strict direction, but a missing flag made the decision silently, and it surfaced only at the next drift as a review nobody expected. The refusal lists each kept extra with both flags, records nothing, and runs pre-sudo too, so it costs no authentication. --keepon a--fileis the one-word "as it is": the slot's keys and its copy stay, read root-side from the slot about to be written, and any--ignore-json-keyon the same--fileadds to the kept keys. To remove a key, list the ones to keep without--keep. The flag rides to the sudo dialog as typed; the review screen lists every key before the y/N and names--keepas the source. A kept key the policy no longer grants refuses, before sudo where the slot is readable.- A non-JSON file simply fails the parse step and always gets the byte-exact verdict; pinned never restricts which kinds of path may carry a declaration, only which keys (below).
- The format is declared by the suffix, like
-
The ignorable ladder. What a slot may declare is itself gated, because "which keys may drift" is exactly the decision an attacker would like to make for you. Two root-owned policy tiers sit above the declaration, and every rung is checked at review and at verify:
machine policy >= user policy >= slot declaration >= drift /etc/pinned/ <user>/policy/ slots/<enc>/ tolerated ignorable.json ignorable.json ignored.json by verify- Both tiers are arrays of entries:
[{"path":["model"],"under":"/Users/x/.config"},{"path":["effortLevel"]}]."under"is optional and means everywhere when omitted; present, it is an absolute prefix matched at a component boundary, so/a/bcovers/a/band/a/b/cand never/a/bb-- the same rulelist --underuses. No other object keys are accepted: one this version does not understand could be a narrowing constraint written by a newer one, and ignoring it would silently widen the grant. - The machine tier is optional (absent = no machine constraint) and is
the file a configuration manager declares (nix:
environment.etc). The user tier is the operative allow-list, managed by theignorableceremony. Absent or empty grants nothing, and an unreadable tier of either kind grants nothing either -- fail closed, in the direction that costs a ceremony rather than a tolerance. - Effective = the intersection: a user entry counts only if the machine
tier has the same path with a scope covering it (a user entry with no
scope is covered only by a machine entry with no scope).
pinned ignorable listprints all three -- machine, user, effective -- with each entry's scope, so "why was this key dropped" is answerable from one unprivileged command. review --file --ignore-json-key <key>refuses a key the effective policy does not grant for that path, loudly, and names the exact remediation (sudo pinned ignorable add KEY --under DIR). The semantics are uniform: pinned cannot tell a human's argv from a calling tool's, so "a human typed it" is never a reason to allow it. The ceremony display then states the declared keys' grant provenance on one line (granted by user policy, under /Users/x/.config: model), so the ladder is audited on screen while the y/N is asked.verifyre-checks at use time: every key recorded inignored.jsonmust still be within the effective policy for that path, or the tolerance is refused and the answer is a plain 11 (with a note naming the key that lost its grant). Narrowing the policy therefore bites at the very next verify -- no re-ceremony, no stale grant surviving in a slot nobody revisits.
- Both tiers are arrays of entries:
-
Tombstones are sentinel slot content, never slot deletion: a pinned file that vanished refuses until restored or ceremonially tombstoned, and a tombstoned path that reappears refuses until re-approved -- retired content resurrected must not read as merely new.
-
A path is a record's identity, and
rekeyis how an identity changes hands without trust changing with it. The record travels verbatim and the machine -- not the human -- establishes the one new claim it makes: the content already at the new path must be exactly what the record names, or the ceremony refuses. So the only thing the human confirms is the thing the ceremony displays, and nothing becomes trusted that was not trusted a moment earlier. The old slot is tombstoned (not deleted) by the same move, because something reappearing at a formerly trusted path is exactly what has to fail closed. -
Signing exports the pin.
pinned signcreates a perfectly normal signed release tag, but the hash it signs comes from the root-owned pin file: you read once at review; nothing is re-read at sign time, so a compromised environment has nothing to MITM (SSH-agent signing is blind -- the binding to content is this code path).review --signed-tagverifies such a tag against root-owned allowed signers -- the slot'ssigners/allowed_signersfirst, the user tier'spolicy/allowed_signersas fallback, so a key trusted for one repo doesn't implicitly vouch for every repo (OpenSSH's SSHSIG allowed_signers format, git's native SSH signing end to end -- stockssh-keygen -Yverification, nothing pinned-specific); any repo with signed releases works with no pinned-specific conventions. Second-machine bootstrap: clone anywhere, install the signer key once withpinned signer add(obtained out of band), approve the tag. -
One tag holds one signature. Sign an existing tag only if it already points at the pinned rev (promoting an unsigned release); moving a tag is refused. Co-signers use distinct tag names by convention (v1.2.3-alice, v1.2.3-bob) -- consumers approve whichever name they trust; allowed_signers is any-of. Threshold multisig: repeat --signed-tag (
review <repo> --signed-tag v1.2.3-alice --signed-tag v1.2.3-bob) -- every named tag must verify and name the same commit or nothing is pinned; k-of-n is the consumer demanding whichever k tags they trust. A deployer may offer that gate: route a stale repo whose newer signed release verifies against the filepinned signer pathnames toreview --signed-tag, where the ceremony's own[y/N]is the acceptance. -
pinned never executes what it approves, and it deploys nothing. A consumer reads the record through
pinned rev <repo>(--releasefor the declared name) and acts on exactly that rev, re-checking that a declared tag still names it; it never opens a slot directly. -
Rendered diffs are never trusted blindly, in three layers: every git call sets
attr.treeto the empty tree (so no.gitattributescan select a driver or filter for any subcommand -- the only repo-wide switch git offers); the review helper additionally forces--no-ext-diff --no-textconv; and reaching for any rendering subcommand (diff,show,log -p,format-patch, ...) another way is refused inside the script. Repo-local.git/configcan define an external diff driver or textconv filter, selected by a.gitattributesthat need not even be committed; both are attacker-writable, both are shell commands, and scrubbing the environment does not stop them. Unhardened, a driver can render any diff as arbitrary text -- a textconv mapping both sides to one constant shows an empty diff for a commit that changed everything -- and it executes during rendering, which for a tool that elevates before diffing means as root. -
pinned show <file>extends the same idea past git, for content that is gated by hash rather than by rev (e.g. a hook wired into a hash-checked settings file). The security-relevant act is the read: one read into memory, those bytes displayed, those bytes hashed -- never two reads with a swap in between. It prints the digest plus a ready-to-paste fail-closed wrapper, so the consumer hashes exactly the wayshowdid. Runs unprivileged (it writes nothing); it lives in a root-owned binary because a user-writable display script could show innocent bytes and hash malicious ones -- and unlike a falsified display, which fails closed at the next hash check, a falsified ceremony fails open. The digest equals whatshasum -a <algo> <file>reports, so the ceremony can be cross-checked with ordinary tools. Algorithms go by their standard names: sha256/sha384/sha512 work everywhere;blake2b-256/-384/-512needb2sum(GNU coreutils) andblake3needsb3sum, each installed system-wide. blake3 is an XOF, so its output size is a flag, not part of the name:--length <bits>(default 256). sha1/md5 and any digest under 256 bits are refused: this hash is the gate. There is deliberately no flag naming a hasher path -- whatever computes the digest decides whether the gate passes, so it must resolve inside the trusted PATH. -
pinned show <repo>closes the same gap on the repo side: after a pin exists, a file pin can be re-read through custody (catserves the stored witness), but a repo's content could only be re-read through ambient git -- exactly the falsifiable display the hardening above exists to refuse. It re-displays the tree at the pinned rev throughsgit, records nothing, and reads from the object store, so a dirty work tree changes not a byte of what is shown. Unprivileged is right here: content-addressing carries the trust (the rev names the bytes, and the pin it comes from is root-owned), and the root-owned binary is what keeps the display path itself unswappable. -
pinned diff <old> <new>lends the file diff ofreview --fileto a caller that already holds two files and needs to show the change between them -- a deployer showing an installed copy against a staged one -- so the caller carries no copy of this display that could drift from it. It is the same code path, not a second renderer: one read of each file, theold:,new:andchange:rows, theshow the diffgate, the diff through the escaper and colorizer, the pager and the closing rule. It reads the two files with the caller's own rights and nothing else (no sudo, no record, no policy, no audit log), refuses a missing, unreadable or non-regular file with nothing shown, says so in one line when the two are byte-identical, and exits 2 withskipped; nothing shownwhen the gate is declined; a caller across a process seam prints no decline line of its own. -
Trust prerequisite: the interactive flow assumes your terminal and shell honestly relay what you type and see. Shell configuration is user-writable state -- a compromised config can alias
pinned, fake any output, and no in-band check (absolute paths, verifier helpers) can prove otherwise from inside the session. The backstop that survives a lying shell is out-of-band: the sudo authentication dialog names the exact command it will run as root -- read it there. Given a trusted shell config, invoking barepinnedis fine.
Two kinds of thing live in a file slot, and keeping them apart is the whole model.
The record is what was approved: a digest (pin.<algo>) plus the
ceremony's declarations (ignored.json, tag). Root-owned, one shape
always, and the only trust anchor -- nothing else in this tool
authorizes anything.
A witness is any bytes that re-hash to the record's digest.
Witnesses are evidence, never authority: every reader authenticates a
witness against the record before using it, so where a witness was
stored, and whose hands carried it there, cannot affect a verdict. Two
arrive by different roads and are otherwise the same kind of thing --
the slot's own approved copy (root custody, written by
review --file <path> --store) and a caller-brought copy
(verify --baseline <copy>). A witness can only ever narrow a
comparison -- enable the ignored-key projection, feed the ceremony's
baseline diff -- and never stands in for the live file: a pinned file
that is missing is exit 20 no matter how many witnesses agree on what
it used to say.
Custody is therefore opt-in. A stored witness serves the tolerant
comparison, archives exactly what was approved, and feeds the display of
what changed -- but it also turns a slot that discloses one digest into
one that discloses the file's whole content. Disclosure is bounded (the
host tier is 0750 root:_<user>-pinned, and a consumer that mounts a
slot is given only the slot dirs it is measured against, whose files it
already reads through those same mounts), but bounded is not
nothing, so it stays a per-slot human decision at the ceremony. Being
per-slot, it is also per-slot reportable: pinned list annotates every
file row with the answer -- a stored copy, or the note that there is
none -- on an indented continuation line, so taking inventory never
means reaching into a slot directory to find out.
verify answers about a path; the consumer then has to use the content,
and the gap between the two is where a swap would fit. Three rungs close
it, in descending order of what the consuming software can be told to
do:
- It can read a path you nominate -> point it at root custody: the
slot's
approvedfile, reached through a root-owned symlink at the slot directory (sudo ln -s "$(pinned slot <path>)" /etc/<tool>/pin, then read/etc/<tool>/pin/approved) -- the dir, never the file, for the same reason every other consumer binds a dir: each ceremony renames a fresh inode into place. No gap at all: the bytes it reads are the bytes root holds, and it never sees the editing surface. - It can run a command ->
pinned cat <path>. Same bytes, with the re-hash against the record done for it, and a stdout contract: approved bytes or an empty stream, never a fragment. - It reads fixed paths by its own logic -> only the launch window needs guarding, and that is the consumer's own problem, not pinned's: verify immediately before handing control over, and accept that a rewrite after launch is outside what a pin can speak for.
Rungs 1 and 2 need custody (--store); rung 3 does not. Consumers that
must parse content and cannot do either use verify --emit or
verify --frozen, which bind the verdict and the bytes to one read.
The names above in one place -- the same bytes seen from different sides, which is exactly where they get confused.
- record -- the root-owned slot content: digest plus declarations (ignored keys, tag). Always the authority.
- witness -- any bytes that re-hash to the record, at the moment they do; evidence that narrows a comparison, never authority. Earned per use, not a stored status.
approved(the slot copy) -- the root-custody copy written at the ceremony, named for its provenance. Becomes a witness each time it re-hashes clean.pinned-baselines-- the consumer-owned candidate store (diff baselines for orientation displays); entries can go stale, and each becomes a witness only when a verify re-hashes it clean.- custody -- who holds bytes at rest (root slot copy vs consumer store): a disclosure and trust fact about location, distinct from the record's authority.
A trust ceremony is mostly a display, so the display has rules. They are recorded here because consumers print around pinned's output (the hardening repo's claude shim shows an orientation preview immediately before handing off), and two speakers sharing a screen must not read as one.
-
Banners name the program and bracket authority. The ceremony opens with
=== pinned: review, as root ===, printed by the root process as it takes the screen; a caller's own preview wears no===. Everything between a banner and the next one belongs to that speaker. One speaker per banner region -- pinned never prints inside a caller's block, and a caller never annotates inside pinned's. -
A caller's display is orientation; pinned's is authority. The ceremony's hash comes from the buffer the ceremony itself displayed, so a caller's richer preview (structural diffs, changed-key summaries) is a reading aid that no record binds to.
-
Field lines are lowercase
label:+ value, padded to one column (file:,approved:,sha256:,ignored:,state:). Stage-boundary statements are Sentence case sentences. Full caps only for a deliberate alarm (!!! FIRST APPROVAL WITH --trust !!!) -- never as generic emphasis. -
Color roles are fixed: red = failure, green = success, yellow = attention, cyan = identifiers (paths, hashes, keys, tags), dim = secondary detail, bold = structure and authority, reverse red = a hidden character shown escaped (and nothing else). Color is decoration only: with a non-tty stdout (or
NO_COLORfor the content highlighters, diffs and escapes) every display degrades to plain text, and no fact rides on color alone. -
Third-party text shows the characters that are there. Commit subjects and bodies, diffs and the paths in them, file content, JSON key names, repo and file names: every character that draws nothing, draws as something else or reorders what follows it (the control characters, bidi controls, zero-width and invisible spaces, variation selectors, tag characters) prints as an escape --
\u{HEX}in uppercase hex with no leading zeros (\u{9},\u{200B},\u{E0041}), and\xHHfor each byte that is not valid UTF-8 -- never raw and never dropped. Valid UTF-8 is decoded first, so only failing bytes are escaped. LF is the line break and a plain space is itself. In a single-line value (a subject, a path, an argument) tab and CR escape; in a diff body, a file shown whole or a commit body under--messagesa tab is layout, and a file or body whose every LF has a CR before it is drawn with plain breaks and labeledCRLF line endingsonce, outside the text (any other CR escapes). A literal backslash that would read as an escape (beforeu{, or beforexand a hex digit) is escaped too, as\u{5C}, so an escape on screen can only be one. Only the escape of a hidden character wears reverse red; an invalid byte's\xHHand the escaped backslash show in the color around them. One escaper, run on the raw bytes before any color, so no byte of the content can act on the terminal; if it fails, the review stops and nothing raw is shown in its place. The records keep their bytes. The full list is inman pinned, "Third-party text". -
Verdicts come last. pinned never wraps third-party text, and a line the terminal soft-wraps restarts at column 0, where it could pose as a row. So every listing and displayed body closes with a rule (
--- end of listing: 3 commits ---,--- end of diff: ... ---,--- end of file: ... ---), and pinned's judgments -- the backward or diverged alarm, signature verdicts, notes on a slot's state -- print below the last rule, under the rows the question decides about and directly above the question. -
A value whose length pinned does not choose ends its line. A subject, path, name or command line comes last on its row, and nothing is padded to its width; pinned reads no terminal width. A bounded value (a hash, a count, a fixed word) is a field and may sit mid-line, so a record verdict reads
✓ approved <hash>: <record path>. A word from a fixed set that qualifies a path is a column before it (path: gone /x/old.txt), and a sentence that qualifies one is a dim line under it. Section rules hold no value (--- exactly the bytes being approved ---): the path is on itsfile:row. Errors and refusals end on their value too, and a message about two such values gives each its own row. -
Transitions use
→. A bounded one stays on one row (pin: b1ea8c7d50 → 5915adef87); an unbounded one continues under its row, the arrow hanging in the gutter so the new value starts in the old one's column:✓ declared: v1.2 → /var/db/pinned/<user>/slots/<enc>/release -
pinned listrows read state first, as one word (current,changed,gone,broken) padded to the widest word in the section, then a repo's short hash, and the name last. A state that says more than its word says it on a dim line under its row, so one long reason widens no column:✓ current d221ac6 locked ∂ changed d526136 claude-code-hardening 2 commits past the pinThe text view promises no format; programs read
list --format json. -
Content highlighters are routed by suffix alone (
.json,.bashand.sh,.md) and insert escapes only between the bytes of the escaped text they are handed; anything else, and malformed JSON, shows with only its escapes styled. -
Diffs are colored by pinned, never by git, so every diff a review shows reads the same way. When a run of removed lines is followed directly by as many added lines, each pair gets a darker background on the part that changed, like Git's contrib
diff-highlight. A pair that shares nothing past its indentation is left unmarked. -
Glyphs:
✓recorded/verified,✗refused,~matched with a declared tolerance,→a transition. -
Gates read one way everywhere. A gate names what happens next, then its keys:
review 1 repo as root; Enter run · n stopbefore an action,review this range; Enter open · n skipbefore a review. A gate or a question never holds a path, a name or a subject: it names its object with a noun that matches a row's label (approve this candidate? [y/N]undercandidate:,tombstone this path? [y/N]underpath:), and each value it decides about stands on a labeled row above it. A question block reads top to bottom: the content and its closing rule, advice, the rows, the alarm or verdict and any consequence line, the question. A row repeats only when a listing, a diff or a pager stands between its first mention and the question (repo:andcandidate:after the diff); a first approval's gate stands directly under the identity block. Rows print when the action can go ahead, and a refusal prints none. The answer is typed at the end of the gate line. Enter,yandyescontinue;ndeclines every gate, because a gate has exactly two outcomes and one decline key is enough; end of input declines too, because a closed tty is not consent. Anything else is not an answer at all. A yellownot an answersays so on its own line and the gate is printed again, without echoing what was typed, so a typo or a stray paste can never pass for a yes and a pasted escape sequence can never rewrite the gate. A decline is quiet and yellow,<verb>; <what stays unchanged>, and the verb says what was declined:skippeda review, before looking;stoppeda run gate;aborteda? [y/N], after looking. The state half is the same words on both sides of the sudo seam, so one decline reads as one event. Every decline exits 2. -
Paging is
less -RFthrough a fixed trusted path -- never$PAGERor user config, since the pager sits between reviewed bytes and eyes.-Fmeans one-screen content prints inline and never takes over the alternate screen; longer content gets the alternate screen and real scrollback. The pager runs in a clean environment, so the caller'sTERMINFOandTERMINFO_DIRSnever reach it: it finds the terminal's description in the system database, after the root-owned directories thePAGER_TERMINFO_DIRS=line names (empty in the source; the nix module puts the system profile's terminfo there). A terminal without an entry in either gets less's dumb mode. -
Structural views are derived; the raw byte diff is authoritative. Section labels, changed-key summaries and the gate's
change: +12 -4row orient a reader; they can lie about where a change sits and never about what changed, because every changed line prints regardless and the digest comes from the bytes, not the view.
Every trust tool has a first-install chicken-and-egg: the only copy that exists lives in a user-writable checkout. You never have to execute that copy privileged, though. Copy it with the OS's own tooling (which moves bytes but runs none of them), then read the copy user-space can no longer touch, then run only what you read:
sudo /usr/bin/install -d -o root -g wheel -m 755 /usr/local/sbin
sudo /usr/bin/install -o root -g wheel -m 444 ./pinned /usr/local/sbin/pinned-unverified
less /usr/local/sbin/pinned-unverified # THE read that anchors trust
sudo mv /usr/local/sbin/pinned-unverified /usr/local/sbin/pinned
sudo chmod 755 /usr/local/sbin/pinned
sudo /usr/local/sbin/pinned setup # or: review REPO --trust
The copy is verbatim, and one of the things you read on the first line
is why: the shebang names /bin/bash absolutely rather than going
through env. pinned self-elevates by re-exec'ing itself under sudo,
sudo passes the caller's PATH straight through to the root process, and
env would resolve that root interpreter from the caller's PATH -- so a
user-writable prefix ahead of the system dirs (/opt/homebrew/bin on an
ARM Mac) would supply the bash that runs as root, and the sudoers digest
could not object: it pins the script's bytes, not its interpreter.
/bin/bash is root-owned on stock macOS (SIP-sealed) and stock Linux,
so naming it leaves nothing to resolve. setup re-checks this on the
file it is about to digest-pin and refuses if the first line ever names
env, a relative path, or an interpreter that is not root-owned.
The -p after the path is the same rule applied to bash's own startup.
Without it, bash runs the file BASH_ENV names, applies the options in
SHELLOPTS and BASHOPTS, and imports functions exported into the
environment (one named exec replaces the builtin), all before the
script's first line, so whatever set the invoking environment would
choose code that runs before pinned elevates. Privileged mode ignores
all four. On a system whose bash is not /bin/bash, edit the path on
that line to its root-owned bash and keep -p. setup refuses a first
line that is anything other than the interpreter followed by -p.
The staging keeps two invariants visible in the filesystem: the final
name only ever holds bytes a human has read, and the execute bit only
ever exists under the final name -- staged bytes cannot be exec'd at
all (mode 444; the kernel refuses). Promotion is mv + chmod, not
pinned code: trusted tooling, inode-preserving, so the bytes you read
are exactly the bytes promoted. mv before chmod -- a non-executable
verified file fails closed; an executable -unverified would not.
Never execute anything named -unverified (the x-bit stops exec, not
bash pinned-unverified -- the name rule still carries).
Reading the checkout beforehand is still sensible, but it can never be conclusive -- anything running as you can swap the file between your read and any use of it. The root-owned copy cannot change, so the read after install is the one that counts. Privileged execution then touches only system binaries (sudo, install, less, mv) and bytes you have read.
The direct route (running the checkout as root via setup or the pre-install fallback) still works and warns loudly; prefer this one.
On a machine with no /bin/bash (NixOS is the one that matters here) a
checkout cannot exec itself; run it as bash -p ./pinned <verb>, and as
sudo bash -p ./pinned setup. Nothing is lost: the interpreter is
still named explicitly by the caller rather than resolved from a
user-writable PATH, -p still keeps the environment out of its
startup, and the declarative install below is the intended route there
anyway, since the module rewrites the first line to the store bash at
build time, keeping -p.
The manual route above is the first-class citizen: it works on any
machine with sudo and needs nothing but this file. If a
configuration-management tool builds your system, pinned can instead
be installed by that tool from an approved rev -- setup is then
never needed, because the deploy does setup's three jobs (binary,
sudoers digest, pin root) declaratively. This repo ships a Nix flake
for that:
-
Approve the config repo using the bootstrap above -- review creates the /var/db/pinned tree itself, so setup never runs:
sudo /usr/local/sbin/pinned review REPO --trust -
Import the flake module and declare who may run it:
inputs.pinned.url = "git+file:///path/to/pinned"; # rev-locked in flake.lock # in the system config: imports = [ inputs.pinned.darwinModules.default ]; # or nixosModules.default security.pinned = { enable = true; users = [ "USER" ]; };The module installs the script into the system profile and writes /etc/sudoers.d/pinned with an eval-time sha256 of the exact bytes it installs -- digest and binary derive from one source in one build, so they can never disagree. It rewrites the shebang to the store bash at build time and keeps
-p, which is the same interpreter pinning the manual route does by hand, done by the packaging that knows its own root-owned prefix. It also declares the group the script consumes but never creates:_<user>-pinned, which makes that user's record tier readable. The directory is not created here -- the script provisions it root-side, so a manual install lands on the same path.security.pinned.rootOnlyOwnersis the one widening ofverify's owner invariant (exit 30): accounts no user can act as, whose files are therefore as unwritable to the tier user as root's, baked into the installed bytes at eval time. It is contributed by the module that creates such an account --lockedadds its lock account_<user>-lock-- so the nix route never types it by hand. A manual install has none unless you put it there: edit thePINNED_ROOT_ONLY_OWNERS=line in your copy (space-separated account names) beforepinned setup, which installs and digest-pins those bytes, so the edited line is covered like every other constant. An entry names an account you claim nobody can act as; one someone can act as widens whatverifytolerates. nix/module.nix is short: read it. -
Deploy (gated, builds the approved rev). The store-installed binary takes over; the bootstrap copy at /usr/local/sbin/pinned can be removed (
sudo rm) -- the module's sudoers entry names only the system-profile path.
Full design: ../claude-code-hardening/design/PLAN-pinned.md Family: ../locked (setup/verify patterns reused), ../sudowhat.