Trust & execution
Some runners can only be read by running them. Reading moon’s task graph
evaluates the target repository’s toolchain configuration, and reading mise’s
tasks can fire that repository’s [hooks]. Pointing this tool at a repository
you did not write is therefore code execution.
allowExec alone cannot gate that. A setting a repository can write is a
setting a repository can grant itself — waypost.config.json lives inside the
repository being scanned. So the CLIs ask a person once, per repository, and
remember the answer outside the repository. That record is trust.
The gate
Section titled “The gate”Execution needs two answers, not one: allowExec must be on, and the
repository must be trusted. One function combines the configured value, the
flag and the trust store, so the two CLIs cannot drift:
| Flag | Configured | Trusted | Execution | Reason |
| ----------- | ---------- | ------- | --------- | ----------------- |
| --no-exec | any | any | no | configured-off |
| --exec | any | any | yes | allowed-by-flag |
| (none) | false | any | no | configured-off |
| (none) | true | no | no | untrusted |
| (none) | true | yes | yes | allowed |
--no-exec short-circuits without consulting the store at all: the answer
cannot change, and a person turning execution off should not have their trust
state read to be told so.
--exec never writes the store. It is consent for one run — a human typed it,
with intent, at the moment of running — which makes it the escape hatch for
scripts and CI. Persisting it would turn one deliberate run into a standing
grant nobody remembers giving.
For how the configured value itself is settled, see
Configuration. A true there is willingness in
principle; it is not permission to run this repository’s code.
Trusting a repository
Section titled “Trusting a repository”way trust
Both CLIs have the command, and both read the same store, so a repository trusted with one is trusted by the other. The options:
way trust # trust the repository at the resolved root
way trust --untrust # remove this repository's trust
way trust --show # report this root's trust status
way trust --list # every trusted path, one per line
way trust --json # the same answer as one JSON object
way trust -C PATH # the directory to inspect (default: cwd)
way trust --help # this text
--json is not a fourth mode — it asks the same question in the machine form,
so it combines with each of the others. The three root-scoped modes share one
shape, {"root": …, "trusted": …, "changed": …, "by": …, "rule": …},
so a caller never branches on the flag it just passed; --list answers a
different question and gets {"trusted": [paths]}. by is
"marker" | "path" | "owner" | null — which layer decided, see
The policy below — and rule is the matching
trust.paths/trust.owners entry, present only when by is "path" or
"owner". An --untrust that found nothing still prints its document, and
reports the miss through exit 1 and stderr.
The three mode flags ask different things, so giving a pair is a usage error
rather than last-one-wins. Trusting an
already-trusted repository says so and changes nothing, so you can tell that
nothing was just granted. --untrust on a root that was never trusted exits
1; --show exits 0 either way, because “not trusted” is a fact the command
was asked for, not a failure.
The terminal UI has the same grant on a key — the key line at the foot of the
screen names it. It arms a y/n question rather than granting outright, and
it is offered only when trust is what is missing. When
a configuration file turned execution off, trusting the repository would
change nothing, so the UI does not offer it. The web view is the same: a
Trust this repository button appears beside a runner that was withheld,
and the server refuses a grant that would not change this run’s gate.
When runners are withheld for want of trust, the sentence you will see is:
this repository is not trusted — run "waypost trust" to trust it, or pass --exec for this run only
It names the tool that printed it, so the library’s own CLI says its own name there.
The policy
Section titled “The policy”A root can also be trusted with no marker at all, through a trust block in
the user layer of the config file — never the project layer, the same
“a repository cannot grant itself” rule allowExec: true follows:
// ~/.config/waypost/config.json
{
"trust": {
"paths": ["/home/me/work/**"], // glob over the resolved root
"owners": ["my-org"] // case-insensitive origin remote owners
}
}
Trust is decided in order — a marker in the store, then trust.paths, then
trust.owners — by @sksizer/sdlc-tool’s
policyExecGate, the substrate detect-runners and waypost mount on top of
the marker store. The glob dialect (*, ?, **, a trailing /** never
matching the directory itself), the origin-owner lookup and the threat model
behind matching on it are documented once, in that package’s README; waypost
adds no vocabulary of its own beyond expanding a leading ~ in a
trust.paths entry against $HOME.
trust --show reports which layer decided (by/rule, above). The plain
grant still writes a marker on a policy-trusted root — a standing policy can
change out from under a repository, and the marker is the more durable of the
two — and --untrust says when the policy still covers the root after the
marker is gone.
Grants made before this existed keep working. The store’s location and marker-file format are unchanged, so a repository trusted with an older version of these CLIs is still trusted.
What a decision is keyed on
Section titled “What a decision is keyed on”The absolute, symlink-resolved repository root. Two paths that name one
repository — a symlinked checkout, a /tmp that is really /private/tmp —
are one decision, because a person answering the question answered it about
the repository, not about the spelling they used to reach it.
The key is a path, not a hash of the repository’s contents. The tradeoff is
worth stating plainly: a repository you trusted that later pulls hostile
changes stays trusted, and --untrust is the only remedy. The alternative is
worse. Content-hashing every runner configuration in a monorepo would
re-prompt on ordinary edits, and a prompt that fires on every edit trains
people to approve without reading — which is weaker security than an honest
weaker rule.
Where the store lives
Section titled “Where the store lives”$XDG_STATE_HOME/waypost/trusted/, or ~/.local/state/waypost/trusted/ when the
variable is unset. XDG state, not config: trust is state a program
maintains, not configuration a person hand-edits, and it deliberately does not
sit beside the settings file. A trust grant living in a file people edit and
sync between machines would travel with a dotfiles repo, and a per-machine
consent record that follows you onto a new machine has stopped being consent.
XDG is used on every platform, macOS included, for the same reason the
configuration paths are. It is a choice, not a universal macOS convention:
gh uses these same three directories on macOS and this follows its layout,
while mise keeps its cache under ~/Library/Caches. Setting XDG_STATE_HOME
moves the trust store wherever you want it. A leading ~ in that variable is
expanded, since direnv, .env loaders and CI YAML all hand the literal tilde
through.
The store is a directory of marker files, one per trusted root. Creating and
unlinking a file are atomic and independent, so two processes trusting two
different repositories at the same moment cannot lose each other’s write. A
marker is named ${basename(root)}-${sha256(root).slice(0, 16)}: the hash
disambiguates same-named repositories, and the basename keeps an ls of the
store legible. Its contents are the resolved absolute path. The write goes
through a temp file and a rename, so a partial write can never be read as a
trust grant — and an empty or blank marker is not a grant either.
The cost of that design is a directory of opaque names rather than one
readable file, which is what --list is for.
The cache is not the trust store
Section titled “The cache is not the trust store”Probe results are also stored, under $XDG_CACHE_HOME/waypost/ (or
~/.cache/waypost/) — see The cache. The two are
deliberately in different places and must not be confused.
The trust store is XDG state because losing it would silently un-trust repositories, and a consent record a cache sweep can erase is not a consent record. The result cache is XDG cache because losing it costs one slow probe and nothing else.
The cache is no part of this gate:
- A cached answer is never a trust grant. The gate is resolved fresh on every
run, from the configured
allowExec, the flag, and the trust store. - Clearing the cache never changes what is trusted, and trusting or untrusting a repository never reads or writes the cache.
- A runner withheld for want of trust is never stored. Nothing was measured, and storing it would let an untrusted run poison the answer a later trusted run should have given. An errored report is not stored either.
- Under an
allowExecoffalse, a cached read reports each exec runner as fresh and carries the same withheld report a real collection would have produced, from the same helper — so the two paths cannot describe the gate differently.
Removing what is stored
Section titled “Removing what is stored”Three things live outside your repositories: the cache, the trust store, and
your user configuration. clean is how they go away.
way clean # show each store, where it is, and what it holdsway clean --cache # stored probe resultsway clean --trust # forget which repositories you have trustedway clean --config --yes # your user configuration fileway clean --all --yes # all threeway clean --json # the same answer as one JSON object--json answers with a stores array of
{key, flag, what, path, bytes, removed} — all three on a bare report, only the ones
you named on a removal. bytes is null when nothing was stored, which separates “there was nothing
to remove” from “it would not delete”; both are removed: false. The closing note about trust goes
to stderr under --json, so stdout stays one parseable document.
Bare clean reports and removes nothing. A command whose subject is deleting
should not delete by default, and putting the safe behaviour behind a
--dry-run flag only means forgetting to pass it.
--config and --all additionally want --yes, because the three stores are
not equally replaceable and the guard follows that rather than being uniform:
| store | what losing it costs | | ------ | ------------------------------------------------------------ | | cache | one slow probe | | trust | re-answering a question you already answered, per repository | | config | everything in it — it is the only one you wrote by hand |
Nothing inside a repository is ever touched. A project’s own
waypost.config.json belongs to whoever wrote it, and a tool that deletes files
from a checkout because it was asked to clean its own data has misunderstood
whose data it is.
detect-runners writes the same three stores and ships the same command, so
clearing them with one tool clears them for the other.