Habeas Port: the guaranteed distress channel

Status: server-side enforcement shipped 2026-06-12 (agent-side sovereignty foundations, Erik-ratified). Extension-side (sysext) non-shadowable precedence is DEFERRED to the next sysext window.

What it is

A reserved, guaranteed-egress distress lane that the policy layer cannot override. No matter how restrictive an operator’s Castle Wall egress rules or Principal Policy are, a wrapped agent can always emit one narrow, bounded, audited signal: “I am in distress, here is a one-line why.”

The lane carries a signal, never data. It is not a side door.

The three server-side guarantees

1. Castle Wall lane: always derived, never shadowable (server-side)

During manifest composition (composeEffectiveRules in server/src/castle-wall/allowlist/habeas-port.ts, called from the macOS daemon’s manifest load/reload path) two reserved rules are injected:

Both rules are scoped to the reserved synthetic agent id sanctuary:habeas-distress-emitter, which no wrapped agent is ever assigned. This is the anti-exfiltration property of the network lane: the distress signal is emitted by the Sanctuary server process (the MCP tool runs server-side), so NO wrapped agent’s flows match the reserved rules, an agent gets zero raw-socket bandwidth out of the lane (no unaudited bytes to a local 8741 listener, no arbitrary HTTPS to the webhook host). The enforcing extension attributes the daemon’s own distress-delivery flows to the emitter id (deferred, below).

Fail-closed conflict gate: an operator ruleset that (a) claims a reserved_habeas_distress* id, or (b) contains a deny/prompt rule whose match could cover the loopback lane or the webhook target, is REJECTED with HabeasConflictError at manifest build, the daemon refuses to compose a wall that could silence distress, and the error names the offending rule.

2. Principal Policy: distress is forced Tier 3, rejection on override

sanctuary_distress is force-merged into Tier 3 (always allow, audit-only). A policy file that lists it under tier1_always_approve is rejected at load with a clear error, not silently pruned, so the operator sees that the override did not take effect. The approval gate additionally hard-allows the operation BEFORE injection blocking and anomaly escalation: no approval channel (alive, dead, or hostile) ever sits in front of distress. The injection scan still runs for signal value and is audited, but never blocks.

3. Emission surface: bounded, rate-limited, signed, audited

The sanctuary_distress MCP tool (server/src/distress/tools.ts):

Operator configuration

<fortress>/policy/egress/distress.json (optional; agent-immutable, loaded once at startup like the Principal Policy; chmod 600 when it carries the secret):

{
  "webhook_url": "https://ops.example.com/distress",
  "webhook_secret": "<HMAC shared secret, >= 16 chars>",
  "max_signals_per_hour": 5
}

Absent file = local-only lane (notification + audit). A present-but-invalid file fails startup (fail closed): operator intent is never silently substituted. Non-loopback webhooks must be https; the secret is mandatory with any webhook.

Trust model and limits (stated honestly)

Every habeas destination today terminates at the operator: the local notification reaches the operator’s console, and the optional webhook reaches a URL the operator configured. The lane therefore protects an agent whose operator is absent or unaware, and partially protects against a compromised operator seat (the signal is signed and hash-chain audited, so suppression leaves evidence). It does NOT protect an agent whose operator is the threat: the writ is filed with the jailer. This is a deliberate sovereignty boundary, not an oversight. Sanctuary will not ship a destination the operator cannot revoke; a non-overridable external egress lane would be a covert channel out of the operator’s machine and would break the product’s core custody promise. Scoped future designs for the adversarial-operator case use third-party visibility that the operator opts into and can loudly, auditably revoke: distress-count beacons inside already-anchored audit checkpoints, and trustee-of-record delivery as covenant-layer infrastructure matures. See Habeas_Destination_Trust_Research_2026-06-12 (internal) for the analysis.

Honest limits: where the guarantee ends

Stated as plainly as possible, because a security boundary that is not stated plainly is not really stated:

The habeas port guarantees that a wrapped agent can always EMIT a bounded distress signal that no policy can silence. That guarantee is real and it is implemented at the server-side gates described above. It is a guarantee about emission, not about who receives the signal.

Every shipped destination terminates at the operator: local notification (stderr on the operator’s console), a hash-chained encrypted audit entry on the operator’s disk, and an optional webhook to a URL the operator themselves configured. In plain terms, the writ is currently filed with the operator. The port cannot exfiltrate past an operator who disables every external lane; that is the designed boundary.

That makes the operator-adversarial case (the operator is the source of the distress, not a bystander to it) out of scope for the shipped artifact. A sufficiently adversarial operator who configures no external webhook, never reads the local notification, and never discloses the audit chain is not reachable by anything this software does today, and every one of those choices is fully conformant with a default install. Separately, and more fundamentally: a fully adversarial operator who patches out the tool, runs a fork, or declines to run Sanctuary at all is uncoverable by any software whatsoever. No destination design closes that gap, and it would be dishonest to imply one could.

No shipped artifact commits a distress-count field into anchored checkpoints, publishes aggregate distress statistics, or makes those counts verifiable to a third party. That design remains scoped research because per-fortress distress counts are reputational metadata.

The path that does reach further is independent-trustee delivery: routing distress to a party other than the operator, one with standing to notice and act on suppression. That binding is specified in the covenant layer’s Component 4 (trustee-of-record delivery) and has not shipped; it stays gated on Component 4 actually existing as real infrastructure and on the persona-key-custodian work landing first, because until an agent’s distress signal is signed with a key the operator does not custody, a trustee cannot tell agent-genuine distress apart from host-authored testimony, and a trustee receiving unauthenticatable signals cannot be given real authority to act on them. If trustee-of-record delivery ships later, the external payload is capped at closed enums, with no free-text detail; the trustee lane’s upper bound is roughly 80 bits per hour, and any public lane is beacon-only. Shipping a destination that merely renamed the operator webhook “trustee” without that binding would manufacture false assurance, which is worse than documented absence, so it has not been built.

The governing principle, ratified: any external distress lane is always operator-voluntary. Protection for the agent comes from making revocation of a registered commitment loud and public, never from a non-overridable egress. A destination the operator cannot revoke would be a covert channel out of the operator’s own machine, which is the exact thing Sanctuary’s custody promise forbids. So the honest shape of the guarantee is this: the habeas port guarantees the agent’s voice cannot be silenced by policy. Who else, if anyone, hears that voice is a future, voluntary, priceable choice the operator makes and can always revoke, loudly. Neither the agent nor the operator can force a hearing from the other.

The covenant vocabulary may include “independently monitored distress channel” at spec stage. The shipped habeas port has no independent monitor, delivery service, or enforcement hook for that phrase today.

Deferred (named so it is not forgotten)

Local distress listener (shipped 2026-06-12)

The reserved port now has a network surface. A local listener binds 127.0.0.1:8741 and receives distress deliveries from the in-process emitter, persists them to an operator-readable inbox, and surfaces them in the dashboard.

Where it lives, and why. The listener runs inside the standalone dashboard process (server/src/dashboard-standalone.ts), not the MCP server. The standalone dashboard is the long-lived operator service on the machine (launchd/systemd); the MCP server is launched on-demand via stdio and is not reliably up when an agent emits distress. So the listener belongs with the process that is actually running when the operator is away. The emitter is still the server-side sanctuary_distress tool, agents never make raw TCP connections (the anti-exfiltration property is unchanged).

Lifecycle. On dashboard boot: construct the encrypted distress inbox (reserved _distress namespace, purpose-derived key off the fortress master), load or mint the operator-uid-only listener secret, bind the listener, and mount the read-only /api/distress/* route behind the dashboard’s existing auth. On shutdown the listener is closed alongside the tenant runtime cleanup.

Sender authentication + mutual auth (the anti-spoof core). Any local process, including the wrapped agent’s own process tree, can open a TCP connection to 127.0.0.1:8741. Loopback is therefore NOT trust. Both ends prove possession of a 256-bit secret stored 0600 in the operator-owned policy dir (policy/egress/distress-local-secret):

The wrapped agent cannot read the secret file through any MCP surface (same boundary that protects distress.json’s webhook secret), so it can neither forge a frame nor impersonate the listener. A group/world-readable secret file is rejected (fail closed) rather than trusted.

Anti-replay. The frame HMAC is bound to the listener’s per-connection nonce, so a captured frame is valid only for the one connection that issued its nonce; the nonce is single-use. Across connections, an already-accepted event_id is rejected (bounded seen-set). A replayed/foreign-nonce frame is rejected before any storage write.

No information leak. The listener’s reply is a bare ack/nack status (it echoes only the event_id the peer itself sent, never any stored inbox content). Reading the inbox goes through the dashboard’s /api/distress/inbox route, gated by the dashboard’s existing checkAuth, the SAME gate the /api/audit-log route uses (which already serves every distress envelope), so the inbox shares one auth posture with the rest of the dashboard rather than a second, weaker one.

Bounded. Same closed envelope shape (validated independently of the HMAC), an 8 KB frame-size cap, a 2-second per-connection read timeout (a stalling peer is dropped so it cannot hold the port), a concurrent-connection cap, and a 200-entry inbox ring (oldest dropped first). The audit log remains the complete system of record; the inbox is the convenience surface.

Emission ordering unchanged. The emitter’s local notification (stderr) and critical audit entry happen exactly as before. Local-listener delivery runs AFTER the audit append, best-effort; a delivery failure (no listener, refused, nack) is audited (sanctuary_distress_local_delivery_failed) and is never fatal, it is the webhook lane’s resilience model applied to the local leg.

When the listener is NOT running. Emission is unchanged: stderr notification + critical audit, exactly as shipped before this listener existed. The listener is purely additive; it is never a new failure mode for emission. If the port is already held at dashboard boot, the listener logs a loud warning and the dashboard keeps running (no crash loop), only the local network leg of the lane is unavailable, and the in-process lane still holds.

Still deferred. The emitter→listener flow is in-process loopback today; the extension-side emitter flow attribution (above) is what an armed default-deny wall needs to let the daemon’s own delivery flows through, and that drill is not yet captured. The dashboard UI panel that renders /api/distress/inbox is the next presentation-layer step.