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.
pinned setup [--yes] self-install + digest-pinned sudoers
pinned review <repo>... [--tag <tag>] [--trust] [--step]
[--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)
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> ...]
[--file <path> ...] [--baseline-store <dir>] [--algo <name>] [--store]
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)
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> retire a pinned file that is gone
pinned rekey <old> <new> 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
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>')
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>]
grant/withdraw a key that a ceremony
may declare ignored; --under scopes
the grant to a directory's subtree,
omitted means everywhere
pinned ignorable list machine tier, user tier, and the
effective intersection, with scopes
pinned status <repo|file> record vs live state
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 list [--under <dir>] live pins: kind, digest, path
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 and cat
run as you: sign needs your SSH agent, show 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 opens with a full-caps alarm and shows its commits in the direction that makes them readable: the reversed range for backward, and the merge base 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). 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, and an
review without
--ignore-json-keyclears the declaration -- with a loud note whenever that narrows or widens what was there. The tag rule covers every extra a slot can hold: each is re-stated by every ceremony, so a review without--storedrops a stored copy too, and identical bytes are a no-op only when the declaration and the custody state are identical as well. - 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. -
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 (authoritative) ===; a caller's own preview opens with its own name and says(orientation preview). 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. - Colour roles are fixed: red = failure, green = success, yellow =
attention, cyan = identifiers (paths, hashes, keys, tags), dim =
secondary detail, bold = structure and authority. Colour is decoration
only: with a non-tty stdout (or
NO_COLORfor the content highlighters) every display degrades to byte-identical plain text. - Content highlighters are routed by suffix alone (
.json,.bashand.sh,.md) and insert escapes only between the original bytes; anything else, and any content awk could not carry through unchanged (no trailing newline, a NUL, malformed JSON), is shown raw. - Glyphs:
✓recorded/verified,✗refused,~matched with a declared tolerance. - Gates read one way everywhere. A
next:line states what happens next and names the one other answer:· n stopsbefore an action,· n skipsbefore a review. 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 — the gate asks again, in yellow and 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. - Structural views are derived; the raw byte diff is authoritative.
Section labels, changed-key summaries and shape lines (
3 hunks, +12/-4 lines) 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 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 ./pinned <verb>, and as
sudo bash ./pinned setup. Nothing is lost: the interpreter is
still named explicitly by the caller rather than resolved from a
user-writable PATH, 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.
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, 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.