Skip to content

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.

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.

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.

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.

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.

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

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 allowExec of false, 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.

Three things live outside your repositories: the cache, the trust store, and your user configuration. clean is how they go away.

Terminal window
way clean # show each store, where it is, and what it holds
way clean --cache # stored probe results
way clean --trust # forget which repositories you have trusted
way clean --config --yes # your user configuration file
way clean --all --yes # all three
way 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.