Skip to main content

E-06: Execution trust

Summary

Two built-in tools exist to run code a file chooses: external-command spawns a subprocess from its own config, and script evaluates recipe-supplied JavaScript. So does one format: exec reads content by shelling out. Together they are the exec class.

Every surface that can name an exec-class tool answers one question, did a person choose this?, and the answer is a property of the surface rather than of the tool:

SurfaceWhere the argv comes fromAnswer
kapi exec <tool>the command line the user typedruns; this is the choice
A kapi.yaml recipea file in the working directoryasks once, remembers, re-asks when the argv changes
A .kpz packagean archive from elsewherestripped on ingest
The engine gRPC APIthe networkrefused
The MCP agent surfacea modelrefused, including under --all-tools

Only the recipe row can ask, because it is the only one with a person present and a legitimate reason to say yes. The rest have a fixed answer, so they do not ask.

A comment edit's project formatter is decided the same way, at the edit that would run it: kapi apply asks once per formatter configuration and remembers, and MCP apply_edits never runs it.

Context

Discovery makes a recipe an ambient input

core/project.ResolveLayout finds a project by walking up from the working directory, the way git finds a repository. This is the right behaviour for a tool people run inside checkouts, and it means entering a directory is enough to bind its recipe. A recipe is therefore something the user stood next to rather than something they opened.

Most of what a recipe declares is inert: languages, content globs, gates, per-tool settings. The exec class is not: a flow step names a program and the framework would run it, with the user's privileges and the user's whole environment. That environment includes provider API keys, which resolve from conventional variables by design.

This is the npm postinstall threat model. What distinguishes that model is that running the code was never surfaced as a decision; build tooling runs code constantly.

The same classification, applied on every surface

Two surfaces can refuse outright, because neither has a user to ask:

  • MCP withholds both tools from the agent surface, and does not fold them into --all-tools, because "show me every tool" and "let a caller execute arbitrary commands" are different requests (S-03).
  • .kpz ingest (kpz.SanitizeRecipe) strips exec-class steps, their arming config, and exec-class format bindings from a package's recipe, because a package has crossed a trust boundary and a hostile packer will not sanitise on the way out.
  • The engine gRPC API refuses with PermissionDenied: a tool name and its config arrive over the wire, so an exec-class tool would let a caller choose the argv this process runs.

The recipe arm is the one case where the answer legitimately varies, so it is the one arm that asks.

Decision

A sticky, per-project decision, keyed to what was approved

The gate lives in host.LoadProjectInteractive, the wrapper every project-aware command already routes through, so every command inherits it at once. core/project.ExecSurface enumerates the exec-class sites in a loaded recipe: flow steps including nested parallel: branches, the defaults.tools and defaults.locales.*.tools presets, and format bindings on content collections and their items. An empty surface, which is every recipe in this repository and the overwhelming majority of real ones, means there is nothing to decide and nothing is shown.

ensureExecTrust then resolves the question in a fixed order: no exec sites → nothing to decide; the environment opt-in set → granted for this process, not recorded; a recorded decision at the current digest → honoured silently; a terminal → show what would run and ask; otherwise → refuse.

The stored record answers what was approved, not which project was trusted:

  • It is keyed by absolute recipe path, so a second checkout of the same project is a second decision. Approval does not travel with a copy.
  • It carries a digest of the exec surface alone (ExecSurfaceDigest), each site's location, kind, name, and canonical config, rather than of the whole file. Adding a target language keeps the approval; adding an argument to the approved command does not. This is what makes a sticky decision safe: it attaches to the argv a person read, so a recipe cannot be approved once and rewritten afterwards.
  • It lives under ConfigDir(), not in the project's .kapi/. The state directory is documented as safe to delete and regenerate, so a record there would evaporate on a routine clean, and could also ship inside the project for the next person to inherit. ConfigDir() honours KAPI_CONFIG_DIR, so a run isolated per the in-repo dogfood contract cannot read or write the developer's real decisions.

Declines are remembered too, and a declined recipe says how to answer again, so a person is never asked repeatedly for an answer they have already given.

--yes does not grant execution trust

--yes means "do not stop for prompts I would obviously accept", and it is already passed by every unattended script that wants plugin auto-install. Reusing it as the key for this decision would mean every existing automation silently acquired the right to run whatever a checked-out recipe named, and automation over untrusted checkouts is precisely the population the gate exists for. The flag would have granted the thing it was introduced to gate.

KAPI_TRUST_EXEC=1 is the separate opt-in, named for what it grants. It is process-scoped and never written to the record: a container's config directory is not a place to persist a decision. Unlike KAPI_NO_PROJECT and KAPI_PLUGINS_DIR_ONLY, which treat any non-empty value as set, it requires an affirmative value; reading KAPI_TRUST_EXEC=0 as "yes" would be the wrong failure for a switch like this.

With no terminal and no opt-in, kapi refuses, naming the recipe, the sites, and both ways to answer. Assuming yes when there is nobody to ask would make the gate a formality.

Refusal is enforced under tool construction, not only at load

The prompt is the user experience; it is not the enforcement point. A handful of internal paths load a recipe directly rather than through LoadProjectInteractive, and whether a subprocess spawns should not depend on which of them got there first. App.checkExecToolAllowed therefore sits on toolFromStep and buildToolByName (the two chokepoints through which recipe-driven configuration becomes a tool) and refuses to build an exec-class tool with no decision behind it. It never prompts: by the time a flow is assembling steps there is no sensible place to stop and ask. It re-reads the record instead, so a project already approved keeps working on those paths.

kapi exec <tool> is untouched. It builds from the registry with an argv the user typed, which is the user's own intent rather than a file's.

A project's formatter is an exec site at the edit that runs it

A comment edit in a language a plugin writes is held to the formatter the edited file's project configures (E-02). That formatter runs code the project controls: an executable the project can install, and a configuration that for prettier and vite-plus is code. core/project.FormatterExecSite makes it an exec site, and host/exectrust_formatter.go decides it with the recipe arm's record, prompt and environment grant.

It is decided at the edit rather than at load. No other command runs a formatter, and a site in the recipe's surface would make every command in a project with a formatter configuration ask or refuse. The record is keyed by the configuration file that selected the formatter, since a formatter's project often has no recipe. The plugin lists each formatter's configuration files as the formatter searches for them, so the nearest one kapi finds is the one the formatter loads, and a package.json or package.yaml counts only when it holds a top-level prettier key. The digest holds the SHA-256 of:

  • the formatter's command and its executable;
  • the interpreter that runs the executable: the program its first line names, or, for a node_modules/.bin shim, the node the shim runs and the script it hands that node;
  • every configuration file in the formatter's list, from the edited file's directory up to the directory of the one that selected it.

A change to any of them asks again. What that code loads in turn is outside the digest: the modules the script, the interpreter or a configuration file imports, the plugins a configuration names, and the programs a shell script runs. So a recorded allow trusts the project's dependencies as installed, not only the files it names. The formatter runs with kapi's environment, with every PATH entry that is not an absolute path removed, so a program the formatter looks up is never found relative to the project.

kapi apply resolves the question in the recipe arm's order, and asks only when the change-set did not arrive on standard input. MCP apply_edits never runs a project's formatter, whatever is recorded, so its row in the table above holds for formatters too. An agent that may write files can write the configuration a formatter loads, and nobody is present to ask. The edit reports did-not-run with the reason formatter, and a person applies it with kapi apply in a terminal. A decision is recorded only by answering that prompt.

Consequences

  • No recipe in this repository arms an exec-class tool. No recipe here, no sample, and no example names one. A comment edit in this repository's own TypeScript does prompt: the root vite.config.ts imports vite-plus, which selects oxfmt, and kapi apply asks before running it.
  • The tools are not removed, deprecated, or hidden. external-command and script keep their config factories, their schemas, their CLI commands, and their reference pages (E-03). The change is that a recipe cannot arm them silently.
  • A single classification, applied everywhere. IsExecClassTool and IsExecClassFormat live in core/project so the recipe arm, the gRPC arm, the package-ingest arm, and the enforcement backstop cannot drift from one another.
  • Recipe text reaching a terminal is sanitised. The prompt shows the command it is asking about, and that text is attacker-authored. Control characters are stripped and the summary is clipped, so the question cannot be repainted or scrolled away by the thing it is asking about.
  • Losing the record costs one prompt. It is not authoritative state; an unreadable or corrupt file is treated as "nothing decided yet" rather than as an error, and the file is plain JSON so a decision can be withdrawn by deleting its entry.