Skip to content
jooizePublic

About

Review-and-pin trust records behind a human gate: root-owned slots declare, the live world must conform, anything undeclared refuses.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

292 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pinned

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.

Commands

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:"'

Design points

  • Pins live in per-user slot directories: /var/db/pinned/<user>/slots/ <encoded-path>/, each holding exactly one of rev.<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.sha256 verifies with no pinned involved) or tombstone (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. ls reads 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) and policy/ (what may be trusted: allowed_signers and ignorable.json). A lane's launch payload carries its own slot dirs and policy/, 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 is verify) 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. pinned has no lane-conditional branch.

  • First approval of a repo shows the full tree (diff from the empty tree) unless --trust is 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 (its ancestor: row) plus both sides for diverged (an empty pin..candidate listing 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, status and rev answer 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).

  • verify is 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 with cat (which adds 16, "no stored witness") and rev (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 model and effortLevel churn hourly should not summon a ceremony hourly, and those keys carry no hardening. A slot may therefore declare ignored.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.sha256 still 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 as ignored-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> and pin.<algo>: ignored.json is the shipped grammar, and an ignored.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 .json suffix has to be truthful, because that array is exactly what jq's delpaths takes (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 (or a.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 approved exists 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 --stream emits 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 → none when 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 --store or --no-store on 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.
    • --keep on a --file is 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-key on the same --file adds 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 --keep as 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 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/b covers /a/b and /a/b/c and never /a/bb -- the same rule list --under uses. 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 the ignorable ceremony. 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 list prints 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.
    • verify re-checks at use time: every key recorded in ignored.json must 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.
  • 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 rekey is 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 sign creates 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-tag verifies such a tag against root-owned allowed signers -- the slot's signers/allowed_signers first, the user tier's policy/allowed_signers as 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 -- stock ssh-keygen -Y verification, nothing pinned-specific); any repo with signed releases works with no pinned-specific conventions. Second-machine bootstrap: clone anywhere, install the signer key once with pinned 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 file pinned signer path names to review --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> (--release for 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.tree to the empty tree (so no .gitattributes can 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/config can define an external diff driver or textconv filter, selected by a .gitattributes that 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 way show did. 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 what shasum -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/-512 need b2sum (GNU coreutils) and blake3 needs b3sum, 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 (cat serves 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 through sgit, 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 of review --file to 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, the old:, new: and change: rows, the show the diff gate, 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 with skipped; nothing shown when 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 bare pinned is fine.

The record and its witnesses

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.

The consumer ladder

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:

  1. It can read a path you nominate -> point it at root custody: the slot's approved file, 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.
  2. 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.
  3. 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.

Glossary

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.

Display conventions

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_COLOR for 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 \xHH for 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 --messages a tab is layout, and a file or body whose every LF has a CR before it is drawn with plain breaks and labeled CRLF line endings once, outside the text (any other CR escapes). A literal backslash that would read as an escape (before u{, or before x and 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 \xHH and 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 in man 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 its file: 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 list rows 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 pin
    

    The text view promises no format; programs read list --format json.

  • Content highlighters are routed by suffix alone (.json, .bash and .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 stop before an action, review this range; Enter open · n skip before 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] under candidate:, tombstone this path? [y/N] under path:), 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: and candidate: 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, y and yes continue; n declines 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 yellow not an answer says 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: skipped a review, before looking; stopped a run gate; aborted a ? [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 -RF through a fixed trusted path -- never $PAGER or user config, since the pager sits between reviewed bytes and eyes. -F means 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's TERMINFO and TERMINFO_DIRS never reach it: it finds the terminal's description in the system database, after the root-owned directories the PAGER_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 -4 row 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.

Bootstrap without executing unverified code

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.

Declarative install (nix-darwin / NixOS)

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:

  1. 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
    
  2. 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.rootOnlyOwners is the one widening of verify'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 -- locked adds 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 the PINNED_ROOT_ONLY_OWNERS= line in your copy (space-separated account names) before pinned 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 what verify tolerates. nix/module.nix is short: read it.

  3. 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.

About

Review-and-pin trust records behind a human gate: root-owned slots declare, the live world must conform, anything undeclared refuses.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages