# Psyche trust boundary: sandboxed tools + stdout `<EVENT>` outbound, daemon-proxied

## Status

accepted (2026-06-03)

**Amended 2026-06-15 (ADR-0020):** wherever this ADR routes a `reply` to "the
`__REPLY_TO__` sender," read it as **the inbound message's `<EVENT>` `from`
attribute**. `__REPLY_TO__` was a mis-elevated relic and is removed from spt-core;
the daemon's strip-and-re-stamp anti-spoof is unchanged — it sources the reply
target from `from`.

## Context

The Psyche (a LiveAgent's companion mind) is an LLM session. If it held the
agent's full capability — sending messages, reaching the network, running
arbitrary commands — a confused or adversarially-steered Psyche could exfiltrate
or message arbitrary endpoints on the agent's behalf. The sister project
(`claude_skill_owl`) resolved this by **sandboxing the Psyche** and making the
wrapper its sole proxy; spt-core inherits the model but had not yet documented or
ported it, and its interim spawn path silently breaks it (see Consequences).

This decision was surfaced while grilling the Psyche I/O model and was found to be
uncovered by prior design passes.

## Decision

**The Psyche is a sandboxed actor whose sole outbound channel is its stdout, which
the daemon parses and relays as its proxy.**

- **Sandbox.** The Psyche session is restricted to `Read`/`Write`/`Edit` tools
  only — no `Bash`, no network, no message-send tool (sister: `claude.rs:34`,
  `--tools "Read,Edit,Write"`). It **cannot send messages itself.**
- **Inbound (two paths, neither is stdout-of-Psyche):** (1) events/messages the
  daemon composes onto the Psyche's **stdin**; (2) **commune/signoff file-drops**
  carrying context deltas (Self → daemon → Psyche; the *Summarizer* authors the
  commune delta — see CONTEXT.md). The Psyche does not read the spool itself.
- **Outbound (sole channel): stdout.** The Psyche emits the **same `<EVENT>`
  envelope grammar** the rest of SPT uses (ADR-0001, `spt-proto::event`), with two
  types it is allowed to author: `<EVENT type="reply">body</EVENT>` and
  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
  (that is the Summarizer's inbound delta, not a Psyche output).
- **The daemon is the outbound proxy + sanitizer.** It captures the Psyche's
  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
  it **strips any `from=`/target/routing attribute the Psyche supplied** and
  **re-stamps `from=<self_id>`**, then applies **constrained routing** — a `reply`
  goes **only to the `__REPLY_TO__` sender** of the message being answered; a
  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
  target and cannot address arbitrary endpoints. Body validation reuses the
  `<EVENT>` rules (escape `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity-decode, size cap).

The trust boundary is therefore **enforced by the daemon at relay time**, not by
the markup: the Psyche may *write* a spoofed `from=`, but the daemon discards it.

## Considered options

- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
  commune) — rejected: file-drops are the *inbound* commune/signoff travel path
  (Self → daemon → Psyche), not a Psyche-authored outbound. Conflating them was the
  original error this ADR corrects.
- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
  (keeps the Psyche syntactically unable to author a wire `<EVENT>`), but rejected
  in favour of one envelope grammar everywhere; the daemon's strip-and-re-stamp
  gives the same anti-spoof guarantee defensively.
- **Targeted reply (`<EVENT type="reply" to="<id>">`)** — rejected for v1: lets the
  Psyche address beyond the inbound sender, widening the trust surface (the daemon
  would have to authorize every target). Reply-to-sender only.
- **Pass-through relay** (no sanitization) — rejected: defeats the point of
  sandboxing the Psyche.

## Consequences

- **The live-Psyche turn driver MUST capture stdout.** spt-core's interim
  `runtime.spawn_session` sets `stdout(Stdio::null())` (`crates/spt-runtime/src/
  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
  detached null-stdout spawn is **not** the live-Psyche turn driver; the turn must
  be a **bounded, stdin-fed, stdout-captured** invocation (mirroring the sister's
  per-turn `--resume`), like the echo-commune's `run_bounded_stdin`. Tracked as
  KNOWN-HAZARDS 7.3 / `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY`.
- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
  parse-and-relay path yet (the sister's `parse_markers`, `claude.rs:594`). It is
  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
  the cross-node reply path (the `reply` half). See M4-PLAN.md task **D7.5**.
- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
  daemon is the **sole trusted author** of wire `<EVENT>` envelopes carrying a real
  `from=`; Psyche-authored attributes are untrusted input.
- **Anti-spoof is a conformance invariant**, not best-effort: a Psyche-supplied
  `from=`/target that survived to the wire would be an identity-spoof bug.

## References

- Builds on **ADR-0004** (Psyche = loop inside the daemon; Summarizer subprocess
  ephemeral) and **ADR-0001** (the `<EVENT>` wire format is copy-verbatim from the
  sister). CONTEXT.md *Psyche* / *Summarizer* glossary entries. KNOWN-HAZARDS 7.3.
