Get release news

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.

ends at the line: stoppedends at an edge: not stoppednumbers refer to the table below

01Threat model

What it stops, and what it doesn’t.

The host is trusted, and everything inside the sandbox is not.

Stops

  1. S1Connections through the proxy to hosts not in allowed_hosts, including prompt-injected ones
  2. S2Raw-IP, PTR and resolve-it-yourself tricks, and names that resolve to loopback, link-local, metadata or private ranges
  3. 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
  4. S4Tools not listed for a declared MCP server that declares a tools list
  5. S5MCP requests the agent sends that the Agentfile doesn’t list (resources, prompts), and every other request method
  6. S6Gated tool calls that pass through the gate, without a decision, under the default on_timeout: abort
  7. S7Forged or replayed webhook approvals
  8. 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
  9. S9Edits, insertions, reordering, and deletions with an entry after them, inside a signed audit log going unnoticed

Doesn’t stop

  1. D1Misuse of a host you allowed, or a declared name whose public DNS points somewhere hostile
  2. 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.
  3. D3Reading the API keys you declared: credentials are passed into the sandbox as environment variables
  4. D4Spend over allowed_hosts, such as direct LLM API calls
  5. D5Actions that aren’t MCP tool calls with an exactly matching name
  6. D6Methods you list under methods: forwarded and logged, but not gated or metered. And tools/list: forwarded, so the agent sees every tool’s name
    And a server’s own notifications: forwarded, with an audit line
  7. D7Traffic that ignores the proxy: it is not logged ↗
  8. D8A host that lies: the runtime host is trusted by construction
  9. D9On Docker, a kernel exploit from the container, or exhausting host CPU, processes or disk ↗
  10. 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
  11. D11The last moments of a run missing from the log ↗
  12. D12A terminal approval: it’s a local, unsigned operator action
  13. D13A line typed ahead at the terminal answering a later approval prompt
  14. D14A gate with on_timeout: proceed and nobody answering
  15. D15A2A replay across home directories sharing one listening identity
  16. D16A leaked approver key, until someone edits approver_pubkey
  17. D17Two runs starting or finishing at once on one host ↗
  18. D18On Firecracker, a root or CAP_NET_BIND_SERVICE process taking the proxy’s address in the ~1.5 s it stops
  19. D19A hostile Firecracker image attacking the host-side result reader: hardened, with known residuals ↗
  20. D20A log re-signed with another key, when constle audit verify runs without --did or --agentfile to pin the expected one: it passes, with a warning
  21. D21On Firecracker, filling the host disk through the guest’s serial console log, which has no cap
  22. D22If constle itself 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.

DestinationRequestResultAudit
Declared hostnameCONNECT api.groq.com:443TCP_TUNNEL/200network_allowedListed in allowed_hosts. A tunnel may only go to 443.
Undeclared hostnamehttps://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.
Raw IP of the declared hosthttps://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.
Allowed host, port 22CONNECT api.groq.com:22TCP_DENIED/403network_blockedAn allowed name is not a raw TCP path. CONNECT is confined to 443.
Allowed name → 127.0.0.1CONNECT localtest.me:443TCP_DENIED/403network_blockedLoopback, link-local, metadata and private ranges are refused on the resolved address, whatever DNS said.
IP whose PTR names an allowed hostCONNECT 1.1.1.1:443TCP_DENIED/403network_blockedReverse lookups are off. A PTR record is written by whoever owns the address.
Skip the proxy, dial direct1.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
Name-based matching, reverse lookups off
DestinationResult
declared hostnameCONNECT allowed
raw IP of the declared host403 Forbidden
raw IP, undeclared403 Forbidden
undeclared hostname403 Forbidden
An allowed name is not a blank cheque
DestinationRequestResultAudit
Declared hostnameCONNECT api.groq.com:443TCP_TUNNEL/200network_allowed
Allowed host, port 22CONNECT api.groq.com:22TCP_DENIED/403network_blocked
Allowed name → 127.0.0.1CONNECT localtest.me:443TCP_DENIED/403network_blocked
IP whose PTR names an allowed hostCONNECT 1.1.1.1:443TCP_DENIED/403network_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)

  1. Unsigned local action, recorded with decided_by: terminal
  2. Arguments too large to show can be denied, never approved here
  3. Non-interactive stdin (CI, pipes) is announced, and on_timeout decides
  4. 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
  1. Constle POSTs the request, then polls GET <url>/<request_id>/decision
  2. Ed25519 over request_id.decision.subject_digest
  3. The approver key is not an agent identity

Verification, in order

  1. Decode approver_pubkey from the Agentfile
  2. Rebuild request_id + "." + decision + "." + subject_digest
  3. Verify the signature with that key
  4. request_id equals the one minted for this gate
  5. subject_digest equals the one sent
  6. Only then, only if decision == "approved", and only if any decided_at reads 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_atdenied
    approver_pubkey missing or not a valid did:keyconstle 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_timeoutResult
    abort (default)call refused, run terminated · gate_timeout
    proceedforwarded 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

    1. No rotation or revocation: a compromised approver key stays valid until the Agentfile changes.
    2. One approver per agent, with no M-of-N.
    3. Ed25519 removes host-side forgery but not host-side coercion, since the host could lie about what it’s asking.
    4. 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.
    5. subject_digest is 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_at is 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
    --agentfile pins 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 run refuses 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.