Agentfile field reference
This page is spec/agent-manifest.md from the Constle repository, reproduced verbatim. The source of truth for the behaviour it describes is pkg/manifest/manifest.go and pkg/manifest/parser.go.
On this page
- 1. Overview
- 1.1 Relationship to
agent-manifest.yaml - 1.2 The rule this specification is written under
- 2. Conventions
- 2.1 Required vs. optional
- 2.2 Enforcement labels
- 2.3 Where enforcement happens
- 3. Document structure
- 4. Top-level fields
- 4.1
apiVersion - 4.2
kind - 5. Section:
identity - 5.1
identity.name - 5.2
identity.version - 5.3
identity.owner - 5.4
identity.did - 6. Section:
sandbox - 6.1
sandbox.isolation - 6.2
sandbox.image - 6.3
sandbox.command - 6.4
sandbox.memory_mb - 6.5
sandbox.disk_mb - 7. Section:
sandbox.network - 7.1
sandbox.network.egress - 7.2
sandbox.network.allowed_hosts - 8. Section:
capabilities - 8.1 What
capabilitiesis not - 9. Section:
credentials - 9.1
credentials[].name - 9.2
credentials[].secret_ref - 9.3 What is not a credential
- 9.4 Audit record
- 10. Section:
mcp - 10.1
mcp.servers[].id - 10.2
mcp.servers[].url - 10.3
mcp.servers[].tools - 10.4
mcp.servers[].pricing - 11. Section:
a2a - 11.1
a2a.listen - 11.2
a2a.peers[].name - 11.3
a2a.peers[].did - 11.4
a2a.peers[].endpoint - 12. Section:
spending - 12.1 Enforcement scope — read this before relying on a cap
- 12.2 Amount format
- 12.3
spending.max_per_run_usd - 12.4
spending.max_per_day_usd - 12.5
spending.max_per_month_usd - 12.6
spending.alerts.warn_at_pct_of_daily - 13. Section:
limits - 13.1
limits.max_duration_seconds - 14. Section:
human_gates - 14.1
human_gates.enabled - 14.2
human_gates.require_approval_for - 14.3 Why there is no
{action, paths, condition}form - 14.4
human_gates.approval_timeout_seconds - 14.5
human_gates.on_timeout - 14.6
human_gates.notify - 15. Section:
compliance - 15.1
compliance.audit_log_level - 15.2
compliance.frameworks - 15.3
compliance.geo_restrictions - 16. Section:
metadata - 17. Cross-field validation rules
- 18. Enforcement summary
- 19. Examples
- 19.1 Minimal
- 19.2 Full
- 20. Versioning and compatibility
- 20.1 Two version numbers
- 20.2 apiVersion progression
- 20.3 What is a breaking change
- 20.4 What is not
- 21. Changelog
- 0.5.0 — 2026-09-23
- 0.4.0 — 2026-09-21
- 0.3.0 — 2026-09-21
- 0.2.0 — 2026-09-13
- 0.1.0 — 2026-08-16
- 22. Roadmap — not valid manifest syntax
1. OverviewLink to this section
The AgentManifest (also called the Agentfile) is a YAML file that tells the Constle runtime everything it needs to know about an AI agent: who the agent is, how to run it in isolation, what it may reach on the network, which MCP servers and peer agents it may talk to, how much it may spend, when it must stop and ask a human, and what to log.
The analogy is a Dockerfile. A developer writes one Agentfile and the Constle runtime executes it the same way on any supported backend.
Core design rule: the manifest declares what the agent needs, not how to implement it. The runtime makes the infrastructure decisions.
1.1 Relationship to agent-manifest.yamlLink to this section
Two documents describe this format, and they are not redundant:
| File | Role |
|---|---|
spec/agent-manifest.md (this document) |
The normative specification. Full prose for every field: type, default, validation rules, enforcement status, and the reasoning behind the design. |
spec/agent-manifest.yaml |
An executable annotated reference file. It parses and passes constle validate against the current runtime. |
Where the two disagree, this document is normative and the discrepancy is a bug. The YAML file is kept executable precisely so that drift is detectable:
$ constle validate spec/agent-manifest.yaml1.2 The rule this specification is written underLink to this section
A declared protection must never look real when it isn't.
This principle governs the entire format, and it is why the enforcement labels
in §2.2 exist and are applied pedantically. A field that is parsed but not
acted upon is labelled as such, in this document and — where the runtime can
detect it — in a warning printed by constle validate and constle run.
Constle would rather tell an operator that a guardrail is inert than let them
believe in one that isn't.
2. ConventionsLink to this section
2.1 Required vs. optionalLink to this section
A field marked required causes the runtime to reject the manifest if it is absent or empty. A field marked optional may be omitted; where a default exists, it is documented and applied at parse time.
Some fields are conditionally required — required only when another field is present. These are listed in full in §17.
2.2 Enforcement labelsLink to this section
Every field carries exactly one label:
| Label | Meaning |
|---|---|
| ENFORCED | The runtime actively prevents violations at execution time. If the agent violates the constraint, the runtime blocks the action or stops the run. |
| VALIDATED | The runtime checks the value is well-formed and internally consistent at parse/validate time, and rejects the manifest if not. It does not constrain behaviour during execution. |
| DECLARED | The value is parsed, defaulted, and carried through (displayed, logged, or exported), but no code path changes behaviour based on it. Enforcement may be planned; it does not exist today. |
| INFORMATIONAL | The runtime does not read the field at all. It exists for humans and external tooling. |
The distinction between DECLARED and ENFORCED is the most important thing in this document. If a field is DECLARED, you cannot rely on the runtime to stop the agent from violating it. A DECLARED security field is documentation, not a control.
2.3 Where enforcement happensLink to this section
Every enforcement point in Constle sits outside the sandbox, at a chokepoint the agent's traffic must physically traverse:
| Chokepoint | Enforces |
|---|---|
| Sandbox environment construction (per run) | credentials |
| Squid egress proxy (per run) | sandbox.network.allowed_hosts |
| MCP gate proxy (per run) | mcp.servers[].tools, human_gates.*, spending.* metering |
| A2A gate + host listener (per run) | a2a.peers authorization, envelope signing and verification |
| Supervisor process | limits.max_duration_seconds, sandbox.memory_mb |
The first row is the only one that acts before the agent exists rather than while it runs, and that is what makes it an enforcement point rather than a filter: the environment is composed, the sandbox is started with it, and there is no later moment at which an agent could ask for more.
This is not an implementation detail — it is the reason certain intuitively desirable fields do not exist. Constle assumes nothing inside the sandbox is trustworthy. A control that depended on the agent truthfully announcing its own behaviour would be reliable only while it was unnecessary. See §14.3 for the worked example (filesystem write gating).
3. Document structureLink to this section
apiVersion: constle.dev/v1alpha1 # required
kind: AgentManifest # required
identity: ... # who the agent is, and its cryptographic identity
sandbox: ... # how to run and isolate it
capabilities: ... # declared action classes; set the capability floor
credentials: ... # the host environment variables the sandbox receives
mcp: ... # MCP servers reachable through the gate proxy
a2a: ... # signed agent-to-agent peers
spending: ... # cost caps, metered at the MCP gate
limits: ... # hard runtime constraints
human_gates: ... # when to pause for human approval
compliance: ... # audit and regulatory metadata
metadata: ... # descriptive onlyAll sections except apiVersion, kind, and identity.name are optional.
A key this specification does not define is a validation error, not a
warning and not a silently ignored line. Every control in an Agentfile is
opt-in, so a discarded key removes the control it was written to declare:
capabilties: empties the capability list and drops the isolation floor,
requre_approval_for: leaves a gate declared and unarmed. Neither failure is
visible — the manifest validates, and the runtime reports the weakened
configuration as if it had been asked for. Matching is exact, so a
case-variant key (apiversion:) is rejected on the same grounds as a
misspelled one. The only open namespace is metadata.labels, whose keys are
free-form by design.
An Agentfile is exactly one YAML document. A second document — anything
after a --- separator — is a validation error rather than ignored content,
for the same reason: a decoder reads one document and stops, so a trailing
document declares nothing while reading as though it does. --- at the start
and ... at the end are markers on the single document and remain valid.
4. Top-level fieldsLink to this section
4.1 apiVersionLink to this section
| Type | string |
| Required | yes |
| Valid values | constle.dev/v1alpha1 |
| Enforcement | VALIDATED |
The schema version of this manifest. Must be exactly constle.dev/v1alpha1;
any other value is rejected with an error naming the expected value.
The v1alpha1 suffix is a promise about stability, not a version of Constle
itself: see §16.
4.2 kindLink to this section
| Type | string |
| Required | yes |
| Valid values | AgentManifest |
| Enforcement | VALIDATED |
The resource type. Must be exactly AgentManifest. The field exists so that
future resource types (for example a separately-distributed AgentPolicy) can
share the same file convention without ambiguity.
5. Section: identityLink to this section
Who this agent is, and — optionally — the cryptographic identity that signs its audit log and its agent-to-agent traffic.
identity:
name: "invoice-processor"
version: "1.2.0"
owner: "finance@company.com"
did: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H99mXQkL3vUbEr8W3hosJqFr"5.1 identity.nameLink to this section
| Type | string |
| Required | yes |
| Enforcement | VALIDATED |
Human-readable name for the agent. It identifies the agent in constle ps,
constle stop, every audit log entry, and the on-disk identity directory
(~/.constle/identities/<name>/). Must be non-empty.
Recommended format is lowercase with hyphens. The name is not a security
boundary: it is not unique across machines, and nothing is authorized on the
basis of it. When you need an identifier that cannot be forged or reassigned,
that is identity.did.
5.2 identity.versionLink to this section
| Type | string |
| Required | optional |
| Recommended format | semver, e.g. 1.0.0 |
| Enforcement | DECLARED |
The version of this agent's own code and configuration — not the version of the
manifest schema (that is apiVersion) and not the version of this
specification.
It is displayed by constle validate and carried into run output. Its value is
in incident reconstruction: when an audit log shows an agent behaving oddly on
a given day, version is what tells you which build produced it.
5.3 identity.ownerLink to this section
| Type | string |
| Required | optional |
| Enforcement | VALIDATED — conditionally ENFORCED (see below) |
Email address or identifier of the human accountable for this agent.
When the agent has a DID identity and the stored identity records an owner
and both values are non-empty, they must match: a run whose Agentfile
declares a different owner than ~/.constle/identities/<name>/identity.json
is refused. The check is an equality comparison, and it only binds when both
sides are populated — it is a guard against an Agentfile drifting away from
the identity it claims, not an authorization system.
Without an owner, attribution in a compliance review is guesswork. Set it.
5.4 identity.didLink to this section
| Type | string |
| Required | optional |
| Valid values | a did:key identifier encoding an Ed25519 public key |
| Enforcement | ENFORCED |
The agent's cryptographic identity: a
did:key identifier that
self-describes an Ed25519 public key, base58btc-encoded (did:key:z…).
Create one with:
$ constle identity create my-agentand paste the printed DID here.
Only the public DID appears in the manifest. The private key lives at
~/.constle/identities/<name>/key.pem (mode 0600, in a 0700 directory) and
never enters the Agentfile, the audit log, or the sandbox — the same
indirection principle as human_gates.notify[].url_secret_ref.
did:key is the only supported method. It is self-describing: the
verification key is recovered from the identifier string alone, so there is no
resolution step, no registry, and no network dependency in the trust path.
Other methods are rejected at validate time. See §22 for why did:web and
did:constle are deliberately not supported yet.
When did is set, three things become true:
- Every audit log entry is signed and hash-chained. Each JSONL entry
carries
did,prev_hash(SHA-256 of the previous raw line), andsig(Ed25519 over the entry withsigabsent).constle audit verifychecks every signature and the whole chain, and reports the exact line and kind of tampering —invalid_signature,chain_break_missing_entry,chain_break_reordered, ordid_mismatch. constle runfails closed. The run refuses to start if the matching private key is missing, unreadable, has permissions other than exactly 0600, or derives a DID different from the one declared. A declared identity must never look real when it isn't.constle validatewarns rather than fails, since validation is not execution.- Identity-scoped features unlock.
spending.max_per_day_usdand anya2aconfiguration require a DID and are rejected without one.
Full design: spec/identity.md.
6. Section: sandboxLink to this section
How Constle runs and isolates the agent.
sandbox:
isolation: network
image: "python:3.11-slim"
command: ["python", "/workspace/agent.py"]
memory_mb: 512
disk_mb: 2048
network:
egress: restricted
allowed_hosts:
- "api.groq.com"6.1 sandbox.isolationLink to this section
| Type | string |
| Required | optional |
| Valid values | none, process, network, kernel |
| Default | the capability floor (§8) |
| Enforcement | ENFORCED (validation against the capability floor, then backend selection) |
The isolation level this agent requires. The minimum sufficient level is always
derived from capabilities (§8) — the strongest level any declared capability
requires. That derived level is called the capability floor, and it applies
whether or not this field is written:
- Omitted. The runtime resolves the level to exactly the capability floor.
- Declared. The runtime holds the declared level to that floor. Equal or stronger is accepted; weaker is a validation error.
constle validate prints the level it resolved and whether it was declared or
inferred.
A value outside the four listed above is a validation error, not an
unknown level to be worked around. Matching is exact, so kernal, Kernel
and " kernel " are all rejected. Nothing tries to guess which level was
meant: an unrecognized level would otherwise rank below every real one and be
satisfied by the weakest backend on the host — a typo silently converting a
kernel requirement into no requirement at all.
A declared level weaker than the capability floor is a validation error for the same reason, reached by writing the weaker level instead of mistyping the stronger one. Declaring a level may only strengthen the boundary, never weaken it — otherwise writing the line would make the boundary weaker than omitting it would have:
$ constle validate agent.yaml # capabilities: [external_transfer], isolation: network
error: validation failed: sandbox.isolation: "network" is weaker than the "kernel"
minimum required by capability "external_transfer" — a declared level may only
strengthen the boundary, never weaken it; raise it to "kernel" or drop that
capabilityThe refusal names every capability sitting at the floor, not just the first, so dropping the named set actually lowers the floor — where dropping one of several would leave it exactly where it was. The per-capability minimums are in §8; they are not restated here.
| Level | What it provides | Use when |
|---|---|---|
none |
No isolation. Development only. | Local testing, never production |
process |
Process-level separation from the host | Agent only reads or writes local files |
network |
Network and process isolation | Agent makes outbound calls |
kernel |
Hardware-level isolation via a Firecracker microVM | Agent can move money, delete data, or spawn sub-agents |
Two separate minimums therefore govern this field, and they are checked at different times against different things:
| Minimum | Answers | Checked | Weaker is |
|---|---|---|---|
| Capability floor (§8) | what this agent may do | at validation, before any backend is chosen | a validation error |
| Backend contract (below) | what this host can build | at backend selection | a refused run, waivable with --accept-isolation |
The resolved level is a minimum contract, not a preference. The runtime selects a backend that provides at least that level and refuses to run when it cannot — a silent downgrade is precisely a protection that looks real when it isn't.
Each backend provides a fixed level, whatever the manifest asks for:
| Backend | Provides |
|---|---|
| Docker | network — separate process and network namespaces, shared host kernel |
| Firecracker | kernel — a guest kernel behind KVM |
So isolation: kernel selects Firecracker, and when Firecracker is unusable
on this machine (it requires KVM and root) the run aborts with the reason and
the setup step, instead of continuing on Docker. --backend=docker chooses an
engine; it does not relax the contract, and is refused the same way. An
explicit isolation: likewise chooses a level; it does not relax the
capability floor, and is refused the same way.
The one way to proceed with a weaker boundary than this host can build is for an operator to name it:
constle run --accept-isolation=network agent.yamlThe level must be strictly weaker than the declared minimum, and the selected
backend must still provide at least the accepted level. The run then prints an
ISOLATION DOWNGRADE ACCEPTED notice carrying both levels, and its
run_started audit entry records the requested level in isolation_level,
the delivered one in details.isolation_achieved, and
details.isolation_downgrade_accepted: true. Requested and achieved isolation
are never collapsed into a single field.
The flag cannot waive the capability-floor refusal: that check fires at validation, before any backend is selected, so the flag never reaches it. An operator can accept a weaker boundary than this machine offers; nobody can accept an Agentfile that contradicts itself.
What the flag can still do is deliver a run whose achieved boundary is below
the capability floor — --accept-isolation=network puts an external_transfer
agent on Docker, whatever the Agentfile says. That is the point of the flag, and
it is why that path is refused unless named, printed as
ISOLATION DOWNGRADE ACCEPTED, and recorded in the audit log. The floor governs
what an Agentfile may declare; the flag governs what an operator may
knowingly accept for one run. Neither is silent.
The same separation holds in the CLI: the run summary labels the manifest
level requested, and the settled sandbox line carries the isolation actually
achieved, naming the requested level beside it whenever the two differ.
6.2 sandbox.imageLink to this section
| Type | string |
| Required | optional in the schema; required in practice by both backends |
| Enforcement | ENFORCED |
The container image to run. The Docker backend pulls and runs it directly; the Firecracker backend resolves it to a rootfs.
Validate() does not reject a manifest without an image, because a manifest
can legitimately be validated for its policy content alone. A run without an
image fails at the backend.
Validate() does reject an image that starts with -. The Docker backend
passes the image to docker run as its first positional argument, and a value
spelled like an option (-v, --privileged) would otherwise be read as one,
with sandbox.command supplying its operands. The backend also ends option
parsing with -- before the image, so this check is the early, named error
rather than the only guard.
image: "python:3.11-slim"
image: "ghcr.io/myorg/myagent:v1.2.0"Pin a digest or an immutable tag for anything you care about. A mutable tag means the thing you audited and the thing that runs are only incidentally the same.
6.3 sandbox.commandLink to this section
| Type | list of strings |
| Required | optional |
| Default | the image's own CMD |
| Enforcement | ENFORCED |
The command to run inside the sandbox, passed through as the container command.
Exec form only — a list of arguments, not a shell string. There is no shell
interpolation. Elements may start with -: the command follows the image, past
the point where docker run reads options, so they reach the container
verbatim — as arguments to the image's ENTRYPOINT, for instance.
command: ["python", "/workspace/agent.py"]6.4 sandbox.memory_mbLink to this section
| Type | integer |
| Required | optional |
| Default | 512 |
| Unit | megabytes |
| Enforcement | ENFORCED |
Maximum RAM available to the agent. The Docker backend passes it as the container memory limit; the Firecracker backend sizes the microVM with it. In both cases the limit is imposed from outside the sandbox and the agent cannot raise it. Exceeding it kills the workload.
6.5 sandbox.disk_mbLink to this section
| Type | integer |
| Required | optional |
| Default | 2048 |
| Unit | megabytes |
| Enforcement | DECLARED |
Intended maximum writable disk space. Parsed and defaulted, but not applied by either backend today. An agent can currently fill the host disk regardless of this value. Treat it as documentation of intent until it moves to ENFORCED.
7. Section: sandbox.networkLink to this section
What the agent may reach on the network. With the MCP gate, this is the most security-critical part of the manifest.
sandbox:
network:
egress: restricted
allowed_hosts:
- "api.anthropic.com"
- "arxiv.org"7.1 sandbox.network.egressLink to this section
| Type | string |
| Required | optional |
| Valid values | restricted, open, none |
| Default | restricted |
| Enforcement | DECLARED |
Intended egress policy mode.
This field is not enforced. It is parsed and defaulted, but no code path
reads it. Egress is governed entirely by allowed_hosts below: the sandbox has
no default gateway, and every outbound connection must pass the proxy
allowlist. Setting egress: open does not open the network — an agent with
egress: open and an empty allowed_hosts reaches nothing at all.
The field is retained because the split between a policy mode and the allowlist
is expected to become real, and removing it now would break existing files.
Until then, do not reason about network exposure from this value; read
allowed_hosts.
7.2 sandbox.network.allowed_hostsLink to this section
| Type | list of strings |
| Required | optional (an empty list means no egress) |
| Charset | lowercase letters, digits, -, .; one optional leading . — see below |
| Enforcement | ENFORCED |
The allowlist from which the per-run egress proxy is built. This is the field that actually constrains the network. Everything not listed is refused.
allowed_hosts:
- "api.groq.com"
- "api.openai.com"
- "api.anthropic.com"
- ".example.com" # matches example.com and all subdomainsEntries are hostnames. An entry beginning with . matches that domain and all
its subdomains; otherwise the match is exact. Ports, schemes, and paths are not
part of the matching — an entry names a host, and the ports that host is
reachable on are fixed by the proxy, not by the entry (see Destinations and
ports below).
Each entry must be a plain hostname: dot-separated labels of lowercase ASCII
letters, digits, and hyphens (no leading or trailing hyphen, at most 63
characters per label and 253 in total), optionally prefixed with a single ..
Anything else — uppercase, whitespace, control characters, a scheme, a port, a
path, a wildcard, or a non-ASCII name (use its punycode form) — is rejected at
validate time. The grammar is strict because each entry is written verbatim
into the per-run Squid configuration, which has no escaping for ACL values: an
entry carrying a newline would end the allowlist directive and start another,
so "example.com\nhttp_access allow all" would open all egress. Such a
manifest is rejected outright. One spelling per host also keeps the two rules
below exact: they compare entries literally, so a HOST.DOCKER.INTERNAL that
Squid still matched would otherwise slip past them.
An IPv4 address written in dotted-quad form satisfies this grammar and is accepted. Squid then matches it literally, so listing an address permits connections to it; the raw-IP rule described under enforcement refuses only addresses that are not listed. Listing one does not exempt it from the destination rule below: a private or link-local address is refused whether it was written into the allowlist or arrived as the answer to a DNS query.
The provider hosts used as examples in this document (api.groq.com, api.openai.com, api.anthropic.com, etc.) are illustrative, not endorsements or defaults — substitute whatever hosts the agent's actual tools and model calls need.
How it is enforced. The agent's sandbox is attached only to an internal network with no route to the internet. A per-run Squid proxy is the sole bridge, and it enforces the allowlist. On the Firecracker backend the same proxy runs on the host, with nftables restricting the guest to it. Enforcement is at the OS network layer: the agent cannot bypass it by unsetting proxy environment variables or dialling an IP directly, because there is no route.
On the Firecracker backend the proxy is the host's own Squid package, and
packaging details differ between distro families (for example, the user Squid
drops privileges to). Constle resolves such details from the host at runtime
rather than assuming any one distribution; scripts/setup-firecracker checks
for the required host tools up front. The Docker backend is unaffected — its
proxy runs inside a pinned container image.
Destinations and ports. An allowlisted name settles which host, not where that host turns out to be or what may be carried to it.
Matching is literal, and with reverse lookups disabled: a destination address matches only an entry spelling that same address, never by being resolved back to a name — a PTR record naming an allowlisted host does not admit the address, and that record is written by the address's owner. Separately, the address a name resolves to is checked, and a destination in the loopback, link-local (which is where cloud instance metadata lives), private, CGNAT or unspecified ranges is refused — in both address families, and whatever the DNS answer said. That check has to be made on the resolved address: no validation of the entry itself can know where a name will point at run time, and the per-run proxy reaches the host's own network.
Ports are fixed: a request may name port 80 or 443, and a CONNECT tunnel may
name 443 alone. Without that, an allowlisted hostname is a raw TCP tunnel to
any port it listens on — SSH, a database, an internal admin service — since the tunnel's
contents are opaque to the proxy by construction. A host that must be reached
on another port is out of scope for the allowlist as it stands.
Blocked attempts are recorded as network_blocked audit events; permitted ones
as network_allowed.
Two further entries are rejected at validate time rather than silently accepted, because each would open a bypass around a stronger control:
- Any host that also appears under
mcp.servers[].urlora2a.peers[].endpoint. Allowlisting it would let the agent reach that server or peer directly, bypassing the gate proxy that enforces tool allowlists, human gates, spending metering, and A2A signing. MCP and A2A traffic is routed through the gate automatically; it must not — and need not — appear here. localhost,127.0.0.1, orhost.docker.internal, whenmcpora2aare declared. These name the sandbox's host, which is where the gate transport listens. (::1is not a valid entry at all — see the grammar above.) Allowlisting them wholesale would expose the gate itself and every other host service to the agent.
Both comparisons are on names rather than on spellings. An entry here is
lowercase with no trailing dot, because the grammar above admits nothing else,
while a host read from mcp.servers[].url or a2a.peers[].endpoint is part of
a URL, where API.EXAMPLE.COM and api.example.com. are legal spellings of
api.example.com — and reach the same server through the proxy, which matches
names case-insensitively. Declaring a server in one spelling and allowlisting
it in another is therefore the same overlap, and is refused as one.
For the same reason a declared url or endpoint must have an ASCII host,
given as the ASCII form it resolves to — punycode for an internationalised
name, exactly as an allowlist entry must be. An HTTP client resolves a URL's
host through IDNA before it connects, so api。example.com — written with
U+3002 rather than a full stop — reaches api.example.com, and
bücher.example reaches xn--bcher-kva.example. Neither spelling can appear
in allowed_hosts, so neither can be compared against it; a non-ASCII host is
refused at validate time rather than compared in two alphabets, and the
refusal names the form that should have been written.
Both are errors, not warnings. A bypass that is merely warned about is a bypass.
8. Section: capabilitiesLink to this section
A flat list of strings naming the classes of action this agent performs.
capabilities:
- read_file
- write_file
- web_search
- external_api
- send_email| Type | list of strings |
| Required | optional |
| Enforcement | ENFORCED for the capability floor; DECLARED otherwise |
This is the complete set of recognised values. An unrecognised entry is a validation error, not a warning — a typo'd capability must not silently lower the isolation level the agent is held to.
| Value | Meaning | Minimum isolation |
|---|---|---|
read_file |
Read files | process |
write_file |
Write files | process |
web_search |
Outbound HTTP for search | network |
external_api |
Call external APIs | network |
send_email |
Send email | network |
spawn_subagent |
Start another agent | kernel |
external_transfer |
Move money or financial assets | kernel |
delete_records |
Permanently delete data | kernel |
The list drives exactly two things, and nothing else:
1. The capability floor (ENFORCED). The runtime derives the strongest level
any declared capability requires. When sandbox.isolation (§6.1) is omitted,
that derived level is the one it uses; when the field is declared, the derived
level is a floor the declaration may not go below. An agent declaring
[web_search, external_transfer] floors at kernel, because
external_transfer demands it — so omitting sandbox.isolation resolves it to
kernel, declaring isolation: kernel is accepted, and declaring
isolation: network is refused at validation naming external_transfer.
The floor binds the capabilities the Agentfile declares, which are
self-asserted. An agent that simply omits external_transfer and declares
isolation: none still validates and still runs at none; nothing at runtime
derives the level from what the agent can actually do. See §8.1.
2. Advisory gate reporting (DECLARED). Capabilities naming an irreversible
action — send_email, spawn_subagent, external_transfer, delete_records
— are reported by constle validate as requiring approval. This is advice,
not enforcement. Declaring send_email here gates nothing on its own.
Enforcement happens only through human_gates.require_approval_for (§14.2),
which matches MCP tool names.
8.1 What capabilities is notLink to this section
It is not a sandbox permission system. Declaring read_file does not grant
file access, and omitting it does not remove it — the agent's actual filesystem
access comes from the image and the mounts, not from this list. Nothing at
runtime blocks an undeclared action on the basis of its absence here.
Note also the shape of the document: capabilities is a flat list of strings,
and mcp: and a2a: are separate top-level keys, not nested under it.
They are independent wiring with their own validators; nothing reads them
through this list.
9. Section: credentialsLink to this section
The host environment variables the sandbox receives.
credentials:
- name: ANTHROPIC_API_KEY
- name: GROQ_API_KEY
secret_ref: GROQ_API_KEY_PROD| Type | list of objects |
| Required | optional |
| Enforcement | ENFORCED |
This list is complete and exclusive — for host environment variables. No
variable reaches a sandbox from the machine constle is running on unless it is
declared here. The runtime's own per-run variables (§9.3) reach it as well, and
so do the container image's ENV and the few the container runtime supplies
itself (PATH, HOME, HOSTNAME, and TERM when a terminal is attached);
those are properties of the image and the runtime, not of the operator's
environment, and this section does not govern them. What it governs completely is the operator's side of the boundary.
An Agentfile with no credentials section receives no host variables at
all. That is the whole point of the section, and it is a change in behaviour
from spec version 0.3.0 and earlier — see §21, and §20.3 for why it is recorded
as a breaking change.
Enforcement happens at sandbox environment construction, before the agent process exists: the backend builds the environment from this list and starts the sandbox with it. There is no runtime filter inside the agent, because a control that ran inside the sandbox would be a control the agent could remove (§2.3).
"Credential" names the motivating case rather than the mechanism. The section also carries non-secret operator input. A task prompt is not a secret, but it is a host variable, and this is the only door — so it is declared here like anything else. Everything declared here is handled as a secret regardless: the runtime never prints a value, and records the entry in the audit log by name only (§9.4).
9.1 credentials[].nameLink to this section
The variable's name inside the sandbox. Required.
| Type | string |
| Required | required |
| Enforcement | VALIDATED |
Must be a portable environment variable name: an initial ASCII letter or
underscore, then letters, digits and underscores. Anything else is a validation
error rather than something the runtime escapes, and the reason is the same one
sandbox.network.allowed_hosts is refused rather than escaped (§7.2) — the
value is rendered into formats that have no escaping for it:
- The Docker backend passes each variable as
docker run -e NAME, with no=, specifically so the value is resolved from the client's own environment instead of appearing in a world-readable argv. A name containing=turns that back into an inline-e NAME=VALUE. - The Firecracker backend writes each variable into the guest's environment
file as
export NAME='value'. The value is single-quote escaped; a name cannot be, because a quoted name is not an assignment. A name carrying a quote, a semicolon or a newline closes that statement and opens another one, in a file the guest sources before the agent runs.
Names must be unique across entries, compared case-insensitively. Two
entries claiming one variable have no answer to which value the agent receives
other than iteration order — and FOO beside foo is one variable on Windows
and two on unix, so a case-sensitive comparison would make that answer depend on
the host OS. This is the same reasoning as the reserved-name matching below.
Reserved names. A name the runtime builds for the run itself is refused:
| Refused | Why |
|---|---|
anything beginning CONSTLE_ |
The MCP and A2A gate URLs carry this run's gate token and are the agent's only route to its gates; the Firecracker guest's network parameters describe its own address. A prefix rather than a list, so a variable added later is protected from the moment it exists. |
HTTP_PROXY, HTTPS_PROXY |
The per-run egress proxy address — the sandbox's only route to the network. |
ALL_PROXY, FTP_PROXY |
Also the per-run proxy address. Clients honour ALL_PROXY as the fallback for every scheme, so a client that read a host-supplied value would address a proxy that does not exist on the sandbox's network — losing the request, and with it the record of the attempt. |
NO_PROXY |
The set of destinations exempt from that proxy: the gate address when one is bound, and nothing otherwise. A host-supplied value would exempt whatever the operator's own network happens to list. |
Matching is case-insensitive, and that is a correctness requirement rather
than caution. Constle runs on Windows as well as unix, and Windows environment
variables are case-insensitive — so http_proxy and HTTP_PROXY are one
variable there and two on unix. A case-sensitive rule would make whether an
Agentfile can overwrite its own sandbox's egress path a property of the host OS.
The rule is a prefix and an exact set, not a substring search: PROXY_API_KEY
and MY_CONSTLE_TOKEN are ordinary third-party variables and are accepted.
9.2 credentials[].secret_refLink to this section
The host variable holding the value. Optional; defaults to name.
| Type | string |
| Required | optional |
| Enforcement | ENFORCED |
This indirection is what makes the scoping per-agent rather than per-name. Two
agents can both read ANTHROPIC_API_KEY inside their sandboxes while resolving
it from ANTHROPIC_API_KEY_PROD and ANTHROPIC_API_KEY_DEV on the host:
credentials:
- name: ANTHROPIC_API_KEY
secret_ref: ANTHROPIC_API_KEY_DEVThe value never enters the Agentfile — only the name of the variable that holds
it. Same principle as human_gates.notify[].url_secret_ref (§14.6) and
identity.did (§5.4): secrets are referenced, never embedded.
It is held to the name grammar of §9.1, because the value is looked up by that
name, but not to the reserved-name list. secret_ref names a variable in
the operator's own environment; forwarding the operator's own HTTP_PROXY into
a sandbox under some other name is their business, and refusing spellings there
would reject legitimate host layouts.
A declared credential the host cannot supply fails the run. constle run
refuses to start — before any sandbox resource is created — when the named host
variable is unset, or is set to the empty string. The two are reported
differently, because the remedy differs and because an empty value is not a
usable credential: forwarding it would put the agent in exactly the state a
missing one produces, with nothing recorded to say so.
constle validate warns rather than failing. Validation is not execution,
and an Agentfile is legitimately validated on a machine holding none of the
keys — a CI runner, or a reviewer's laptop. This is the same split as
identity.did (§5.4): declared-but-unusable is a warning at validate time and
a refusal at run time.
9.3 What is not a credentialLink to this section
The runtime's own variables. These reach the agent regardless of this section. They are not operator secrets being scoped — they are how the sandbox is reachable and contained — and an Agentfile can neither add to them nor replace them (§9.1).
| Variable | When | Backend |
|---|---|---|
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, FTP_PROXY (and lowercase) |
every run | both |
NO_PROXY (and lowercase) |
every run; empty unless a gate is bound | both |
CONSTLE_MCP_<ID>_URL |
one per declared mcp.servers entry |
both |
CONSTLE_A2A_URL |
when a2a.peers are declared |
both |
CONSTLE_GUEST_CIDR, CONSTLE_GATEWAY_IP |
every run | Firecracker only |
Every proxy name is written on every run, including the ones the runtime has no value of its own for, and that is load bearing rather than tidy: a name that is reserved in the validator but never written leaves the environment composition with nothing to overwrite, so the second guard below would not exist for it.
The runtime sets every proxy variable explicitly, including the two it has no
value of its own for, and that is a consequence of this section rather than a
detail of it. The Docker CLI adds proxy variables to every container it starts
from the proxies block of the operator's ~/.docker/config.json, whether or
not constle asked. An explicit value wins over that injection, so every name the
CLI can fill in must be written — otherwise the operator's own arrives in the
sandbox undeclared, with no way for an operator to stop it, since these names
cannot be declared either. Each of those values is a URL, so where one carries
user:password@host the leak is the operator's proxy credentials and not merely
their internal hostnames.
They are applied after the declared credentials when the environment is composed, so the ordering itself denies the overwrite even for a manifest that never went through validation. Two independent guards, because a fix that existed only at validate time would be one refactor away from being no fix.
That second guard is only as complete as the table above: it holds for a name because the runtime writes that name, not because the validator refuses it.
human_gates.notify[].url_secret_ref (§14.6) names a variable the host
process reads in order to deliver a webhook. It is resolved outside the sandbox
and never forwarded into it, so it is not declared here.
The capability floor is unaffected. A credential is not a capability and
does not imply one. sandbox.isolation is derived from capabilities and from
nothing else (§8), so declaring ANTHROPIC_API_KEY does not raise the floor to
network. Stated explicitly because the opposite is a natural assumption, and
relying on it would be relying on nothing.
9.4 Audit recordLink to this section
The run_started entry records credentials_granted: the declared names, in
declaration order.
"details": {
"backend": "docker",
"image": "python:3.11-slim",
"isolation_achieved": "network",
"credentials_granted": ["ANTHROPIC_API_KEY", "AGENT_TASK"]
}Names only. Never values, and never a digest of a value either — a hash of a
low-entropy secret is broken offline, and nothing in this specification requires
proof of which key was used. The host variable named by secret_ref is not
recorded: it describes the operator's own environment layout, not what the agent
received.
The key is present even when the list is empty, so "this agent was granted
nothing" is a recorded fact rather than the absence of one. An entry with no
credentials_granted key at all is a run from a version that predates this
section — which is to say, one where every key the operator had reached every
agent.
Declaration order is used rather than sorted order because it is deterministic
for a given Agentfile, and a JSON array's order is part of the signed bytes of
the entry (§5.4). When identity.did is declared, the record is therefore
signed and hash-chained like every other entry: what an agent was granted
becomes something a later reader can verify rather than reconstruct.
10. Section: mcpLink to this section
The Model Context Protocol servers this agent may call.
mcp:
servers:
- id: web-search
url: "https://mcp-search.example.com/mcp"
tools: ["search"]
pricing:
meters:
- usage_path: "result.usage.input_tokens"
usd_per_unit: "0.00000300"
- usage_path: "result.usage.output_tokens"
usd_per_unit: "0.00001500"Every declared server is reachable only through the Constle gate proxy — a
protocol-aware chokepoint, the MCP analogue of the HTTP egress proxy. The agent
receives CONSTLE_MCP_<ID>_URL pointing at the gate; the real URL never enters
the sandbox, and the sandbox network blocks every direct path to it (§7.2).
The gate is what makes tool allowlists, human gates, and spending metering enforceable: the call must physically traverse it.
Transport surface. The gate accepts only the three HTTP methods Streamable
HTTP defines on an MCP endpoint — POST, which carries every JSON-RPC message,
GET, which opens the server→client SSE stream, and DELETE, which terminates
the session. Any other method is refused with 405 Method Not Allowed and an
Allow: GET, POST, DELETE header, and is never forwarded. Because the spec
puts every JSON-RPC message on a POST — "Every JSON-RPC message sent from
the client MUST be a new HTTP POST request to the MCP endpoint" — a GET or
DELETE carrying a request body is refused with 400. Both refusals are
recorded as mcp_request_blocked audit events.
This matters because the gate's checks are driven by the JSON-RPC it reads: a
tools/call smuggled onto a method the gate did not inspect would reach the
upstream with the tool allowlist, the human gate, spending metering, and the
tool_call_start / tool_call_end records all skipped.
Unambiguous bodies only. The same reasoning applies one level down. The
gate inspects a copy of the request and forwards the original bytes, so a body
whose members two conforming parsers resolve differently is one the gate cannot
make a promise about: a repeated params resolves last-wins for some parsers
and first-wins for others, and a member that differs from another only in case
binds for a case-folding parser and not for a case-sensitive one. Either lets
the gate inspect one call while the upstream runs another. The gate therefore
refuses — with 400 and an mcp_request_blocked event — any body holding two
members of one object that are equal, or that differ only in case, at any
depth, params.arguments included, rather than guessing which was meant. The
two strings the gate routes on, method and params.name, are refused on the
same principle when they differ from the spelling the gate matches only by
case, by surrounding whitespace, or by a control character.
One endpoint, no traversal. A client may append a sub-path after the server
id, and the gate forwards it under the declared endpoint: with a declared
url of https://mcp.example.com/v1/mcp, a request to
$CONSTLE_MCP_<ID>_URL/messages reaches https://mcp.example.com/v1/mcp/messages
and nothing else. The sub-path can only descend.
The gate judges the sub-path after one decode, which is also what the origin
gets back off the wire, and refuses — with 400 and an mcp_request_blocked
event — any segment that a second reading of the same bytes would turn into
structure: a . or .. segment however it was encoded, an interior empty
segment, a percent sign that survives the first decode (the mark of an origin
being asked to decode twice, as in %252e%252e%252f), a path parameter
(..;/, which servers that strip ;... before normalising read as ..), and
a backslash (a separator to an origin on a platform that treats it as one).
Those are the readings that are known, and the list cannot be complete: NFKC
normalisation folds . into . and ‥ into .., a Windows best-fit code
page maps ∕ to / and ¥ to \, and a server that re-parses the decoded
path ends it at ? or #. So every byte of the decoded sub-path must also be
an RFC 3986 unreserved character — A–Z, a–z, 0–9, -, ., _,
~ — and anything else, including any non-ASCII character and a space, is
refused the same way. Encoding an unreserved character is untouched:
report%2Ejson decodes to report.json and is forwarded.
Nothing is normalised, because normalising picks one of the readings and the
gate cannot know which one the origin will pick. The declared url is held to
the same rules — an endpoint path that is itself ambiguous is refused when the
gate is built, since a base that does not mean one thing cannot bound anything
— with one difference: its path may also contain @ and :, which hosted MCP
servers use in the paths they serve (/@org/name/mcp). The difference runs one
way. The endpoint is a constant the operator wrote; the sub-path is chosen by
whoever sends the request and gets nothing beyond the unreserved set, so no
request can place either character anywhere the Agentfile did not. Neither
character is inert to every origin — a Windows file server may read a path that
starts /C: as a drive — and that reading is the operator's to avoid, in a
string only the operator writes.
This keeps the tool allowlist meaningful when several MCP servers share one origin. A traversal out of the declared endpoint would otherwise reach a neighbouring server's endpoint, which the gate would forward under this server's allowlist, human gates and metering.
No protocol upgrades. Streamable HTTP defines none, so a request carrying
an Upgrade header or the upgrade token in Connection is refused with
400 and an mcp_request_blocked event, and a 101 Switching Protocols from
an upstream fails the response with 502. An accepted upgrade would stop the
exchange being HTTP at all: the gate would be holding open a raw bidirectional
tunnel it cannot inspect, to a host the sandbox is forbidden to reach directly
(§7.2), for as long as either side kept it open.
10.1 mcp.servers[].idLink to this section
| Type | string |
| Required | yes |
| Charset | lowercase letters, digits, -, _ |
| Enforcement | VALIDATED |
A unique local identifier for this server. It names the environment variable
the agent reads (web-search → CONSTLE_MCP_WEB_SEARCH_URL: hyphens become
underscores, uppercased) and appears in audit events. Must be unique across
servers; duplicates are rejected.
10.2 mcp.servers[].urlLink to this section
| Type | string |
| Required | yes |
| Valid schemes | http, https |
| Enforcement | ENFORCED (host-side only) |
The real endpoint of the MCP server. Streamable HTTP is the only supported MCP transport, so other schemes are rejected. The URL must have a host.
Its path is held to the rules under One endpoint, no traversal in §10: after
one decode it may contain no . or .. segment, no empty segment except a
trailing one, and no byte outside A–Z, a–z, 0–9, -, ., _, ~,
@ and :. A path that breaks one fails constle run when the gate is built,
before any sandbox is started; constle validate does not check it.
This value is host side only. It is never forwarded into the sandbox, and
its host must not appear in allowed_hosts (§7.2).
10.3 mcp.servers[].toolsLink to this section
| Type | list of strings |
| Required | optional |
| Default | empty — every tool is allowed through |
| Enforcement | ENFORCED |
An allowlist of tool names the agent may call on this server. When present, a
tools/call naming anything else is refused at the gate and recorded as an
mcp_tool_blocked audit event. When omitted, every tool passes through —
gated tools (§12) still gate.
Declaring the allowlist is worth the effort: it converts "this server exposes 40 tools and the agent probably only uses 2" from a trust assumption into an enforced fact.
10.4 mcp.servers[].pricingLink to this section
| Type | object with a meters list |
| Required | optional |
| Enforcement | ENFORCED |
When present, the gate proxy meters every tools/call response from this
server and charges it against the caps in spending (§12).
Pricing is deliberately server-wide. A priced server cannot expose an
"unpriced" tool: a response missing a declared usage value is a metering
failure that kills the run (fail closed), because a server that could omit its
usage field could zero its own bill. To mix free and priced tools from one
upstream, declare its URL twice under two ids with disjoint tools
allowlists — one priced, one not.
Pricing is always declared here by the operator, never guessed or hardcoded per provider, so the metering code stays generic and auditable.
A pricing block with an empty meters list is rejected: it could not measure
anything, and would read as "priced" while metering nothing.
pricing.meters[].usage_pathLink to this section
| Type | string |
| Required | yes |
| Enforcement | VALIDATED (syntax), ENFORCED (extraction) |
A dot-separated path into the full JSON-RPC response message, locating one
usage number. A digit segment indexes an array:
result.content.0.usage.input_tokens.
There are no wildcards. The path is an exact, deterministic contract — the same principle as exact tool-name matching for gates. A pattern language here would mean the bill depended on a fuzzy match.
pricing.meters[].usd_per_unitLink to this section
| Type | string (exact decimal) |
| Required | yes |
| Precision | at most 8 decimal places (1e-8 USD) |
| Enforcement | VALIDATED (parse), ENFORCED (charge) |
The price of one usage unit, as a decimal string, never a YAML float. Floats cannot represent decimal money exactly, and a rounding error in a spending cap is a security bug, not a cosmetic one. Internally all money is integer micro-cents.
The cost of one response is the sum over all meters — a list, because real API pricing rates input and output units differently.
11. Section: a2aLink to this section
Signed agent-to-agent communication with explicitly declared peers.
a2a:
listen: ":9443"
peers:
- name: summarizer
did: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H99mXQkL3vUbEr8W3hosJqFr"
endpoint: "https://summarizer.example.net/a2a"Every peer is declared by the operator, with its DID and endpoint exchanged out of band. There is deliberately no discovery mechanism. An agent can only ever exchange A2A calls with peers written into this file — it cannot find, resolve, or be introduced to a peer it was not already configured to know about. That is a scope decision, not a gap.
All A2A traffic is signed and verified in the host Constle process using
this agent's identity, so identity.did is required. The sandbox never signs,
never verifies, and never learns a peer's real endpoint: it talks only to the
per-run gate at CONSTLE_A2A_URL.
Inbound calls carry replay protection: duplicate msg_ids are rejected
against a durable per-identity store, so the guarantee spans process restarts
and concurrent runs on the same machine — not only the run that first saw the
message. The remaining, documented limitation is that this state is
per-machine: the same identity listening on several machines does not share a
seen set.
Full design, including the inbound listener hardening, envelope format, and
the replay-guard store: spec/a2a.md.
11.1 a2a.listenLink to this section
| Type | string (host:port or :port) |
| Required | optional |
| Enforcement | ENFORCED |
The host-side address on which this agent's Constle process accepts inbound calls from declared peers. Omit it for outbound-only agents.
The listener runs on the host, never in the sandbox. It relays a call
inward only after the call passes signature verification and its sender DID
appears in peers. Verified calls are parked in a bounded per-peer inbox that
the agent drains over a connection it initiates.
Declaring listen without peers is an error. No sender could ever be
authorized, so the listener could only ever reject — a configuration that looks
like connectivity and provides none.
11.2 a2a.peers[].nameLink to this section
| Type | string |
| Required | yes |
| Charset | lowercase letters, digits, -, _ |
| Enforcement | VALIDATED |
A local alias for the peer, used in gate URLs and audit events. Must be unique.
This is the only way the sandbox can name a peer — it posts to
$CONSTLE_A2A_URL/send/<name>, and an undeclared name is rejected at the gate
with a 403. Nothing in the sandbox can name an endpoint.
11.3 a2a.peers[].didLink to this section
| Type | string (did:key) |
| Required | yes |
| Enforcement | ENFORCED |
The peer's did:key identifier. The verification key for every message to and
from this peer is recovered from this string alone — no registry, no resolution
service.
Rejected at validate time: a malformed DID, two peers declaring the same DID
(sender identity would be ambiguous), and a peer DID equal to this agent's own
identity.did.
11.4 a2a.peers[].endpointLink to this section
| Type | string (URL) |
| Required | yes |
| Valid schemes | http, https |
| Enforcement | ENFORCED (host-side only) |
The peer's public A2A URL — its host process's a2a.listen address. Host side
only; never forwarded into the sandbox, and its host must not appear in
allowed_hosts (§7.2).
12. Section: spendingLink to this section
Cost guardrails, enforced against traffic metered at the MCP gate.
spending:
max_per_run_usd: "0.50"
max_per_day_usd: "5.00"
max_per_month_usd: "50.00"
alerts:
warn_at_pct_of_daily: 8012.1 Enforcement scope — read this before relying on a capLink to this section
Limits are enforced against cost metered at the MCP gate proxy, for servers
that declare a pricing block (§10.4). Nothing else is metered.
In particular, traffic through sandbox.network.allowed_hosts is not
metered. Constle refuses to TLS-intercept it: doing so would let the runtime
read everything the agent says to every allowlisted host, which is far beyond
what cost metering needs. The consequence is stated plainly rather than hidden:
a limit declared without a priced MCP server measures nothing at all.
constle validate and constle run warn explicitly in each of these cases:
| Situation | Warning |
|---|---|
| Limits declared, no priced MCP server | Limits are not enforced — nothing to meter |
Limits declared, priced servers present, allowed_hosts non-empty |
Limits cover only the priced servers; allowed_hosts traffic is unmetered |
| Priced servers present, no limits declared | Usage is metered but nothing is enforced |
max_per_month_usd declared |
Not enforced by this version |
12.2 Amount formatLink to this section
All amounts are exact decimal strings, never YAML floats, for the reason
given in §10.4. A cap of "0" is rejected as ambiguous — at enforcement
time a zero cap would read as "unset", so the manifest must say which it means:
omit the field to leave a limit unset.
12.3 spending.max_per_run_usdLink to this section
| Type | string (exact decimal) |
| Required | optional |
| Enforcement | ENFORCED |
Hard cap on metered cost for a single run. Crossing it trips the gate and kills
the run through the same path as limits.max_duration_seconds, recording a
spending_limit_reached audit event naming max_per_run_usd.
The cap trips when the running total exceeds it. Because metering is post-hoc — a response's cost is only knowable once the response has arrived — the charge that crosses the cap is still incurred and still recorded. The ledger records reality; enforcement stops what happens next.
12.4 spending.max_per_day_usdLink to this section
| Type | string (exact decimal) |
| Required | optional |
| Requires | identity.did |
| Enforcement | ENFORCED |
Hard cap per UTC calendar day, tracked durably across runs in
~/.constle/spending/<did>/ under a file lock, so concurrent runs of the same
identity share one ledger.
It requires identity.did and is rejected without one. Keying the ledger
by name would let a rename reset the tracking, which is not a cap.
Two behaviours follow from durability:
- A run whose accumulated daily spend already meets or exceeds the cap is
refused before the sandbox starts, with a
spending_limit_reachedevent recordingaction: run_refused. Starting it would guarantee an overshoot, since the kill can only land after a charge is metered. - An unreadable ledger is a hard error, never treated as
$0spent.
12.5 spending.max_per_month_usdLink to this section
| Type | string (exact decimal) |
| Required | optional |
| Enforcement | DECLARED |
Not enforced by this version. The value is parsed and validated, but no monthly ledger exists. Declaring it produces an explicit warning rather than silent false assurance.
12.6 spending.alerts.warn_at_pct_of_dailyLink to this section
| Type | integer, 1–100 |
| Required | optional |
| Requires | spending.max_per_day_usd |
| Enforcement | ENFORCED (non-blocking) |
Writes a one-time spending_limit_reached warning to the audit log when the
day's total first crosses this percentage of max_per_day_usd. It never blocks
a call — it is a signal, not a control.
The threshold comparison is exact (cross-multiplied in arbitrary precision), so
it cannot drift or overflow. Setting it without max_per_day_usd is an
error: there would be no cap to warn about.
13. Section: limitsLink to this section
Hard runtime constraints.
limits:
max_duration_seconds: 30013.1 limits.max_duration_secondsLink to this section
| Type | integer |
| Required | optional |
| Default | 0 (no limit) |
| Unit | seconds |
| Enforcement | ENFORCED |
Maximum wall-clock run time. On expiry the runtime stops the sandbox and
records a terminated_by_limit audit event. 0 or omitted means no limit.
A negative value is rejected rather than read as "no limit", and so is a value
above 9223372036, the largest number of seconds the runtime's timer can
represent. Above that the number no longer converts to the time it names: just
past the bound it converts to a negative duration and the run is killed at
once, and further out it comes back round as a fraction of a second. Either
way a limit written to be effectively infinite would end the run almost
immediately, so both are refused. (The field is an integer, so on a 32-bit
build its own width is the lower limit in practice.)
This is a supervisor-side timer, not a request the agent can decline.
14. Section: human_gatesLink to this section
When the agent must stop and ask a human.
human_gates:
enabled: true
require_approval_for:
- "send_email"
approval_timeout_seconds: 300
on_timeout: abort
notify:
- channel: webhook
url_secret_ref: "HUMAN_GATE_WEBHOOK_URL"Human gates are the primary defense against an agent being talked into a consequential action — by a prompt injection, a poisoned document, or its own misjudgement.
14.1 human_gates.enabledLink to this section
| Type | boolean |
| Required | optional |
| Default | false |
| Enforcement | ENFORCED |
The master switch. When false, no gating occurs at all, even if
require_approval_for lists entries. Set it to true for any agent whose
gates you intend to rely on.
Because the default is false, a require_approval_for list written without
enabled: true gates nothing. constle validate and constle run report such
entries as NOT enforced and warn, naming this switch — the entries are declared
but disarmed, and a declared protection must never look real when it isn't.
14.2 human_gates.require_approval_forLink to this section
| Type | list of strings — MCP tool names |
| Required | optional |
| Enforcement | ENFORCED for entries matching a declared MCP tool |
Mapping contract: an entry gates a call when it is an exact,
case-sensitive match for the tool name — the params.name of a tools/call
request — on any server declared under mcp.servers. The tool name is the only
protocol-level identifier the gate proxy observes, and exact match is the only
deterministic, auditable mapping. There is no semantic guessing and no pattern
syntax.
When a gated call arrives, the gate pauses it, emits a gate_triggered audit
event, notifies any configured webhook, and waits for a decision — recorded as
gate_approved, gate_denied, or gate_timeout. An approved call is then
forwarded like any other, bracketed by tool_call_start / tool_call_end
audit events — the same events every forwarded MCP tool call emits, gated or
not, so the log leading up to a gate prompt shows what the agent was doing
before it asked.
Entries that cannot match are reported, not silently ignored. An entry that
provably matches no tool on any declared server is surfaced as a warning at
both validate and run time, stating that those calls will run without
approval. An entry is treated as possibly-enforced when any declared server
omits its tools allowlist, since the runtime match is against the actual tool
name of every call.
Note the consequence: with no mcp.servers declared, nothing is gated, and
Constle says so.
14.3 Why there is no {action, paths, condition} formLink to this section
Gating a filesystem write — "require approval for writes under
/workspace/output" — cannot be expressed here, and the reason is
architectural rather than a missing feature. It is worth stating in full,
because the omission otherwise looks like an oversight.
Constle's core assumption is that nothing inside the sandbox is trusted. If the agent is compromised, anything it reports about its own behaviour is attacker-controlled. A gate that depended on the sandbox announcing "I am about to write this path" would be exactly the wrong shape: reliable only while it was unnecessary.
Every gate that exists today is enforced at a chokepoint outside the sandbox. The MCP gate proxy sees the tool call because the call must physically traverse it. The egress proxy sees the connection for the same reason.
File writes have no such external chokepoint yet. Enforcing on them requires
host-side observation of the filesystem — a watcher standing in the same
relation to writes as the proxy does to network traffic. That component does
not exist. Until it does, a paths/condition field could only be implemented
by trusting the sandbox, so it is absent by design rather than unimplemented
by accident.
14.4 human_gates.approval_timeout_secondsLink to this section
| Type | integer |
| Required | optional |
| Default | 300 |
| Enforcement | ENFORCED |
How long a gated call waits for a decision before on_timeout applies. A
negative value is rejected, and so is a value above 9223372036, the largest
number of seconds the wait can represent. Above that the number no longer
converts to the time it names — just past the bound it converts to a negative
duration and the gate expires the instant it opens, and further out it comes
back round as a fraction of a second. With on_timeout: proceed either one
forwards the call with no human in the loop, so both are refused. (The field
is an integer, so on a 32-bit build its own width is the lower limit in
practice.)
When stdin is not a terminal — a backgrounded run, a pipe, CI — no human can
answer, so the gate says so once and simply waits for the deadline, letting
on_timeout decide. It does not block forever on a read that can never
resolve, and it does not silently treat "nobody is watching" as approval.
14.5 human_gates.on_timeoutLink to this section
| Type | string |
| Required | optional |
| Valid values | abort, proceed |
| Default | abort |
| Enforcement | ENFORCED |
| Value | Behaviour |
|---|---|
abort |
The gated call is refused and the run stops. The safe default. |
proceed |
The call is forwarded without approval. |
This field decides every gate that reaches its deadline without a decision,
which is a wider set than "nobody was watching." An unreachable decision
endpoint, an endpoint that answers but never with a parseable decision, and a
declared approver_pubkey whose notify URL never resolved all arrive here too
(spec/human-gates-webhook.md §8.2) — Constle cannot tell a silent approver
from a broken channel, and does not try to. A decision that does arrive and
fails verification is a denial rather than a timeout, and this field does not
affect it.
There is deliberately no retry. Use abort: an agent that proceeds without
approval after a timeout has a gate that reduces to a delay — and under
proceed, a broken decision channel reduces to the same thing.
14.6 human_gates.notifyLink to this section
| Type | list of {channel, url_secret_ref} |
| Required | optional |
| Supported channels | webhook |
| Enforcement | ENFORCED |
Where to signal that a gate has triggered. An unsupported channel is a
validation error, not a warning — a declared notification path must never
look real when it isn't. A webhook entry without url_secret_ref is
likewise rejected.
url_secret_ref names the environment variable holding the webhook URL,
keeping the secret out of the committed manifest — the same indirection as
identity.did keeping the private key out.
Delivery of the notification is fire-and-forget: the gate never blocks on it, and a failed delivery never blocks the approval flow. An unset environment variable produces a visible warning and the gate still enforces locally.
The same URL is also where a decision is fetched from when
human_gates.approver_pubkey is set — one URL serves both, and the signed
decision channel is specified in spec/human-gates-webhook.md. Where several
entries resolve, notifications fan out to every resolved URL but decisions are
polled from the first one only; the others are signal endpoints and cannot
answer a gate. With no approver_pubkey declared, or with none of the notify
URLs resolving, the webhook is a signal only, and the local prompt plus
on_timeout are the whole enforcement. In the second case Constle warns at run
time rather than refusing to start, so a gate can be armed with its remote
decision channel silently absent — on_timeout (§14.5) is what decides such a
gate.
15. Section: complianceLink to this section
Regulatory and audit metadata.
compliance:
audit_log_level: standard
frameworks:
- "EU_AI_ACT"
- "SOC2_TYPE2"
geo_restrictions:
allowed_regions: []
denied_regions: []15.1 compliance.audit_log_levelLink to this section
| Type | string |
| Required | optional |
| Valid values | none, minimal, standard, verbose |
| Default | standard |
| Enforcement | DECLARED |
Not enforced. The value is parsed and defaulted, but no code path varies
logging on it — audit output is identical at every level today, and setting
none does not disable the audit log.
15.2 compliance.frameworksLink to this section
| Type | list of strings |
| Required | optional |
| Enforcement | INFORMATIONAL |
Regulatory frameworks the deployment is meant to satisfy. Descriptive metadata for external policy engines, auditors, and registries. Constle neither validates the names nor changes behaviour based on them.
Common values: EU_AI_ACT, SOC2_TYPE2, ISO27001, HIPAA, PCI_DSS.
15.3 compliance.geo_restrictionsLink to this section
| Type | object with allowed_regions and denied_regions string lists |
| Required | optional |
| Enforcement | INFORMATIONAL |
Region identifiers where the agent may or may not run. Parsed and carried through for downstream tooling. Constle does not determine its own region and cannot refuse to run on this basis. These lists constrain nothing today.
16. Section: metadataLink to this section
Descriptive fields, never read by the runtime when making execution decisions. All INFORMATIONAL.
metadata:
description: "Processes invoices and routes them to the approval queue."
author: "finance-team@company.com"
license: "Apache-2.0"
labels:
team: "finance"
cost_center: "cc-1042"
environment: "production"| Field | Type | Notes |
|---|---|---|
description |
string | What this agent does, for humans |
author |
string | Email, DID, or handle |
license |
string | SPDX identifier for the agent's code |
labels |
map of string to string | Arbitrary key/value pairs for cost allocation, ownership, environment tagging. No enforced key names or formats. |
17. Cross-field validation rulesLink to this section
These rules involve more than one field, and all of them are errors, not warnings. Each closes a path where a declared control could be silently inert or bypassed.
| Rule | Rationale |
|---|---|
spending.max_per_day_usd requires identity.did |
The daily ledger is keyed by DID; keying by name would let a rename reset it |
spending.alerts.warn_at_pct_of_daily requires max_per_day_usd |
No cap to warn about |
A spending cap of "0" is rejected |
Ambiguous between "no spending allowed" and "unset" |
a2a.* requires identity.did |
Every A2A call is signed with the agent's identity |
a2a.listen requires a non-empty a2a.peers |
No sender could ever be authorized |
Peer DIDs must be unique, and none may equal identity.did |
Sender identity would be ambiguous |
mcp.servers[].id and a2a.peers[].name must be unique and match the id charset |
They are embedded in env var names, gate URLs, and audit events |
sandbox.network.allowed_hosts entries must be plain hostnames |
Written verbatim into the Squid allowlist: a newline injects a directive, whitespace a second host |
An MCP server URL host must not appear in allowed_hosts |
Would bypass the gate proxy |
An A2A peer endpoint host must not appear in allowed_hosts |
Would bypass the signing gate |
Host loopback aliases must not appear in allowed_hosts when mcp or a2a are declared |
Would expose the gate transport and other host services |
An mcp.servers[].pricing block must declare at least one meter |
Would read as priced while metering nothing |
human_gates.notify[].channel must be webhook, with a url_secret_ref |
A declared notification path must never look real when it isn't |
| An unrecognised capability is rejected | A typo must not silently lower the capability floor |
credentials[].name must be a portable environment variable name |
Rendered into docker run -e NAME and into an export NAME='value' line the guest sources: a = restores an inline value in a world-readable argv, a quote or newline opens a second shell statement |
credentials[].name must not be CONSTLE_*, HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, FTP_PROXY or NO_PROXY (any case) |
Would let the Agentfile replace its own sandbox's egress path or gate URL |
credentials[].name must be unique, compared case-insensitively |
Two entries for one variable leave the value to iteration order; a case variant is one variable on Windows |
credentials[].secret_ref must be a portable environment variable name |
The value is looked up by it |
| An unrecognised key is rejected (§3) | A typo must not silently drop the control the key declares |
| A second YAML document is rejected (§3) | Everything after the first document is ignored, so it would declare nothing |
A declared sandbox.isolation may not be weaker than its capabilities require |
Writing the line must not make the boundary weaker than omitting it |
Warnings — surfaced, but not fatal — cover the cases where a declaration is
well-formed but the runtime cannot act on it: unenforceable gate entries, gate
entries declared beneath enabled: false, unmetered spending limits,
max_per_month_usd, an identity.did whose private key is not available on this
machine, and a credentials entry whose host variable is not set here — which
constle run then refuses outright (§9.2).
18. Enforcement summaryLink to this section
| Field | Enforcement | Notes |
|---|---|---|
apiVersion |
VALIDATED | Must be constle.dev/v1alpha1 |
kind |
VALIDATED | Must be AgentManifest |
identity.name |
VALIDATED | Required; appears in all audit events |
identity.version |
DECLARED | Displayed and carried through |
identity.owner |
VALIDATED | Enforced as an equality check against the stored identity when both are set |
identity.did |
ENFORCED | Signs and chains the audit log; run fails closed without the local key |
sandbox.isolation |
ENFORCED | Resolved from the capability floor when absent; held to it when declared; drives backend selection |
sandbox.image |
ENFORCED | Pulled and run by the backend; a leading - is rejected at validate time |
sandbox.command |
ENFORCED | Passed as the container command |
sandbox.memory_mb |
ENFORCED | Container memory limit / microVM size |
sandbox.disk_mb |
DECLARED | Parsed and defaulted; not applied |
sandbox.network.egress |
DECLARED | Parsed and defaulted; no code path reads it |
sandbox.network.allowed_hosts |
ENFORCED | Per-run Squid allowlist; the real egress control |
capabilities |
ENFORCED (capability floor) / DECLARED (gate advice) | Unknown values rejected |
credentials[].name |
ENFORCED | The complete set of host variables the sandbox receives; nothing undeclared reaches it |
credentials[].secret_ref |
ENFORCED | Host variable resolved into name; run fails closed when it is unset or empty |
mcp.servers[].id |
VALIDATED | Unique; names CONSTLE_MCP_<ID>_URL |
mcp.servers[].url |
ENFORCED | Host side only; never enters the sandbox; forwarding is scoped to this endpoint |
mcp.servers[].tools |
ENFORCED | Non-listed tools blocked at the gate |
mcp.servers[].pricing |
ENFORCED | Meters every tools/call response; fails closed on missing usage |
a2a.listen |
ENFORCED | Host-side listener; verifies before relaying inward |
a2a.peers[].name |
VALIDATED | The only peer reference the sandbox can name |
a2a.peers[].did |
ENFORCED | Verification key for every message |
a2a.peers[].endpoint |
ENFORCED | Host side only; never enters the sandbox |
spending.max_per_run_usd |
ENFORCED | Trips the gate and kills the run |
spending.max_per_day_usd |
ENFORCED | Durable per-DID ledger; refuses to start when exhausted |
spending.max_per_month_usd |
DECLARED | No monthly ledger exists; warned about |
spending.alerts.warn_at_pct_of_daily |
ENFORCED | One-time non-blocking audit warning |
limits.max_duration_seconds |
ENFORCED | Sandbox stopped; terminated_by_limit recorded |
human_gates.enabled |
ENFORCED | Master switch; false disables all gating |
human_gates.require_approval_for |
ENFORCED | Exact MCP tool-name match; unmatchable entries warned |
human_gates.approval_timeout_seconds |
ENFORCED | Default 300 |
human_gates.on_timeout |
ENFORCED | Default abort |
human_gates.notify |
ENFORCED | Webhook only; unsupported channel is an error |
compliance.audit_log_level |
DECLARED | Parsed and defaulted; logging does not vary |
compliance.frameworks |
INFORMATIONAL | Descriptive metadata |
compliance.geo_restrictions |
INFORMATIONAL | Constle cannot determine its own region |
metadata.* |
INFORMATIONAL | Never read for execution decisions |
19. ExamplesLink to this section
19.1 MinimalLink to this section
The smallest valid Agentfile that does something useful:
apiVersion: constle.dev/v1alpha1
kind: AgentManifest
identity:
name: "my-agent"
sandbox:
image: "python:3.11-slim"
command: ["python", "/workspace/agent.py"]
network:
allowed_hosts:
- "api.openai.com"Runs python /workspace/agent.py in a Python 3.11 container, blocks all
outbound traffic except to api.openai.com, applies no time limit, and
writes an unsigned audit log.
19.2 FullLink to this section
A production-shaped manifest exercising every enforced control:
apiVersion: constle.dev/v1alpha1
kind: AgentManifest
identity:
name: "invoice-processor"
version: "2.1.0"
owner: "finance-team@company.com"
did: "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP"
sandbox:
isolation: kernel
image: "ghcr.io/myorg/invoice-agent:2.1.0"
command: ["python", "main.py"]
memory_mb: 1024
network:
allowed_hosts:
- "arxiv.org"
capabilities:
- read_file
- write_file
- external_api
- send_email
credentials:
- name: ANTHROPIC_API_KEY
secret_ref: ANTHROPIC_API_KEY_FINANCE
- name: AGENT_TASK
mcp:
servers:
- id: erp
url: "https://mcp-erp.example.com/mcp"
tools: ["lookup_invoice", "post_payment"]
pricing:
meters:
- usage_path: "result.usage.input_tokens"
usd_per_unit: "0.00000300"
- usage_path: "result.usage.output_tokens"
usd_per_unit: "0.00001500"
- id: email
url: "https://mcp-email.example.com/mcp"
tools: ["send_email"]
spending:
max_per_run_usd: "0.50"
max_per_day_usd: "10.00"
alerts:
warn_at_pct_of_daily: 80
limits:
max_duration_seconds: 300
human_gates:
enabled: true
require_approval_for:
- "post_payment"
- "send_email"
approval_timeout_seconds: 300
on_timeout: abort
notify:
- channel: webhook
url_secret_ref: "HUMAN_GATE_WEBHOOK_URL"
compliance:
audit_log_level: verbose
frameworks:
- "EU_AI_ACT"
- "SOC2_TYPE2"
metadata:
description: >
Reads incoming invoices, validates them against the ERP, and initiates
payments. Human approval is required before every payment and every email.
author: "finance-team@company.com"
license: "Proprietary"
labels:
team: "finance"
cost_center: "cc-1042"
environment: "production"Note what makes the gates in this example real: post_payment and send_email
are exact tool names declared under mcp.servers[].tools, so the gate proxy
matches and pauses them. Had they been written as capability names not exposed
by any declared server, constle validate would warn that they gate nothing.
20. Versioning and compatibilityLink to this section
20.1 Two version numbersLink to this section
| Number | What it versions | Current |
|---|---|---|
| Spec version | This document — its prose, structure, and accuracy | 0.5.0 |
apiVersion |
The wire format the runtime accepts | constle.dev/v1alpha1 |
The spec version changes whenever this document changes materially, including
when a field's enforcement status changes without any change to the format. The
apiVersion changes only when the format itself changes incompatibly.
20.2 apiVersion progressionLink to this section
| apiVersion | Status | Meaning |
|---|---|---|
constle.dev/v1alpha1 |
Current | Unstable. Field names and semantics may change. |
constle.dev/v1beta1 |
Planned | Stable field names. New fields may be added. |
constle.dev/v1 |
Planned | Fully stable. Backward-compatible changes only. |
The runtime will support the previous apiVersion for at least one major
release after it is deprecated; a v1beta1 runtime will run v1alpha1
manifests and record a deprecation warning.
20.3 What is a breaking changeLink to this section
A previously valid manifest being rejected, or behaving differently without modification:
- renaming a field;
- changing a field's type;
- making an optional field required;
- removing a valid enum value;
- changing a default in a way that affects security behaviour.
20.4 What is notLink to this section
- adding a new optional field;
- adding a new valid enum value;
- adding an entirely optional section;
- moving a field from DECLARED to ENFORCED;
- closing a gap between two fields that are already ENFORCED — enforcing a constraint one of them always implied, but which the runtime failed to check.
The last two deserve comment, and share a reason. Promoting a field to ENFORCED
can certainly stop an agent that a previous version let run — but the manifest
declared the constraint, and Constle's whole premise is that a declared
constraint should be real. Closing a gap between two ENFORCED fields is the same
case seen from a different angle: the constraint was already declared by the
pair, and the runtime simply was not checking it. sandbox.isolation and
capabilities are both ENFORCED, yet a declared level weaker than the
capability floor used to validate — a manifest whose own two halves contradicted
each other, resolved silently in favour of the weaker one. Under this
specification both are bug fixes, not breaches of compatibility. Both are always
called out in the changelog.
21. ChangelogLink to this section
0.5.0 — 2026-09-23Link to this section
Changed — the MCP gate forwards a sub-path only in unreserved characters (§10):
- The gate refused a sub-path segment that was a dot segment, or that held
%,;or\after one decode, and forwarded everything else re-encoded. An origin that reads those bytes a second way turned some of them into exactly that structure: NFKC normalisation folds.(U+FF0E) into.,‥(U+2025) into..and/into/; a Windows best-fit code page maps∕(U+2215) to/on code page 1252 and¥to\on 932; a server that re-parses the decoded path ends it at?or#, and drops a tab as WHATWG URL parsers do. Against an origin that normalises with NFKC, a request for$CONSTLE_MCP_<ID>_URL/%EF%BC%8E%EF%BC%8E/adminon an endpoint of/v1/mcpwas served as/v1/admin— outside the declared endpoint, under this server's tool allowlist, human gates and metering. - Every byte of the decoded sub-path must now be an RFC 3986 unreserved
character:
A–Z,a–z,0–9,-,.,_,~. Anything else is refused with400and anmcp_request_blockedevent whose reason names the set. The refusals that already existed keep their own reasons. Percent-encoding an unreserved character is unaffected:report%2Ejsonis still forwarded asreport.json. - The rule is an allowlist rather than a longer list of refusals because the second readings cannot be enumerated: every normalisation form and every code page is one more table.
Changed — the declared endpoint is held to a byte set of its own (§10.2):
- An
mcp.servers[].urlwhose path is itself ambiguous was already refused. Its path must now also consist, after one decode, of the unreserved characters plus@and:. Those two are admitted for the endpoint alone: hosted MCP servers serve paths such as/@org/name/mcp, and the endpoint is a constant the operator wrote. The sub-path, which the sender writes, does not get them. - The refusal happens where the other endpoint refusals already did: when
constle runbuilds the gate, before any sandbox is started.constle validatedoes not report it.
Breaking under §20.3:
- Both changes can refuse what worked before without modification. A request
whose sub-path carried a space, a non-ASCII character or any other byte
outside the set now gets
400where it reached the upstream, and an Agentfile whosemcp.servers[].urlpath carries a byte outside the endpoint's set now failsconstle run. This is recorded as a breaking change rather than argued into §20.4, although the sub-path half closes a gap in a guaranteemcp.servers[].urlalready made as an ENFORCED field — forwarding scoped to the declared endpoint.apiVersionis unchanged:v1alpha1is documented as unstable (§20.2), and the format did not change. - Migration: a fixed sub-path that needs
@or:can move into the declaredurl, where both are admitted. A sub-path that varies per request cannot, and there is no setting that widens the sub-path's set.
0.4.0 — 2026-09-21Link to this section
Added — credentials, and the host environment is no longer ambient (§9):
- The runtime forwarded a hardcoded set of host variables —
ANTHROPIC_API_KEY,GROQ_API_KEYandAGENT_TASK— into every sandbox whenever they were set on the host. An agent that needed one of the operator's keys received all of them. Which keys an agent held was a property of the operator's shell, identical for every agent on the machine, and nothing in the Agentfile said a word about it. - An agent now receives exactly the variables it declares under
credentials, resolved from the host by name or throughsecret_ref. The section is the complete and exclusive list, enforced where the backend composes the sandbox's environment — before the agent process exists, so there is no later moment at which it could ask for more. - An Agentfile with no
credentialssection receives no host variables at all. This is a breaking change under §20.3 — a previously valid manifest behaves differently without modification, and a default that affects security behaviour changed — and it is not covered by any §20.4 exemption: no field was promoted from DECLARED to ENFORCED, because no field existed. It is recorded here rather than treated as a bug fix.apiVersionis unchanged:v1alpha1is documented as unstable (§20.2), and the format itself gained an optional section rather than changing. - Migration is one block per Agentfile, naming the variables the agent already
read.
constle validateandconstle runprint acredentialsrow on every manifest, including the empty case, stating that nothing is forwarded — so the change is visible in the summary rather than only in a failure from inside the container. - A declared credential whose host variable is unset, or set to the empty string,
fails
constle runbefore any sandbox resource is created, and warns atconstle validate. Same split asidentity.did(§5.4). Previously a missing key was indistinguishable from an unset one: the backend's owndocker run -e NAMEspelling sets nothing and exits 0 when the variable is absent. - Non-secret operator input is declared here too — a task prompt is not a secret, but it is a host variable, and this is the only door (§9). The alternative, a second section for non-secret values, would be a second door needing its own scoping story.
credentialsdoes not affect the capability floor, and §9.3 says so outright: a credential is not a capability and implies none.
Hardened — two paths that only became reachable once the names came from the Agentfile (§9.1, §9.3):
- Variable names are held to the portable environment-variable grammar. The
Firecracker backend writes each one into an
export NAME='value'line in a file the guest sources as root — the value is quoted, a name cannot be — and the Docker backend passes-e NAMEwith no=specifically to keep values out of a world-readable argv. A name carrying a quote, a newline or an=breaks out of one or the other. Refused rather than escaped, for the same reasonsandbox.network.allowed_hostsis (§7.2). - The runtime sets every proxy variable explicitly, including
NO_PROXYandALL_PROXY, which it previously left alone. The Docker CLI injects proxy variables into every container from the operator's~/.docker/config.json, so leavingNO_PROXY,ALL_PROXYandFTP_PROXYunset delivered the operator's internal proxy host and internal domains into the sandbox undeclared — and, since each is a URL, the operator's proxy password with them where one was configured. For a client that prefersALL_PROXYit also meant requests addressed to a proxy with no route, failing without ever appearing in the run's Squid-derived network audit. The Firecracker backend now writes the same names for a second reason: a name reserved in the validator but never written leaves the composition ordering with nothing to overwrite, so the validation-independent guard did not coverNO_PROXYon a run with no gate bound, norALL_PROXYorFTP_PROXYat all. Both found by independent review of this change rather than by the change itself. - Names the runtime builds for the run itself are refused — case-insensitively, because Windows environment variables are, so a case-sensitive rule would make the refusal depend on the host OS. Uniqueness between two declared names is compared the same way, for the same reason. The declared credentials are also applied before the runtime's own variables when the environment is composed, so the ordering denies the overwrite independently of validation. The Firecracker backend had merged the host variables over its own proxy and guest-network block, which was harmless only while the forwarded names were hardcoded.
Changed — section numbering:
credentialsis documented in Agentfile order, betweencapabilitiesandmcp, so sections 9 through 21 are renumbered to 10 through 22. Every cross-reference in this document was updated with them. No content moved.- Two cross-references that were already wrong before the renumbering are
corrected with it: §9 pointed at
spendingas §10, and §8 pointed athuman_gates.require_approval_foras §11 — the spending and human-gates sections. Both had been off since the sections around them were last reordered, and shifting them without fixing them would have moved a pointer known to be broken.
0.3.0 — 2026-09-21Link to this section
Changed — an unrecognised key is now rejected (§3, §17):
- The runtime decoded Agentfiles leniently: a key this specification does not
define was discarded without a word. Because every control here is opt-in,
a discarded key silently removed a control —
capabilties:produced an empty capability list and an isolation floor ofnone,requre_approval_for:produced a gate section with nothing in it. The manifest then validated, andconstle validatereported the weakened configuration as the intended one. Unknown keys are now errors, reported together with the line, the section, its accepted keys, and the nearest match. - Matching is exact, so
apiversion:andHuman_Gates:are rejected for the same reason as a misspelling; the runtime never guessed which key was meant and does not start now. metadata.labelsis unaffected: its keys are an open namespace by design.- An Agentfile is now required to be a single YAML document. Strictness that
stopped at the first one would have been strictness in name only: a decoder
reads one document and returns, so a second document — an unknown key, a
whole second policy, or YAML that does not parse at all — was discarded by
exactly the silence this change exists to end, and a file whose tail was
malformed was still answered with "is valid". A leading
---and a trailing...are markers on the one document and stay valid. - This is a breaking change under §20.3 — a previously valid manifest is
now rejected — and it is not covered by any §20.4 exemption. It is recorded
here rather than treated as a bug fix.
apiVersionis unchanged:v1alpha1is documented as unstable (§20.2), and the format itself did not change.
Fixed — human gates are no longer reported as enforced while disabled (§14.1, §17):
constle validateclassifiedrequire_approval_forentries purely by whether they matched a declared MCP tool, without consulting the master switch. A manifest withenabled: falsebeside an entry matching a real tool was reported asenforced … paused at the MCP gate proxy for approval, while the gate proxy — which had always honoured the switch — forwarded every such call ungated.constle runsaid nothing at all.- Such entries are now reported as not enforced, and warned about by name,
identifying the master switch as the reason. No semantics changed:
enabledstill defaults tofalseand still disarms every entry.
0.2.0 — 2026-09-13Link to this section
Two changes to sandbox.isolation, both of the kind §20.4 classifies as a bug
fix rather than a breaking change, and both rejecting manifests a previous
runtime accepted. The first shipped without a changelog entry; it is recorded
here alongside the second rather than left undocumented.
Changed — a declared level is now held to the capability floor (§6.1, §8):
- The minimum derived from
capabilitiesused to be consulted only whensandbox.isolationwas omitted. A declared level bypassed it entirely, socapabilities: [external_transfer]besideisolation: networkvalidated, was satisfied outright by Docker, and recorded no downgrade — writing the line made the boundary weaker than omitting it would have. A declared level may now only be equal to or stronger than the floor; weaker is a validation error naming every capability that forces it. --accept-isolationdoes not and cannot waive this: the refusal fires at validation, before backend selection. The flag still covers the separate backend minimum. The two minimums are now distinguished in §6.1.- The floor binds declared capabilities only. Omitting a capability still lowers it; nothing derives the level from what the agent can actually do (§8, §8.1).
- §20.4 gained the compatibility bullet this change required, since
sandbox.isolationwas already ENFORCED and so the existing DECLARED→ENFORCED exemption did not cover it.
Changed — a declared level is a minimum contract against the backend (§6.1):
- Previously documented as a preference the runtime resolved to the strongest
available backend. The runtime now refuses to run when the selected backend
cannot provide the declared level, and
--backendno longer relaxes it.--accept-isolation=<level>is the one explicit waiver, recorded in the run output and in therun_startedaudit entry. - A level outside the four defined values became a validation error rather than an unknown string ranking below every real level.
0.1.0 — 2026-08-16Link to this section
First numbered release of this specification, and the first revision verified field-by-field against the runtime rather than against intent.
Added — sections that did not previously exist in this document:
mcp: gate-proxied MCP servers, tool allowlists, and thepricing/metersmetering model (§10).a2a: signed agent-to-agent peers, the host-side listener, and the no discovery scope decision (§11).identity.did:did:keyidentity, signed and hash-chained audit logs, and the fail-closed run behaviour (§5.4).spending.alerts.warn_at_pct_of_daily(§12.6).human_gates.approval_timeout_secondsandhuman_gates.notify(§14.4, §14.6).- Cross-field validation rules, collected in one table (§17).
- The enforcement-point model — why every control sits outside the sandbox (§2.3), and the worked consequence for filesystem gating (§14.3).
Corrected — the previous revision described the runtime inaccurately:
spending.max_per_run_usdandmax_per_day_usdwere documented as DECLARED. Both are ENFORCED, metered at the MCP gate against priced servers, with a durable per-DID daily ledger. The scope limits of that metering are now stated explicitly (§12.1).human_gates.*were documented as DECLARED and "planned for v1.0". Gates are ENFORCED on MCP tool calls.human_gates.require_approval_forwas documented as taking capability categories. It takes exact MCP tool names; the mapping contract is now specified (§14.2).sandbox.network.egresswas documented as ENFORCED. It is DECLARED — no code path reads it, andegress: opendoes not open the network (§7.1).compliance.audit_log_levelwas documented as ENFORCED with a per-level event table. It is DECLARED; logging does not vary by level (§15.1).compliance.frameworksandgeo_restrictionswere documented as DECLARED; they are INFORMATIONAL.capabilitieswas documented as enforcement "planned for v0.5". Its actual role — isolation inference plus advisory gate reporting, and nothing else — is now stated, along with what it is not (§8.1).sandbox.network.allowed_hostswas documented as required underegress: restricted. It is optional; an empty list means no egress.- References to Constle release versions (v0.4, v0.5) were removed. This document now describes the runtime it ships with, and states enforcement status directly rather than by release number.
Structure:
- Added spec-level version numbering, distinct from
apiVersion(§20.1). - Added this changelog.
- Stated the relationship between this document and the executable
agent-manifest.yamlreference file, and which is normative (§1.1).
22. Roadmap — not valid manifest syntaxLink to this section
The following are planned but do not exist in the runtime. They are described here in prose, deliberately outside the field reference, so that no reader can mistake them for syntax that works. Nothing in this section may be written into an Agentfile.
identity — did:web and did:constle methods.
Both require a resolution step that did:key does not: did:web fetches a
document over HTTPS, and did:constle implies a registry. Each therefore
introduces a trust dependency — a network path and an authority — into what is
currently a self-contained verification. That dependency has to be designed
before it ships, because a DID method whose resolution can be intercepted is
worse than no DID at all. Only did:key is supported today.
human_gates — path- and condition-scoped approval for filesystem writes.
Blocked on the host-side file watcher described in §14.3. The field shape is
not the hard part; the external chokepoint is.
spending — monthly ledger enforcement for max_per_month_usd.
The daily ledger already establishes the durable, DID-keyed, lock-protected
pattern; the monthly one is the same mechanism over a wider window.
sandbox — enforcement of network.egress as a policy mode.
Making egress a real policy mode distinct from the allowed_hosts allowlist,
so that open and none mean what they say (§7.1).
sandbox — application of disk_mb.
Currently parsed and defaulted but imposed by neither backend (§6.5).