constle docs constle docs v0.5.0pre-1.0

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

Spec version
0.5.0
apiVersion
constle.dev/v1alpha1
Status
Draft. Field names and semantics may change before v1.0.
Last updated
2026-09-21
Source of truth
pkg/manifest/manifest.go and pkg/manifest/parser.go
Annotated reference file
spec/agent-manifest.yaml
Canonical URL
https://docs.constle.dev/reference/agent-manifest/

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:

shell
$ constle validate spec/agent-manifest.yaml

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

yaml
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 only

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

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

shell
$ constle identity create my-agent

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

  1. Every audit log entry is signed and hash-chained. Each JSONL entry carries did, prev_hash (SHA-256 of the previous raw line), and sig (Ed25519 over the entry with sig absent). constle audit verify checks every signature and the whole chain, and reports the exact line and kind of tampering — invalid_signature, chain_break_missing_entry, chain_break_reordered, or did_mismatch.
  2. constle run fails 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 validate warns rather than fails, since validation is not execution.
  3. Identity-scoped features unlock. spending.max_per_day_usd and any a2a configuration 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.

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

text
$ 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
capability

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

text
constle run --accept-isolation=network agent.yaml

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

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

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

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

yaml
allowed_hosts:
  - "api.groq.com"
  - "api.openai.com"
  - "api.anthropic.com"
  - ".example.com"        # matches example.com and all subdomains

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

  1. Any host that also appears under mcp.servers[].url or a2a.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.
  2. localhost, 127.0.0.1, or host.docker.internal, when mcp or a2a are declared. These name the sandbox's host, which is where the gate transport listens. (::1 is 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.

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

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

yaml
credentials:
  - name: ANTHROPIC_API_KEY
    secret_ref: ANTHROPIC_API_KEY_DEV

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

json
"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.

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

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

yaml
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: 80

12.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_reached event recording action: 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 $0 spent.

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.

yaml
limits:
  max_duration_seconds: 300

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

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

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

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

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

yaml
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/admin on an endpoint of /v1/mcp was 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 with 400 and an mcp_request_blocked event whose reason names the set. The refusals that already existed keep their own reasons. Percent-encoding an unreserved character is unaffected: report%2Ejson is still forwarded as report.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[].url whose 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 run builds the gate, before any sandbox is started. constle validate does 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 400 where it reached the upstream, and an Agentfile whose mcp.servers[].url path carries a byte outside the endpoint's set now fails constle run. This is recorded as a breaking change rather than argued into §20.4, although the sub-path half closes a gap in a guarantee mcp.servers[].url already made as an ENFORCED field — forwarding scoped to the declared endpoint. apiVersion is unchanged: v1alpha1 is documented as unstable (§20.2), and the format did not change.
  • Migration: a fixed sub-path that needs @ or : can move into the declared url, 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_KEY and AGENT_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 through secret_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 credentials section 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. apiVersion is unchanged: v1alpha1 is 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 validate and constle run print a credentials row 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 run before any sandbox resource is created, and warns at constle validate. Same split as identity.did (§5.4). Previously a missing key was indistinguishable from an unset one: the backend's own docker run -e NAME spelling 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.
  • credentials does 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 NAME with 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 reason sandbox.network.allowed_hosts is (§7.2).
  • The runtime sets every proxy variable explicitly, including NO_PROXY and ALL_PROXY, which it previously left alone. The Docker CLI injects proxy variables into every container from the operator's ~/.docker/config.json, so leaving NO_PROXY, ALL_PROXY and FTP_PROXY unset 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 prefers ALL_PROXY it 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 cover NO_PROXY on a run with no gate bound, nor ALL_PROXY or FTP_PROXY at 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:

  • credentials is documented in Agentfile order, between capabilities and mcp, 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 spending as §10, and §8 pointed at human_gates.require_approval_for as §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 of none, requre_approval_for: produced a gate section with nothing in it. The manifest then validated, and constle validate reported 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: and Human_Gates: are rejected for the same reason as a misspelling; the runtime never guessed which key was meant and does not start now.
  • metadata.labels is 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. apiVersion is unchanged: v1alpha1 is 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 validate classified require_approval_for entries purely by whether they matched a declared MCP tool, without consulting the master switch. A manifest with enabled: false beside an entry matching a real tool was reported as enforced … paused at the MCP gate proxy for approval, while the gate proxy — which had always honoured the switch — forwarded every such call ungated. constle run said 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: enabled still defaults to false and 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 capabilities used to be consulted only when sandbox.isolation was omitted. A declared level bypassed it entirely, so capabilities: [external_transfer] beside isolation: network validated, 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-isolation does 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.isolation was 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 --backend no longer relaxes it. --accept-isolation=<level> is the one explicit waiver, recorded in the run output and in the run_started audit 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 the pricing / meters metering model (§10).
  • a2a: signed agent-to-agent peers, the host-side listener, and the no discovery scope decision (§11).
  • identity.did: did:key identity, 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_seconds and human_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_usd and max_per_day_usd were 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_for was documented as taking capability categories. It takes exact MCP tool names; the mapping contract is now specified (§14.2).
  • sandbox.network.egress was documented as ENFORCED. It is DECLARED — no code path reads it, and egress: open does not open the network (§7.1).
  • compliance.audit_log_level was documented as ENFORCED with a per-level event table. It is DECLARED; logging does not vary by level (§15.1).
  • compliance.frameworks and geo_restrictions were documented as DECLARED; they are INFORMATIONAL.
  • capabilities was 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_hosts was documented as required under egress: 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.yaml reference 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).