Constle Security

Assume the agent is compromised.

Constle’s controls sit below the model, so they hold whether or not the agent knows it has been prompt-injected. Here is exactly where they stop.

01Threat model

What it stops. What it doesn't.

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
DestinationRequestResult
declared hostnamehttps://api.groq.com/CONNECT allowed
raw IP of the declared hosthttps://172.64.149.20/403 Forbidden
raw IP, undeclaredhttps://1.1.1.1/403 Forbidden
undeclared hostnamehttps://evil.example.com/403 Forbidden
An allowed name is not a blank cheque
CaseRequestSquidAudit
allowed host, HTTPSCONNECT 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
address whose PTR is oneCONNECT 1.1.1.1:443TCP_DENIED/403network_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

WSigned webhook

human_gates:
  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

  1. 1Decode approver_pubkey from the Agentfile
  2. 2Rebuild request_id + "." + decision + "." + subject_digest
  3. 3Verify the signature with that key
  4. 4request_id equals the one minted for this gate
  5. 5subject_digest equals the one sent
  6. 6Only then, and only if decision == "approved", forward
A decision that arrives: unconditional
ConditionResult
approver_pubkey missing or not a valid did:keyconstle validate fails
Signature doesn't verifydenied · gate_signature_invalid
request_id mismatchdenied · gate_request_id_mismatch
subject_digest mismatchdenied · gate_digest_mismatch
Anything but "approved"denied
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. 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.

constle audit verify --agentfile=agent.yaml ~/.constle/logs/my-agent-2026-08-08.jsonl
✓ audit log verified: ~/.constle/logs/my-agent-2026-08-08.jsonl

  entries:   2 (all signatures valid, hash chain intact)
  signed by: did:key:z6MkgroKowQYDZjDmqbn82mJv4YFPKowS2xDhxGYrp4u3P1o
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.

cosign verify-blob \
  --certificate checksums.txt.pem --signature checksums.txt.sig \
  --certificate-identity-regexp '^https://github\.com/constle/constle/\.github/workflows/release\.yaml@refs/tags/v' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  checksums.txt
sha256sum --check --ignore-missing checksums.txt

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.

CProvenance
gh attestation verify constle_<version>_linux_amd64.tar.gz --repo constle/constle

06Limitations

Five gaps, documented on purpose.

Each is stated in the code at the point where it matters.

  1. 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.

    pkg/manifest/manifest.go (HumanGates.RequireApprovalFor) · cmd/constle/gates.go

  2. 2max_per_month_usd is parsed but not enforced

    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.

    pkg/manifest/manifest.go (Spending.MaxPerMonthUSD)

  3. 3Traffic through allowed_hosts is not metered

    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.

    pkg/manifest/manifest.go (Spending) · internal/mcpgate/metering.go

  4. 4A2A replay state is per machine, not shared

    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.

    internal/a2a/envelope.go (replayGuard) · internal/a2a/replay_store.go

  5. 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.

    cmd/constle/main.go (renderRunSummary, "KNOWN GAP") · issue #16

07Reporting

Found a hole? Tell us privately.

Please don't open a public issue. Use GitHub Security Advisories. Disclosure is coordinated, with a default 90-day embargo and credit to the reporter.

Response targets (SECURITY.md)
Acknowledgementwithin 48 hours
Triage and severitywithin 7 days
Fix, critical / highwithin 30 days
Fix, mediumwithin 90 days
Low / informationalbest effort