Known limitations
Seven gaps. Each one is a case where a manifest field looks stronger than the runtime currently is.
On this page
- 1. Human gates match MCP tool names by exact string, and nothing else
- 2.
max_per_month_usdis parsed but not enforced - 3. Traffic through
allowed_hostsis not metered for spending - 4. A2A replay state lives in the invoking user's home directory
- 5.
sandbox.network.egressis declared but has no consumer - 6. On the Docker backend, a credential value must fit on one line
- 7. On Windows, an Agentfile that sets
identity.diddoes not run - Scope, by design
- Firecracker: reading results back
Important
Read these before you rely on anything else in these docs.
Text description
1. Human gates match tool names by exact string, nothing else. A gate fires only on a byte-exact match to the MCP tool name: no semantic, prefix or wildcard matching. An unmatched entry warns loudly at validate and run time rather than failing silently. pkg/manifest/manifest.go · cmd/constle/gates.go
2. max_per_month_usd is parsed but not enforced. Accepted and validated as a decimal, but nothing enforces it and no monthly ledger exists. Only max_per_run_usd and max_per_day_usd are enforced (the daily cap durably, across runs). pkg/manifest/manifest.go
3. Traffic through allowed_hosts isn’t metered for spending. Cost is metered only at the MCP gate. Plain HTTPS to an allowed host, including direct LLM API calls, is allowlisted and logged but not counted toward any cap. Deliberate: metering it would mean TLS-intercepting the agent. pkg/manifest/manifest.go · internal/mcpgate/metering.go
4. A2A replay state lives in the invoking user’s home directory. Seen message IDs persist under ~/.constle/a2a/replay/<did>/ across restarts and fail closed, but aren’t shared: one identity listening from several home directories can have a request replayed once per home directory within the ±5-minute window. internal/a2a/listener.go · internal/a2a/replay_store.go
5. sandbox.network.egress is declared but has no consumer. The field parses, defaults to restricted and accepts any value; nothing reads it. All egress enforcement comes from network.allowed_hosts. Treat allowed_hosts as the entire network policy; egress is documentation. cmd/constle/main.go (renderRunSummary)
6. Docker: a credential value must fit on one line. A value with a line break, a NUL byte or invalid UTF-8, or longer than about 64 KiB, makes constle run refuse to start on Docker, naming the variable, never the value. Firecracker accepts such values. internal/sandbox/docker_envfile.go
7. On Windows, an Agentfile with identity.did does not run. A key made by constle identity create cannot be loaded on Windows, so constle run refuses the Agentfile rather than running unsigned. The signed audit log, max_per_day_usd and A2A all need identity.did. internal/identity/identity.go · cmd/constle/identity.go
1. Human gates match MCP tool names by exact string, and nothing elseLink to this section
human_gates.require_approval_for gates a call when an entry is a byte-exact, case-sensitive match for the params.name of a tools/call request on a server declared under mcp.servers. The tool name is the only protocol-level identifier the gate proxy sees, and exact match is the only mapping that is deterministic and auditable: there is no semantic matching, no prefix matching, no wildcards.
What this means for you: an entry like external_transfer gates nothing unless an MCP server actually exposes a tool named exactly external_transfer. Constle warns about every unmatched entry at both validate and run time, so an unenforceable gate is loud rather than silent, but it is still unenforceable. Human gates also do not apply to plain HTTPS traffic through allowed_hosts; the gate proxy only sees MCP.
Source: pkg/manifest/manifest.go (HumanGates.RequireApprovalFor, "MAPPING CONTRACT"), cmd/constle/gates.go.
2. max_per_month_usd is parsed but not enforcedLink to this section
The field is accepted by the parser and validated as a decimal amount. Nothing enforces it. Declaring it produces an explicit warning and no monthly ledger exists. max_per_run_usd and max_per_day_usd are enforced (the daily one durably, across runs, keyed by DID).
Source: pkg/manifest/manifest.go (Spending.MaxPerMonthUSD).
3. Traffic through allowed_hosts is not metered for spendingLink to this section
Cost is metered only at the MCP gate proxy, against the pricing block a server declares. Ordinary HTTPS to a host in network.allowed_hosts, including every direct call to an LLM API, is allowlisted, logged and not counted toward any spending cap.
This is a deliberate privacy trade-off, not an oversight: metering that traffic would require Constle to TLS-intercept the agent's connections and read their contents, and Constle refuses to do that. The consequence is real and you should size it: an agent that spends money over allowed_hosts rather than through a priced MCP server has no spending enforcement at all. That is exactly the case the quickstart's example hits, and why it prints NOT ENFORCED.
Source: pkg/manifest/manifest.go (Spending, "Enforcement scope"), internal/mcpgate/metering.go.
4. A2A replay state lives in the invoking user's home directoryLink to this section
The A2A listener accepts requests only: an envelope that answers another message (in_reply_to set) is refused there, because a response travels back on the HTTP exchange of the call it answers. For every request, the listener rejects a msg_id it has already seen and a timestamp more than ±5 minutes from the local clock. The seen IDs are durable: the id of every request that passes this check is persisted under ~/.constle/a2a/replay/<did>/, including a request then shed with a 503 because the peer's inbox quota is full (a retry needs a new msg_id), so the check spans process restarts and concurrent runs of the same identity, and the listener fails closed (a retryable 503) if that state cannot be read or written. A response is neither window-checked nor recorded; it is accepted only on the exchange of the call it answers, and only if it quotes that call's fresh msg_id. What the seen set does not span is machines or home directories: it lives in the invoking user's home directory and is not replicated.
What this means: if the same identity listens from more than one home directory, a request captured in flight can be replayed once per home directory, provided each replay lands inside the 5-minute timestamp window.
Source: internal/a2a/listener.go (servePublic), internal/a2a/envelope.go (replayGuard), internal/a2a/replay_store.go.
5. sandbox.network.egress is declared but has no consumerLink to this section
The field parses and defaults to restricted, any value is accepted, and then nothing reads it. All egress enforcement is derived solely from network.allowed_hosts, which becomes the Squid dstdomain allowlist. An empty list reaches no host; only Constle's own MCP and A2A gates stay reachable.
So egress: open and egress: none both parse cleanly, change nothing about what the agent can reach, and still render as restricted in the run summary. This is the one gap in this list that is a declared policy which looks real and is not, which is precisely what the warnings in items 1 and 2 exist to prevent elsewhere. The run summary's label is deliberately not derived from the field: deriving it would display a value the runtime ignores.
Treat allowed_hosts as the entire network policy. It is. An empty or absent allowed_hosts reaches no host beyond Constle's own gates; egress is documentation.
Source: cmd/constle/main.go (renderRunSummary, "KNOWN GAP").
6. On the Docker backend, a credential value must fit on one lineLink to this section
The Docker backend hands the declared credentials to the container as an env file the docker client reads (--env-file), so that neither the values nor the declared names ever enter the client's own argv or environment. That format has no quoting or escaping, so a value it cannot carry unchanged is refused rather than altered: one containing a line feed, a carriage return or a NUL byte, one that is not valid UTF-8, or one whose NAME=VALUE line is longer than 65,535 bytes. constle run stops before it creates anything, and the message names the variable and the reason, never the value.
What this means for you: a multi-line credential such as a PEM private key cannot be passed as-is on the Docker backend. Encode it (base64, for example) and decode it inside the agent, or run on the Firecracker backend, whose environment file quotes each value and accepts all of these.
Source: internal/sandbox/docker_envfile.go (renderDockerEnvFile).
7. On Windows, an Agentfile that sets identity.did does not runLink to this section
constle identity create writes the private key with mode 0600, and loading it refuses any other mode. On Windows a file's mode never reads as 0600, so the key cannot be loaded, and constle run refuses an Agentfile that sets identity.did rather than running unsigned. The signed audit log, spending.max_per_day_usd and A2A all require identity.did.
Source: internal/identity/identity.go (Load), cmd/constle/identity.go (loadRunIdentity).
Scope, by designLink to this section
These are not gaps between a field and the runtime, so they are not numbered with the seven above. They are choices, stated here so you don't have to find them:
- No filesystem policy. The agent's file access comes from its image.
capabilitiesis not a permission system, and there is no gate on file writes, because writes have no chokepoint outside the sandbox (Field reference §8.1 and §14.3).sandbox.disk_mbis declared, not applied. - Declared credentials enter the sandbox. The variables named under
credentialsare passed in as environment variables, so the agent can read the API keys you declare. What stays on the host is listed in Architecture. - Egress is per sandbox, not per program. Every process inside the sandbox reaches the same allowed hosts.
- No TLS interception. Constle decides on destination names and never intercepts the agent's TLS, which is why traffic to allowed hosts has no per-path rules and no spend metering.
Firecracker: reading results backLink to this section
When a Firecracker run ends, the host reads the agent's exit code and log out of the guest's workspace image with debugfs. Every byte of that image's filesystem metadata is the guest's to choose, so the parsing is hardened: it runs as a dedicated account that owns no files, with no capabilities, no supplementary groups and no_new_privs, a reset environment, resource limits, an empty session keyring, a wall-clock timeout, and fresh PID, mount, network and IPC namespaces for every read. The image is handed over as an open descriptor, never by path. What remains:
- There is no chroot: the reader can read whatever its account can read on the host.
- It keeps the kernel interfaces of an unprivileged process, including creating a user namespace of its own.
- Memory is limited per process (address space), not across its processes as a whole.
- What it can reach on the host outside its network namespace, local sockets for instance, has not been verified.
- Whether a read can delay the teardown of its namespaces has not been reviewed.
- The process limit belongs to the reader account and is shared by concurrent reads: about 16 reads at once, or one hostile read, can make other reads fail. A failed read is reported after the run's output, never shown as an agent that printed nothing.
- If
constleitself dies mid-read, the reader is killed through a parent-death signal, which it can clear.
This bounds what a parser compromised by a hostile image could do. It does not remove the host-side parsing.
Source: internal/sandbox/firecracker_reader_linux.go.