The host is trusted. Everything inside the sandbox is not.
✓ Stops
Exfiltration to hosts not in allowed_hosts, including prompt-injected instructions
Raw-IP, PTR and resolve-it-yourself tricks, and names that resolve to loopback, link-local, metadata or private ranges
Dialling out directly: there is no route
Tools not listed for a declared MCP server
Gated tool calls without a decision, under the default on_timeout: abort
Forged or replayed webhook approvals
Spend past the caps, at priced MCP servers
Undetected edits to a signed audit log
✕ Doesn't stop
Misuse of a host you allowed, or a declared name whose public DNS points somewhere hostile
Spend over allowed_hosts, such as direct LLM API calls
Actions that aren't MCP tool calls with an exactly matching name
A host that lies: the runtime host is trusted by construction
A terminal approval: it's a local, unsigned operator action
A gate with on_timeout: proceed and nobody answering
A2A replay across machines sharing one listening identity
A leaked approver key, until someone edits approver_pubkey
02Network
The proxy decides. The sandbox can't route around it.
The agent's only next hop is a Squid proxy that checks each CONNECT against allowed_hosts. Every allow and every block is an audit event.
Name-based matching, reverse lookups off
Destination
Request
Result
declared hostname
https://api.groq.com/
CONNECT allowed
raw IP of the declared host
https://172.64.149.20/
403 Forbidden
raw IP, undeclared
https://1.1.1.1/
403 Forbidden
undeclared hostname
https://evil.example.com/
403 Forbidden
An allowed name is not a blank cheque
Case
Request
Squid
Audit
allowed host, HTTPS
CONNECT api.groq.com:443
TCP_TUNNEL/200
network_allowed
allowed host, port 22
CONNECT api.groq.com:22
TCP_DENIED/403
network_blocked
allowed name → 127.0.0.1
CONNECT localtest.me:443
TCP_DENIED/403
network_blocked
address whose PTR is one
CONNECT 1.1.1.1:443
TCP_DENIED/403
network_blocked
IPv6 2606:4700:4700::1111 OSError: [Errno 101] Network is unreachable
IPv4 1.1.1.1 OSError: [Errno 101] Network is unreachable
AWhy PTR records don't count
Without reverse lookups off, an address whose PTR record named an allowed host was admitted too. A PTR record is written by whoever owns the address, not by whoever wrote the allowlist.
BBoth address families
The raw-IP ACL is spelled dst all, and the internal-destination rule lists IPv6 loopback, link-local and unique-local ranges beside the IPv4 ones. The generated config is parsed by a real Squid in tests; any complaint fails the build.
CThe DNS trust boundary
For public addresses, the proxy trusts DNS: name-based allowlisting is only as good as the resolution behind it. What DNS can't do is aim a declared name back inside, because internal destinations are refused on the resolved address.
03Human gates
Two ways to say yes. One of them is signed.
A gated tools/call pauses at the MCP gate. Both channels run at once; the first decision wins.
TTerminal (stdin)
⏸ human gate: agent "invoice-processor" wants to call
MCP tool "pay_invoice" on server "accounting"
subject: sha256:4b3f…e91a
arguments (25 bytes, 1 lines) — shown in full:
{"invoice_id":"INV-0042"}
approve? [a]pprove / [d]eny (timeout 300s → abort):
Unsigned local action, recorded with decided_by: terminal
Arguments too large to show can be denied, never approved here
Non-interactive stdin (CI, pipes) is announced; on_timeout decides
6Only then, and only if decision == "approved", forward
A decision that arrives: unconditional
Condition
Result
approver_pubkey missing or not a valid did:key
constle validate fails
Signature doesn't verify
denied · gate_signature_invalid
request_id mismatch
denied · gate_request_id_mismatch
subject_digest mismatch
denied · gate_digest_mismatch
Anything but "approved"
denied
No decision at all: policy, not guarantee
on_timeout
Result
abort (default)
call refused, run terminated · gate_timeout
proceed
forwarded without approval · gate_timeout
An unreachable endpoint, a malformed 200, or no resolvable notify URL all count as no decision. Default wait: 300 s (approval_timeout_seconds).
!Gate limitations from the spec
No rotation or revocation: a compromised approver key stays valid until the Agentfile changes.
One approver per agent. No M-of-N.
Ed25519 removes host-side forgery, not host-side coercion: the host could lie about what it's asking.
Recorded decisions omit the arguments, so a log writer could relabel a genuine approval as a different call. The request_id settles it with the approver's own records.
subject_digest is unsalted SHA-256: guessable arguments can be recovered from the log.
spec/human-gates-webhook.md §8–§10
04Audit log
One edited byte, caught.
JSONL per agent per UTC day. With identity.did set, every entry is Ed25519-signed and hash-chained.
error: TAMPERING DETECTED in ~/.constle/logs/my-agent-2026-08-08.jsonl
line 1: invalid_signature — signature does not verify against did:key:z6Mkg…
— the entry was edited after signing
≠What it proves
Proves: the runtime's own account of the run is unaltered since signing.
Proves: a recorded webhook approval was signed by the pinned approver key.
Doesn't prove: that the account is true. The host wrote it.
--agentfile pins both trust anchors. Unpinned, a check only shows the log is internally consistent.
With a declared DID and no local key, constle run refuses to start.
05Releases
Verify before you trust a binary.
The release workflow signs checksums.txt with cosign keyless signing, pinned to that workflow's identity.
The two --certificate-* flags are not optional. Keyless signing has no fixed public key. Without pinning the identity, cosign reports Verified OK for a file signed by anyone.
AWhat the installer does
constle.dev/install fetches checksums.txt and refuses an archive whose SHA-256 doesn't match. With cosign on your PATH it checks the signature first, pinned as above, and aborts on failure. Without cosign it says so and enforces the checksum alone. A hand-downloaded archive skips all of this.
BMake the signature mandatory
CONSTLE_REQUIRE_SIGNATURE=1 refuses any install without a verified signature. Check a release for checksums.txt.sig before relying on it: the README currently notes that no published release carries one yet.
Each is stated in the code at the point where it matters.
1Human gates match MCP tool names by exact string only
require_approval_for gates a call when an entry is a byte-exact, case-sensitive match for the params.name of a tools/call on a declared server. An entry like external_transfer gates nothing unless a server exposes a tool with exactly that name. Constle warns about every unmatched entry at validate and run, but it stays unenforceable. Gates don't apply to plain HTTPS through allowed_hosts.
Accepted and validated as a decimal amount; nothing enforces it and there's no monthly ledger. Declaring it prints a warning. max_per_run_usd and max_per_day_usd are enforced, the daily one durably across runs, keyed by DID.
Cost is metered only at the MCP gate, against each server's pricing. Ordinary HTTPS to an allowed host, including every direct LLM API call, is allowlisted and logged but not counted. Metering it would mean TLS-intercepting the agent's connections, which Constle refuses to do. So an agent that spends over allowed_hosts has no spend enforcement at all, and the quickstart prints NOT ENFORCED for exactly that reason.
The listener rejects duplicate msg_ids and envelopes more than ±5 minutes off the local clock. Seen IDs persist under ~/.constle/a2a/replay/<did>/, spanning restarts and concurrent runs, and fail closed with a retryable 503 if that state can't be read or written. They are not replicated: run one identity as a listener on several machines and a captured envelope can be replayed once per machine inside the window.
5sandbox.network.egress is declared but has no consumer
It parses, validates and defaults to restricted, and nothing reads it. egress: open and egress: none change nothing. All egress enforcement comes from allowed_hosts; an empty or absent list denies everything.