Security
Assume the agent is compromised.
Constle’s controls sit below the model, so they hold whether or not the agent knows it’s been prompt-injected. Here is exactly where they stop.
01Threat model
What it stops, and what it doesn’t.
The host is trusted, and everything inside the sandbox is not.
Stops
- S1Connections through the proxy to hosts not in
allowed_hosts, including prompt-injected ones - S2Raw-IP, PTR and resolve-it-yourself tricks, and names that resolve to loopback, link-local, metadata or private ranges
- S3Dialling out directly: on Docker the network is unreachable, and on Firecracker an IPv4 dial is dropped at the host while IPv6 has no route in the guest
- S4Tools not listed for a declared MCP server that declares a
toolslist - S5MCP requests the agent sends that the Agentfile doesn’t list (resources, prompts), and every other request method
- S6Gated tool calls that pass through the gate, without a decision, under the default
on_timeout: abort - S7Forged or replayed webhook approvals
- S8Spend past the caps on calls that pass through the gate to priced MCP servers: stopped after the call that crosses one, though each overlapping run of one identity can pass the daily cap by one call
- S9Edits, insertions, reordering, and deletions with an entry after them, inside a signed audit log going unnoticed
Doesn’t stop
- D1Misuse of a host you allowed, or a declared name whose public DNS points somewhere hostile
- D2Traffic to hosts you allow isn’t gated. ↗ If an allowed host shares a server with an MCP server, a request can reach that MCP server past the gate.
- D3Reading the API keys you declared:
credentialsare passed into the sandbox as environment variables - D4Spend over
allowed_hosts, such as direct LLM API calls - D5Actions that aren’t MCP tool calls with an exactly matching name
- D6Methods you list under
methods: forwarded and logged, but not gated or metered. Andtools/list: forwarded, so the agent sees every tool’s name
And a server’s own notifications: forwarded, with an audit line - D7Traffic that ignores the proxy: it is not logged ↗
- D8A host that lies: the runtime host is trusted by construction
- D9On Docker, a kernel exploit from the container, or exhausting host CPU, processes or disk ↗
- D10Lines cut from the end of a signed log, or a whole log swapped for an older or another complete one signed by the same key: both still verify
- D11The last moments of a run missing from the log ↗
- D12A terminal approval: it’s a local, unsigned operator action
- D13A line typed ahead at the terminal answering a later approval prompt
- D14A gate with
on_timeout: proceedand nobody answering - D15A2A replay across home directories sharing one listening identity
- D16A leaked approver key, until someone edits
approver_pubkey - D17Two runs starting or finishing at once on one host ↗
- D18On Firecracker, a root or
CAP_NET_BIND_SERVICEprocess taking the proxy’s address in the ~1.5 s it stops - D19A hostile Firecracker image attacking the host-side result reader: hardened, with known residuals ↗
- D20A log re-signed with another key, when
constle audit verifyruns without--didor--agentfileto pin the expected one: it passes, with a warning - D21On Firecracker, filling the host disk through the guest’s serial console log, which has no cap
- D22If
constleitself is killed: the sandbox keeps running with no time limit, its gates are gone, and another local process could take a gate’s address until the sandbox is stopped
02Network
The proxy decides.
The agent can reach a Squid proxy, which checks each CONNECT against allowed_hosts, and its run’s own MCP and A2A gates. On Docker it also reaches Docker’s embedded resolver, which finds the proxy by name. Whether that resolver forwards other names is untested. Requests the proxy receives, allowed or refused, become audit events when the run ends, but the run’s last moments can be missing.
CONNECT api.groq.com:443TCP_TUNNEL/200network_allowedListed in allowed_hosts. A tunnel may only go to 443.https://evil.example.com/403 Forbiddennetwork_blockedNot declared. This is how a prompt-injected exfiltration attempt through the proxy ends, and how you find out it happened.https://172.64.149.20/403 Forbiddennetwork_blockedEven the real IP of an allowed host is denied, because an IP literal passes only if that literal was listed.CONNECT api.groq.com:22TCP_DENIED/403network_blockedAn allowed name is not a raw TCP path. CONNECT is confined to 443.CONNECT localtest.me:443TCP_DENIED/403network_blockedLoopback, link-local, metadata and private ranges are refused on the resolved address, whatever DNS said.CONNECT 1.1.1.1:443TCP_DENIED/403network_blockedReverse lookups are off. A PTR record is written by whoever owns the address.1.1.1.1 · 2606:4700:4700::1111Docker: Network is unreachable. Firecracker: IPv4 dropped at the host, and IPv6 has no route in the guest.neither is loggedOn Docker, the agent’s network has no route out, and your host has no address on it. On Firecracker the proxy runs on the host, with nftables restricting the guest to it and the gates.AWhy PTR records don’t count
Reverse lookups are off, so an address is admitted only if it was itself listed. A PTR record is written by whoever owns the address, not by whoever wrote the allowlist.
BBoth address families, and no lookups for refused names
The raw-IP refusal is a string comparison, which doesn’t care what family an address is in, and the internal-destination rule lists IPv6 loopback, link-local and unique-local ranges beside the IPv4 ones. A name outside the allowlist is refused before anything resolves it, so a refused name never reaches DNS through the proxy.
CThe DNS trust boundary
For public addresses, the proxy trusts DNS, so name-based allowlisting is only as good as the resolution behind it. DNS can’t aim a declared name back inside, though, because internal destinations are refused on the resolved address.
The proxy’s results, as two tables
| Destination | Result |
|---|---|
| declared hostname | CONNECT allowed |
| raw IP of the declared host | 403 Forbidden |
| raw IP, undeclared | 403 Forbidden |
| undeclared hostname | 403 Forbidden |
| Destination | Request | Result | Audit |
|---|---|---|---|
| Declared hostname | 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 |
| IP whose PTR names an allowed host | CONNECT 1.1.1.1:443 | TCP_DENIED/403 | network_blocked |
03Human gates
Two ways to say yes, and one of them is signed.
Named calls that pass through the gate pause there for a decision. Both channels run at once, and the first decision wins.
TTerminal (stdin)
- 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, and
on_timeoutdecides - A line typed ahead can answer a later prompt, so answer only after reading it
WSigned webhook
agent.yaml, the gateexample
human_gates:
enabled: true
require_approval_for: [pay_invoice]
approver_pubkey: did:key:z6Mk… # constle webhook-keygen
notify:
- channel: webhook
url_secret_ref: HUMAN_GATE_WEBHOOK_URL
on_timeout: abort- Constle POSTs the request, then polls
GET <url>/<request_id>/decision - Ed25519 over
request_id.decision.subject_digest - The approver key is not an agent identity
Verification, in order
- Decode
approver_pubkeyfrom the Agentfile - Rebuild
request_id + "." + decision + "." + subject_digest - Verify the signature with that key
request_idequals the one minted for this gatesubject_digestequals the one sent- Only then, only if
decision == "approved", and only if anydecided_atreads as an RFC 3339 timestamp, forward
A decision that arrives (at an armed gate): unconditional
Sample decisions, signed and checked here with Ed25519subject_digest: SHA-256 of the sample arguments
“approved” with an unreadable decided_at | denied |
approver_pubkey missing or not a valid did:key | constle validate fails |
With human_gates.enabled: false, no call is held, and validate and run warn. Gate entries without enabled are refused.
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. The default wait is 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, with no M-of-N.
- Ed25519 removes host-side forgery but not host-side coercion, since 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_idsettles it with the approver’s own records. subject_digestis unsalted SHA-256, so guessable arguments can be recovered from the log.
04Audit log
One edited byte, caught.
The log is one JSONL file per agent per UTC day. With identity.did set, every entry is Ed25519-signed and hash-chained.
constle audit verify · output, abridgedthe entry count is left out
$ constle audit verify --agentfile=agent.yaml ~/.constle/logs/basic-agent-<date>.jsonl
✓ audit log verified: /home/you/.constle/logs/basic-agent-<date>.jsonl
entries: … (all signatures valid, hash chain intact)
signed by: did:key:z6MkgroKowQYDZjDmqbn82mJv4YFPKowS2xDhxGYrp4u3P1o
pinned: DID matches the expected identity
pins from: agent.yaml (identity.did only — it declares no approver_pubkey,
so gate decisions are unpinned)
# the same file, one byte edited
error: TAMPERING DETECTED in /home/you/.constle/logs/basic-agent-<date>.jsonl
line 1: invalid_signature — signature does not verify against did:key:z6Mkg…
— the entry was edited after signing
- Proves
- No signed entry was changed, inserted or reordered since signing.
- Proves
- A recorded webhook approval was signed by the pinned approver key. The approver’s
decided_atis unsigned, and the log uses the entry’s own time. - Doesn’t prove
- That the account is true, since the host wrote it.
- Doesn’t show
- Lines cut from the end of the file, or a whole log replaced by another one signed with the same key. Keep a copy or its hash where the agent’s host can’t change it.
- Pins
--agentfilepins both trust anchors. Unpinned, a check only shows the log is consistent with its own key, and says so.- Keys
- With a declared DID and no local key,
constle runrefuses to start.
05Releases
Releases
The current source and its signed releases aren’t public yet. The release workflow signs the checksums of each release it builds with cosign.
06Limitations
Where each rule stops.
The known limitations, each traced to the code, plus what Constle leaves out by design.
07Reporting
Report security issues privately.
We follow coordinated disclosure, with a default 90-day embargo and credit to the reporter.