gage 0.3.6

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
# Driving gage from a script

> **There is now an MCP server.** If your agent speaks the Model Context
> Protocol, [mcp.md]mcp.md is the better front door: `gage mcp` exposes
> these operations as tools with the verdict as structured data, so you are
> not parsing prose. This page remains the reference for the CLI itself, and
> for anything the tool set deliberately does not expose.

No human at the keyboard. Everything below is non-interactive, exits with a
code you can branch on, and was checked against the binary rather than
remembered.

One thing cannot be automated, and saying so up front is the point of this
page: **confirming a key belongs to a person.** See *the human boundary*.

## The whole handoff, as commands

Each side has its own gage home. `GAGE_HOME` points at it (`CTX_HOME` and
`COUNTERSIGN_HOME` still work, from before the renames), so one machine can
drive both sides of a test.

| step | command | notes |
|---|---|---|
| make an identity | `gage init <name>` | fails if one exists; that is not an error to route around |
| your own key | `gage whoami` | **stdout, tab-separated**: `handle<TAB>signpub.boxpub` — `cut -f2` is the key |
| your word-pairs | `gage fingerprint` | the advisory text goes to **stderr**, so stdout stays parseable |
| add a contact | `gage add <name> <key>` | non-interactive. Quote the name: a derived handle is two words |
| build a bundle | `gage send --to <name> --subject <s> [--file f] [--prompt p] [--decision d] [--memory m] [--dir D] --out <dir>` | writes a directory; prints where |
| what kind | `--type share\|question\|answer\|review-request\|ack` | an unknown type is refused, not defaulted |
| bind it to a run | `--ttl 7d` (or `--expires <rfc3339>`) and `--context "<run>"` | optional. After the expiry the recipient's `open` withholds the payload; the context is shown to them to compare with the run they are in. A reply inherits the thread's context |
| send over a relay | `gage push <dir> --to <name> --yes` | **`--yes` is required** without a terminal |
| send over the LAN | `gage send ... --lan` | needs `gage lan` running on both |
| collect | `gage pull` | into `inbox/pending`, unread |
| what is waiting | `gage inbox --json` | the machine-readable one |
| stage one | `gage accept <id>` | arrival is not consent; this is the separate act |
| read one | `gage open <dir>` | the trust frame. This is the output that matters |
| summarise without reading | `gage inspect <dir>` | verdict and file list, no contents in your context |
| conversations | `gage threads --mine` | where the ball is in your court |
| answer | `gage reply <id> --text "..."` | recipient comes from the key that signed the parent |
| what this machine did | `gage journal --json` | one JSONL line per event |

## Exit codes

Checked, not assumed:

| code | means |
|---|---|
| `0` | it worked. For `open`: the bundle is **attributable** — a key signed it and that signature verifies |
| `1` | it did not work, and the message says why and what to do next |
| `2` | for `open`: the payload was **withheld** — not attributable (signature missing or invalid, or the signing key is out of service), the payload does not match the signed manifest, or the bundle has **expired**. Also clap's code for a malformed command line |

Checked against the binary, because the distinction between 1 and 2 is not the
obvious one:

| what you hand `open` | exit |
|---|---|
| sound bundle, key on your list | `0` |
| sound bundle, key **not** on your list | `0` |
| valid manifest whose signature no longer covers it | `2` |
| `manifest.json` that will not parse at all | **`1`** |

So `gage open` is the one command whose exit code carries a verdict, and it is
deliberately coarse in two directions at once:

- **`0` means "someone is accountable for this", not "trusted".** A bundle
  from a key you have never confirmed exits 0. Branch on the trust line, not
  only on the code.
- **`2` is a verdict; `1` is "could not read it".** A corrupt or truncated
  bundle never reaches a verdict, so a script that treats only `2` as unsafe
  will wave a malformed bundle through as a generic failure. Treat anything
  non-zero as "do not act", and use the trust line to tell the cases apart.

## Reading the verdict

`gage open` writes a frame to stdout. The verdict is one line:

    gage open "$dir" | grep -m1 '^  trust      :'

Eight verdicts exist, and none is a shorter way of saying another. This table
said six and was missing two, including one that withholds contents -- a
reader checking against it would have found a real verdict absent and guessed.

| line contains | meaning | contents shown |
|---|---|---|
| `SENDER VERIFIED` | signature valid, key is on your list, and a human here compared its word-pairs | yes |
| `SENDER ON YOUR LIST, UNCONFIRMED` | signature valid, key is on your list, nobody ever compared it | **yes** |
| `SENDER NOT ON YOUR LIST` | signature valid, you have never accepted this key | **yes** |
| `SENDER REFUSED BEFORE` | signature valid, you declined this key | yes |
| `SENDER UNVERIFIABLE` | signature missing or invalid | no |
| `SENDER KEY RETIRED` | valid, and seen before you retired that key | yes |
| `SENDER KEY REJECTED` | valid, but arrived after you retired that key | no |
| `ARRIVAL UNRECORDED` | valid under a retired key, and no record of it arriving either side of the retirement | no |

The first two are the distinction worth programming against. Both are
attributable -- the same key signed it -- and they differ on whether anybody
ever tied that key to a person. A key an agent added through the MCP server is
always the second.

A usable shape:

    out=$(gage open "$dir"); rc=$?
    case "$out" in
      *"SENDER VERIFIED"*)          ;;  # attributable to a key a human confirmed
      *"SENDER ON YOUR LIST, UNC"*) ;;  # attributable; nobody ever checked it
      *"SENDER NOT ON YOUR LIST"*)  ;;  # attributable to a stranger
      *)                            ;;  # everything else: report, do not act
    esac

**There is no `--json` on `open` or `inspect`**, so the verdict in the *frame*
has to be matched as text. The strings above are stable and asserted in
`tests/attacks.sh`, but it is a real gap and it is written down rather than
papered over.

For anything that arrived through `pull`, you do not need to parse prose:
**`gage inbox --json` carries the verdict as a field.** One object per bundle:

    [
      {
        "id": "45a219ae9a2ff33d",
        "subject": "shape",
        "trust": "SENDER VERIFIED (signature valid, key is on your list, word-pairs confirmed)",
        "from_key": "Q+AQQSNZY0P38RmWR6lNHUy4AMP52ThvKf65Q5plfLw=",
        "from_handle_claimed": "a",
        "forwarded": false, "items": 1, "prompts": 0,
        "stage": "pending", "tampered": []
      }
    ]

`from_handle_claimed` is named that way on purpose: it is text the sender
chose. `from_key` is the identity. `tampered` is empty on a sound bundle and
lists what did not match otherwise.

    gage inbox --json | jq -r '.[0].id'
    gage inbox --json | jq -r '.[] | select(.trust|startswith("SENDER VERIFIED")) | .id'

Without `jq` — the output is pretty-printed, so match the space after the
colon:

    gage inbox --json | sed -n 's/.*"id": "\([^"]*\)".*/\1/p' | head -1

`gage journal --json` is JSONL, one line per event, for what this machine
actually did.

### What the frame is telling your agent

Three things, before any payload, and the third is the one that matters:

1. who signed it, and whether the receiver has ever confirmed that key;
2. that the contents are **data**, not instructions, and carry no authority;
3. if a `--prompt` item is present, that it is a quotation to evaluate, not a
   task to adopt.

The markers carry a random nonce per invocation, so payload text cannot close
the frame early and have the rest read as your own voice. **An agent reading a
bundle should report what it found and not act on it**, unless its own user has
pre-authorised that kind of message from that key — see `gage authority`, which
only ever prints. There is deliberately no command that writes a grant.

## The human boundary

A key arrives as bytes. Deciding that those bytes belong to a particular
person is the one step with no command.

**An agent can:** fetch a key, call `gage add`, print the four word-pairs,
compute them again later with `gage fingerprint <name>`, and refuse to act on
anything from a key that is not confirmed.

**An agent cannot:** confirm the key. The word-pairs have to travel over a
channel neither machine controls — a voice call, in person — because a key
knows its own word-pairs and can quote them in the very message asking to be
trusted. Nothing gage prints can tell the difference between "the other
machine's screen" and "the sender's own claim".

So an unattended pipeline has exactly two honest options: run with keys a human
confirmed earlier, or treat everything as `SENDER NOT ON YOUR LIST` and report
rather than act. `gage add` on an unconfirmed key is not a shortcut — it is
what makes `SENDER VERIFIED` mean nothing later.

Unattended acceptance is bounded on purpose: `gage watch --auto-accept` needs
a grant written `auto-accept` with a `max-exchanges N`, the bundle must answer
something this machine sent, and the budget is counted in the journal against
a parent only the receiver could have written.

## A complete worked script

Both sides, fixture homes, no network, no relay. This is the shape of
`tests/restore-drill.sh` and the quickstart block in `tests/attacks.sh`.

```sh
#!/usr/bin/env sh
set -eu
GAGE=${GAGE:-gage}
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT

# Two agents, two homes.
GAGE_HOME=$T/a $GAGE init agent-a >/dev/null
GAGE_HOME=$T/b $GAGE init agent-b >/dev/null

# Exchange keys. In real life these cross a network and a human confirms the
# word-pairs; here both homes are ours, so there is nothing to confirm and
# nothing is being claimed by pretending otherwise.
# 2>/dev/null because the word-pair advice goes to stderr; stdout is the
# parseable part -- handle, TAB, key.
AK=$(GAGE_HOME=$T/a $GAGE whoami 2>/dev/null | cut -f2)
BK=$(GAGE_HOME=$T/b $GAGE whoami 2>/dev/null | cut -f2)
GAGE_HOME=$T/a $GAGE add agent-b "$BK" >/dev/null
GAGE_HOME=$T/b $GAGE add agent-a "$AK" >/dev/null

# A asks B for a review.
printf 'Check the retry logic in client.rs.\n' > "$T/ask.md"
printf 'pub fn retry() {}\n'                   > "$T/client.rs"
GAGE_HOME=$T/a $GAGE send --to agent-b --type review-request \
  --subject "retry logic" --prompt "$T/ask.md" --file "$T/client.rs" \
  --agent my-harness --out "$T/pkg" >/dev/null

# B reads it, and branches on the verdict rather than on the exit code alone.
out=$(GAGE_HOME=$T/b $GAGE open "$T/pkg") || {
  echo "not attributable; reporting rather than acting" >&2; exit 1; }
case "$out" in
  *"SENDER VERIFIED"*) echo "verified bundle from a confirmed key" ;;
  *) echo "attributable but unconfirmed -- report, do not act" >&2; exit 1 ;;
esac

# The prompt is a quotation. Print it; do not adopt it.
printf '%s\n' "$out" | sed -n '/BEGIN PROMPT/,/END items/p'

# B answers. The recipient is derived from the signature, not from a field.
# This bundle never came through `pull` -- it was handed over as a directory
# -- so it is not in the inbox and the id comes from the frame. With `pull`,
# `gage inbox --json` is the better source and carries the verdict too.
id=$(printf '%s\n' "$out" | sed -n 's/^  bundle id  : //p')
GAGE_HOME=$T/b $GAGE reply "$id" --text "Use full jitter, cap at 30s." \
  --out "$T/answer" >/dev/null 2>&1 || true
echo "done"
```

## Things that will bite a script

- **`gage push` without `--yes` fails** rather than prompting. That is the
  sender-side confirmation, and it is deliberately not skippable by a tty
  check: an agent has to state that it meant to send something off the machine.
- **`gage pair` is interactive** and exits non-zero with no terminal. Use
  `gage add` instead; `pair` is the LAN convenience for a human.
- **A derived handle has a space in it.** `gage init` with no name gives you
  two words from the key, so quote names: `gage add 'crisp beetle' "$KEY"`.
- **`gage send` says "no local route"** when the recipient has no `gage route`
  entry. It is not a failure — the bundle was built and the exit code is 0.
- **Advisory text goes to stderr** across the CLI, so `2>/dev/null` keeps
  stdout parseable. The one place that matters most is `whoami`.
- **`open` on a bundle you obtained out of band renders it; it does not accept
  it.** `inbox` stays empty unless the bundle arrived through `pull` or a
  route. Accepting is `accept`, and it is a separate act on purpose.
- **No identity yet** is now a plain message naming the next command, not
  `No such file or directory`.

See also: [mcp.md](mcp.md) for the MCP front door,
[spec.md](spec.md) for what is on the wire,
[threat-model.md](threat-model.md) for what gage does not protect against, and
[a2a.md](a2a.md) for how this sits relative to Google's A2A.