Agent Sandbox Registry

python-sdk

03 - Policy and permissions

docs/en/archive/server-sdk-v2/03-policy-and-permissions.md

03 - Policy and permissions

Status: archive. Not the current specification.

Status: target design, not implemented. The shipped server uses endpoint ids and one policy engine; see README section 1 for what exists and section 2 for what does not.

Part of Server SDK v2. Rules R2-R4. Supersedes the scope/endpoint-rule model of fine-grained-capabilities.md (identity is the dotted op ID; scopes become derived sugar, section 13). v2.1: hardened after the independent security review (41 findings, all 41 PoCs now HELD). The normative v2.1 rules are consolidated in section 15; the earlier sections were edited to agree with them. Changes are marked (v2.1).

1. Summary, with the motivating example

"Only download is enabled, and only under /tmp":

from agent_sandbox.server import start, Policy, ops, PathScope

policy = Policy().allow(
    ops.files.download, ops.files.stat, ops.files.list,
    paths=PathScope("/tmp", deny=["**/.env"], max_bytes="10MB"),
)
start(policy=policy)       # (v2.1) core.health is mounted unless explicitly denied; start() refuses lint errors

or without writing code, as a preset (presets.download_only(root="/tmp") expands to the policy above plus core.health, which a bare allow would otherwise leave unmounted, B-1):

start(preset="download-only", root="/tmp")

or as a file baked into the image (/etc/agent-sandbox/server.json, authored as YAML in template.yaml):

server:
  preset: download-only
  root: /tmp                  # one keyword for every preset; aliases `paths:` and `workspace:` are accepted (B-8)

Effect (all verified against the spike): GET /files/download-stream?path=/tmp/a.txt works; /tmp/../etc/passwd and a symlink in /tmp pointing outside get 403 policy.path_not_allowed; /tmp/.env gets 403 policy.path_denied (and so does deny(paths="/tmp/secret") for every descendant of /tmp/secret, B-4); files.upload, files.delete, commands.run... answer 404 op.not_enabled and are absent from /capabilities.

2. Model

A policy is an ordered list of rules plus optional hooks and audit sinks.

Policy()                                     # empty: nothing is mounted (default-deny); allow()/deny() with no selector is a ValueError
  .allow(*selectors, principals=(), paths=None, commands=None, limits=None, authorize=None, name="")
  .deny (*selectors, principals=(), paths=None, commands=None, reason="", name="")
  .without(*selectors)                       # sugar for deny(selectors) with a clear reason
  .authorize(fn)                             # global hook (decorator)
  .audit(sink)                               # audit sink (decorator)
policy_a + policy_b                          # union of rules/hooks (deny still overrides)

Selectors (strings or ops.* nodes):

Selector Matches Specificity rank
files.list / ops.files.list exactly that op 4
files.* / files.** (v2.1, IF-30) ns.* = ops one level below the namespace (files.list); ns.** = any depth (agents.** includes agents.codex.sessions.create; agents.* does not) 3
group:file_ops, effect:read legacy group list / all ops carrying the effect 2
* every op (including future ones - discouraged, lint warns in non-legacy policies) 1

Explicit-only operations (v2.1, IS-C2, IS-P4, IS-G1; amends D6). On the allow side an explicit-only op is mounted only by a rule that names its exact ID; wildcard, ns.*, ns.**, group:, effect: and * never match it. The explicit-only set is: all ops whose effects include admin, core.reload, system.env.set, terminal.create, process.signal, agents.<a>.install and agents.<a>.sessions.create. Ops registered without effects= default to admin and are therefore explicit-only (lint warn core.effects_undeclared). On the deny side the opposite holds: every selector reaches them (deny("effect:exec") denies terminal.create; deny("system.*") denies system.env.set), so a deny is never weaker than the allow it must beat. A selector that is a group may not contain system.env.set (the legacy group:system loses it; the compat preset adds it back only on request, section 4). Presets may name explicit-only ops (an operator must be able to say "this preset mounts process.signal") except system.env.set, core.reload and every install, which only a hand-written allow(<exact id>) mounts. agents.<a>.sessions.create is explicit-only because creating a session starts a process (agent sessions are exec-effect ops, section 15.6).

Effects are coarse tags declared by each op (04 lists them):

Effect Meaning
read reads sandbox state (files, system info, listings)
write creates/modifies files or state
delete removes files/processes/sessions
exec runs a process or code inside the sandbox
net makes outbound network requests (browser navigation, installs, LLM calls)
admin changes server/sandbox configuration or exposes policy (env set, definitions, reload, describe-all)

effect:read is a convenient selector (a read-only server = allow("effect:read")), but exec is not read-safe: effect-selected rules never include ops whose effects also contain exec, write, delete unless the selector says so (allow("effect:read") mounts only ops whose effects are a subset of {read}).

Mounted = at least one allow rule matches the op for this principal. Not mounted -> 404 op.not_enabled. core.health is the one exception: it is mounted for everyone unless a rule explicitly denies it (B-1; readiness must not depend on the policy author remembering it).

Rule hygiene (v2.1, IS-P1, IS-P1c, B-2). * A bare allow("commands.run") (no CommandScope) and a bare allow("files.download") (no PathScope) are not "unrestricted": they fail with 403 policy.scope_required at request time and with error config.scope_required in check-config; start() refuses to run with lint errors (config.lint_errors) unless allow_lint_errors=True. * Policy.allow/deny accept selectors that match nothing only with strict=False; by default SandboxServer raises PolicyError config.unknown_selector (with did-you-mean) at construction, so the card's promise "typos error" is true. * principals=["*"] means any authenticated principal; the anonymous principal never matches it (IS-A7). * without(*selectors) is sugar for deny; hyphen and underscore spellings of presets are both accepted (download-only == download_only, B-13).

3. Evaluation algorithm (normative)

Inputs: op, args, principal. Steps (spike: Policy._decide):

  1. Mount check. Let A = allow rules whose selectors match op and whose principals match. If A is empty: 404 op.not_enabled (identical body for "unknown to policy" and "unknown to you").
  2. Deny pass (deny-overrides). For each deny rule matching op and principal (every selector kind reaches explicit-only ops):
  3. no scope -> 403 policy.denied_by_rule (with the rule's reason);
  4. with paths= globs -> the canonical value of every declared path parameter is matched, and every ancestor of it, so deny(paths="/secret") covers /secret/a/b (IS-P3, B-4); a relative argument is canonicalised against every allow anchor of the op (the roots of each candidate allow statement) and denied if any anchor yields a denied path (IS-P2); a bare directory pattern means "this directory and everything below it"; -> 403 policy.path_denied;
  5. with commands= deny patterns -> the same normalised argv that the allow pass uses (command xor argv, otherwise 400 args.invalid; one normalisation, IS-P2b) -> 403 policy.command_denied. A deny is absolute: no allow rule, hook or preset can override it. A non-removable system deny list (/proc, /sys, /dev and every directory registered with protect_dir, section 15.3) is applied to every path of every statement even when no rule mentions it (IS-F1, IS-L1).
  6. Allow pass. Sort A by (selector rank desc, has principals desc, declaration order asc). For each statement, try to admit the call: require the scope kinds the op declares (path, command, url, agent; otherwise policy.scope_required), resolve every declared path param through the statement's PathScope, check/resolve the command through its CommandScope, the URL through its UrlScope, the agent options through its AgentScope, apply limits. The first admitting statement wins and supplies scope, canonical values and limits. If none admits: 403 with the first statement's denial code (policy.path_not_allowed, policy.command_not_allowed, ...). Consequence: allow statements are a union of alternative grants, each fully constrained ("/tmp read-only" OR "/workspace read-write").
  7. Hooks (veto only). The winning statement's authorize= hook, then global hooks, run with the admitted context. A hook may return None/True (no objection), False, Deny(reason, code=...), or Narrow(limits=Limits(...)) (tighten limits further). Hooks cannot add permissions. Exceptions, timeouts (default 2 s) and unawaitable results deny (403 policy.hook_error) - fail-closed. (v2.1, IS-P5, IS-P5b) The hook receives a frozen view (MappingProxyType for args, paths, claims; tuples for argv), so it cannot mutate admitted values; any return value other than None/True, False, Deny, Narrow is 403 policy.hook_bad_return (a truthy string is not an "allow").
  8. Operator layer (section 10) is applied as additional deny rules and limit minima before step 3, so it behaves identically to author rules but cannot be removed by them.
  9. Audit record is always emitted (allowed or denied).

Why "first admitting statement wins" and not "most permissive"? Predictability for humans and AI: the more specific statement is listed (or sorts) first; limits therefore come from one statement and never from a hidden merge. check-config flags shadowed statements (a later statement for the same op that can never be reached because an earlier unconstrained one always admits).

3.1 Per-principal and per-op limits (the "different limits for different callers" case)

policy = (Policy()
  .allow(ops.files.download, principals=["role:guest"],
         paths="/srv/public", limits=Limits(max_bytes="1MB", rate="10/min"))
  .allow(ops.files.download, principals=["role:staff"],
         paths=["/srv/public", "/srv/internal"], limits=Limits(max_bytes="1GB", concurrency=4))
  .allow(ops.commands.run,  principals=["id:grader"],
         commands=CommandScope(allow=["python3 /workspace/grade.py"], cwd="/workspace", timeout=60)))
# per-tenant roots (v2.1, B-15): "{principal.id}" is substituted after validating the id as ONE safe path component
  .allow(ops.files.list, ops.files.download, principals=["*"], paths=PathScope("/data/{principal.id}"))

Principals come from authentication (section 5). Rule principals entries: id:<id>, role:<role>, * (any authenticated; never the anonymous principal, IS-A7). An empty list means every principal including anonymous. check-config warns (B-19) for a principals entry that no configured token or authenticate result can produce (id: / role: naming nobody).

4. Presets

Presets are functions returning a Policy; they compose with + and refine with .without(...). (v2.1, B-8) Every preset that needs a directory takes the one keyword root= (aliases paths= and workspace= are accepted for the in-flight spellings); there is no default root: omitting it is a ValueError because "default /" is how /proc/self/environ got readable (IS-F1). In config files the names are hyphenated (download-only) and in Python underscored (download_only); both spellings are accepted in both places (B-13). Every preset includes core.health.

Preset Expands to
presets.minimal() core.health, core.capabilities only
presets.files_readonly(root, deny=("**/.env", "**/*.pem")) files.list/stat/download/download_stream only (B-7: download_stream is part of every download grant), read-only scope
presets.readonly(root) files_readonly plus files.search/archive, system.info/metrics/ports/packages, process.list/get/output; read-only scope; the system deny list applies
presets.download_only(root, deny=("**/.env",), max_bytes="100MB") files.download/download_stream/stat/list under root
presets.commands_only(root, allow=(...), timeout=60) commands.run/stream with the given CommandScope (cwd = root)
presets.workspace_jail(root, readonly=False, commands=None) all files.* jailed to root (secrets denied), commands.run/stream only if commands= is given, system.info
presets.ci_runner(root, commands=(...), timeout=600, writable=False) (v2.1, B-9) a read-only workspace plus the command allowlist; writable=True is an explicit opt-in and then check-config applies the writable-script rule and escape-capable lint to the commands. terminal.* off
presets.agent_host(root, agents=("codex",), permissions="read-only") (v2.1, B-10) the generic agent ops a first SDK call needs (agents.definitions.create/get/list/update/delete, agents.environments.create/get/list, agents.sessions.*, agents.runtimes.list, models), the adapter ops agents.<a>.run, agents.<a>.sessions.create, agents.<a>.sessions.send_event for each listed adapter (exact IDs; install is not included), and an AgentScope(workdir=root, permissions="read-only"). Experimental adapters carry a lint warning (B-16)
presets.full_access(root, commands, unsafe) every non-explicit-only op of every group enabled by default in v1 with a root jail and a CommandScope; PathScope.anywhere/CommandScope.unrestricted only with unsafe=True. Never mounts system.env.set, core.reload, installs
presets.legacy_compat() loud and unsafe (v2.1, IS-C2): the 40 legacy op ids with the v1 BASE_DIR jail and v1 renderer for the legacy bindings; system.env.set only when ABX_SERVER_LEGACY_ENV_SET=1. start() logs legacy_compat is active at WARNING and check-config prints the diff from legacy_diff(server) (the legacy ids the effective policy drops, B-6). It is the implicit default only when ABX_SERVER_TOKEN is set or the bind is loopback

Every preset is documented with its exact expansion in describe --preset <name>; a golden snapshot test pins the op lists (no privilege creep: adding a new op to a namespace does not add it to a preset unless the snapshot is updated in the same change, reviewed by a security owner).

5. Authentication and principals

6. Path scoping

PathScope(*roots, deny=(), readonly=False, symlinks="within", max_bytes=None, create_dirs=True, allow_hardlinks=False)
PathScope.anywhere(unsafe=True)    # (v2.1) the only way to say "no jail"; a lint error
paths="/tmp"                       # shorthand: PathScope("/tmp")
paths=["/srv/a", "/srv/b"]         # multiple roots
PathScope("/data/{principal.id}")  # per-principal root (id validated as one safe component)

(v2.1) PathScope("/") and glob characters in a root raise ValueError (a root is a directory, not a pattern; / made /proc reachable, IS-F1).

Field Meaning
roots allowed roots (canonicalised with realpath at policy construction; the first root anchors relative paths)
deny glob patterns; ** crosses /, * does not, a pattern without / matches the basename anywhere (**/.env, *.pem, /etc/shadow). A pattern also matches any ancestor of the candidate, so a directory pattern denies its subtree (v2.1, IS-P3). Matching uses the linear-time segment matcher of section 15.3 (no regex, IS-P9)
readonly refuse write/delete effects through this statement
symlinks "within" (default): a symlink is fine iff its target stays inside a root; "deny": any symlink component below the root is refused; "follow" (unsafe, lint error unless unsafe=True)
max_bytes per-file cap for reads/writes through this scope (also caps archive/search output)

6.1 Resolution algorithm (spike resolve_path)

  1. Reject NUL bytes, non-strings, > 4096 chars, > 256 segments -> policy.path_invalid.
  2. Anchor relative paths at the first root; normalise lexically only after anchoring (so a/../.. cannot escape by being collapsed away from the root).
  3. If symlinks="deny": lstat every component below the root; any symlink -> policy.symlink_denied.
  4. realpath (non-strict so not-yet-existing files resolve). Must be inside a root, else policy.path_not_allowed.
  5. Match deny globs against the canonical path and each of its ancestors, then the system deny list -> policy.path_denied.
  6. readonly + write/delete effect -> policy.read_only.
  7. Return the canonical path. Handlers use only this value.

6.2 Race-free use (the TOCTOU fix)

Canonicalise-then-open is racy: an attacker with write access to a directory under a root can swap a parent for a symlink between steps 4 and the open(). The spike demonstrated that opening with O_TRUNC first and verifying through /proc/self/fd afterwards already truncated the victim file. The normative mechanism is open_beneath(scope, canonical, flags):

6.3 Multi-path and list-valued parameters

files.move(src, dst): src is checked as read+delete, dst as write, both under the same winning statement (or paths={"src": scopeA, "dst": scopeB} in the statement for different scopes). files.archive(sources[], dest): every source is read, dest is write. files.search(root, pattern): root is read; results are re-checked. A call is admitted only if every path parameter is admitted.

7. Command scoping

CommandScope(
    allow=("ls {path}", "pytest -q ...", "python3 /workspace/grade.py"),
    deny=("ls ... -R ...", "ls ... --recursive ..."),      # defense in depth ONLY (see 7.3)
    shell=False,                                            # shell=True is refused unless explicitly True
    exec_dirs=("/usr/bin", "/bin", "/usr/local/bin"),
    cwd=PathScope("/workspace"),                            # or "/workspace"
    env_pass=("PATH", "LANG", "HOME"),                      # copied from a STARTUP SNAPSHOT of the server environment (v2.1)
    env_set=(),                                             # names the CALLER may set (default: none)
    env_deny=("LD_*", "PYTHON*", "NODE_OPTIONS", ...),      # can never be set, even if listed in env_set (union with ENV_NEVER, v2.1)
    timeout=60, max_output_bytes="1MB", cpu_seconds=30, memory="512MB", processes=64,
)

7.1 Matching

Token Matches
literal (-q, pytest) exactly one argv element
glob in argument position (*.py) one argv element; a glob never matches a token containing /, .. or starting with - unless the pattern itself starts with that prefix (IS-C1, glob-token traversal); use {path} for paths
... zero or more elements (anywhere, including leading: ... -R ...)
{path} one element, not starting with -, no NUL; canonicalised through the statement's cwd PathScope and the canonical value replaces it in the executed argv
{int} [0-9]{1,9}
{word} [A-Za-z0-9_][A-Za-z0-9_.@:+=,-]*

7.2 Execution constraints

7.3 What command scoping does and does not give you

8. Hooks: authorize(ctx) -> Decision

from agent_sandbox.server import Deny, Narrow

@policy.authorize
def no_exe_uploads(ctx):
    if ctx.op == "files.upload" and ctx.paths["path"].endswith((".exe", ".dll")):
        return Deny("executables may not be uploaded", code="policy.extension_not_allowed")
    if ctx.principal.id == "guest":
        return Narrow(limits=Limits(max_bytes="1MB"))

9. Limits

Limits(max_bytes, max_entries, timeout, max_output_bytes, cpu_seconds, memory, processes, rate, concurrency).

10. Configuration sources, layering, reload

Sources, from author to operator (later layers can only restrict from layer 3 on):

# Layer How Can allow?
1 Python code SandboxServer(policy=...), start(policy=..., preset=...), enable_builtin(...) yes
2 Baked config /etc/agent-sandbox/server.json (or ABX_SERVER_CONFIG), compiled from the server: block of template.yaml or server.yaml; legacy /etc/agent-sandbox/server-policy.json is read as {"policy": ...} yes
3 Environment (author inputs) ABX_SERVER_PRESET, ABX_SERVER_ENABLED_GROUPS, ABX_SERVER_ENABLED_OPS (never able to mount an explicit-only op) yes (equivalent to layer 1)
4 Environment (restrictions) ABX_SERVER_DISABLED_GROUPS (legacy), ABX_SERVER_DISABLED_OPS, ABX_SERVER_READONLY=1, ABX_SERVER_RESTRICT_PATHS=/workspace no - restrict only
5 Operator drop-ins /etc/agent-sandbox/policy.d/*.json (root-owned, read-only mount on hardened images) no - restrict only

Merge rule: layers 1-3 are concatenated rule lists. Layers 4-5 contribute only deny rules, limits minima, path-root intersections and group/op disables; an allow in layers 4-5 is a config error (check-config and startup fail). The effective policy has a revision (sha256 of the canonical JSON) reported by /capabilities.

Config file schema (JSON; YAML accepted when PyYAML is importable; the runtime itself needs only JSON):

version: 1
preset: workspace-jail            # optional; string or list; params via `presets: [{name, ...}]`
rules:
  - allow: [files.list, files.stat, files.download]       # selectors
    principals: [role:viewer]                              # optional
    paths: {roots: [/workspace], deny: ["**/.env", "**/*.pem"], readonly: true, max_bytes: 50MB}
    limits: {rate: 60/min}
  - allow: commands.run
    principals: [id:grader]
    commands:
      allow: ["python3 /workspace/grade.py", "pytest -q {path}"]
      cwd: /workspace
      timeout: 60s
      env_pass: [PATH, LANG]
      env_set: [CI]
  - deny: [files.delete, "terminal.*"]
    reason: "not in this template"
auth: {tokens: [...]}              # section 5
audit: {sink: stderr, redact: [env, content]}
hooks: {timeout: 2s}

11. Error model (policy and pipeline)

Body (legacy-compatible superset; error and type are exactly what old clients parse):

{"error": "path is not permitted", "type": "PolicyDenied", "code": "policy.path_not_allowed",
 "op": "files.download", "request_id": "r-7f3c", "details": {"param": "path"}}

On the OpenAI wire (/v1/** routes) the same condition renders as {"error": {"message", "type", "param", "code"}}.

Family code HTTP Meaning
auth.required, auth.invalid, auth.conflicting_credentials 401 missing/invalid token (generic message); Bearer and X-Access-Token differ
auth.rate_limited 429 failed-auth limit (credential fingerprint)
request.bad_target, request.host_denied, request.origin_denied, request.bad_content_type, request.bad_json, request.busy 400 / 403 / 415 / 503 transport checks of pipeline step 0-1
config.* start-up only auth_secret_missing, auth_secret_weak, secret_inline, reserved_id, open_non_loopback, open_exec, unknown_key, unknown_selector, unknown_preset, lint_errors, scope_required, state_dir_unsafe
policy.scope_required, policy.scope_unbound, policy.hook_bad_return, policy.hardlink_denied, policy.not_regular_file, policy.url_denied, policy.url_invalid 403 v2.1 additions
op.not_enabled 404 (v2.1) unknown id, unknown route, unknown adapter and not mounted for this caller: one code, identical bytes (suggestions only in verbose dev mode, among mounted ops)
op.method_not_allowed 405 Allow header lists methods
args.invalid, args.missing, args.unknown, args.type 400 schema validation (details.param)
policy.denied_by_rule, policy.denied_by_hook, policy.hook_error 403 deny rule / veto / hook failure
policy.path_invalid, path_not_allowed, path_denied, symlink_denied, path_changed, read_only 403 path scope
policy.command_invalid, command_not_allowed, command_denied, command_not_found, executable_not_allowed, shell_not_allowed, env_var_denied, signal_not_allowed 403 command scope
policy.language_not_allowed, model_not_allowed, agent_option_denied 403 op-specific allowlists (URLs use policy.url_denied/url_invalid)
policy.not_permitted 403 a rule matched but its constraints rejected the call and no more specific code applies
limit.max_bytes, limit.request_too_large 413 size
limit.rate, limit.concurrency 429 with Retry-After
install.required, install.pending, install.failed, install.checksum_mismatch, install.read_only, install.disabled, install.network_unreachable 424 / 202 / 502 / 502 / 503 / 403 / 503 lazy install (06); install.pending is a 202 with a job id
idempotency.mismatch 409 same Idempotency-Key, different request hash (x-should-retry: false)
agents.* 4xx/5xx agents runtime (05)
server.internal, server.unavailable, server.shutting_down 500/503 server faults (no internals leaked)

403 vs 404 leakage rules

  1. Not mounted for you = 404 (never 403): callers cannot probe which ops exist beyond what /capabilities shows them.
  2. Paths: policy first, existence second; the same 403 for existing and non-existing out-of-scope paths. Messages never echo canonical or resolved paths (only the caller's own argument via details.param).
  3. policy.denied_by_rule may include the rule's reason string authored by the operator; the rule names and glob lists are shown only to admin principals.
  4. (v2.1, IS-A5) Unknown and unmounted ops are byte-identical and both are audited (the audit record notes mounted: false), so probing is visible to operators without being visible to callers.
  5. 401 vs 403/404: unauthenticated callers always see 401 first, even for unknown routes (pipeline step 3 precedes every 404); core.health is anonymous by default (v1 behaviour, returns only {status, version}) and can be made authenticated with auth.anonymous_health: false.

12. Security properties and threat model

Promised (each should have a bypass test):

Not promised (documented honestly): confinement of an allowed program (a permitted git/python3 script.py can do what the process user can do), the agents (they run CLIs with the server user's privileges; AgentScope passes native sandbox flags to the CLI, requires an explicit workdir, permissions level and command/model allowlists, and a lint warns that file/command scopes do not apply; permissions="full" needs unconfined_ok=True), kernel isolation, DoS by an authenticated principal beyond the configured limits, hard-link tricks, other local users. Network egress control is the platform's responsibility; the server only labels ops with the net effect.

13. Mapping from the legacy/in-flight concepts

Old New
CapabilityGroup.FILE_OPS etc. group:file_ops selector (golden list in 04)
enable_builtin(table, group), table.disable_group(...) convenience over Policy rules; still work
ABX_SERVER_DISABLED_GROUPS layer 4 (restrict)
ABX_SERVER_ENABLED_GROUPS layer 3 (author) - needed to enable browser, dev_tools, agents
scopes like files:read derived sugar: scope:files.read = allow("effect:read") over the files.* ops; not a primitive
POST /capabilities/check core.check (alias capabilities_check), same JSON
X-Access-Token only both headers on every route, Authorization: Bearer wins, conflict is 401
default (no policy) presets.legacy_compat() (loud; section 4)

14. Observability

15. v2.1 hardening rules (normative)

These rules come from the independent security review (41 findings) and are the contract an implementation codes against. Each carries its finding id.

15.1 Listener and transport (IS-A2, IS-A3, IS-A8, IF-19)

  1. Request target. The target is taken from the raw request line (BaseHTTPRequestHandler rewrites a leading //). //, /./, /../, %2f, %2e, %5c, a backslash, NUL or control characters -> 400 request.bad_target.
  2. Host and Origin. Host must be a loopback name or in allowed_hosts; Origin, when present, must be in allowed_origins -> 403 request.host_denied / request.origin_denied. This stops DNS rebinding and cross-origin browser calls to a loopback server (a browser can send a cross-origin POST with a token-less simple request; with the server's open mode that was a remote-code-execution primitive).
  3. Content-Type. A request with a body must be application/json -> 415 request.bad_content_type, so text/plain "simple requests" never reach a handler. The size check (Content-Length -> 413) precedes the body read.
  4. Timeouts and pool. Socket header and body reads time out after 3 s per read (default; header_timeout). A bounded pre-authentication pool (max_pre_auth, default 64) answers 503 request.busy beyond the bound; core.health and core.capabilities have a reserved slot so readiness probes are never starved (IF-19). Slow-loris sockets are closed without a traceback.
  5. Order (normative): target -> host/origin -> auth -> size -> content-type -> body.

15.2 State, secrets, process (IS-L1, IS-C6, IS-A1)

15.3 Matching and path machinery (IS-P2, IS-P3, IS-P9, IS-F1, IS-F2, IS-F4, IS-F5)

15.4 UrlScope (IS-U1, IS-U1b)

UrlScope(hosts=(), schemes=("https",), ports=(), paths=("/**",), block_private=True). The URL is parsed once with a strict parser and the parsed hostname is compared (never a glob over the raw string). Rejected as policy.url_invalid: userinfo (@), fragments, backslashes, control or non-ASCII characters, numeric/hex/octal host spellings, host characters outside [a-z0-9.-], lengths over 2048. scheme, port (default per scheme) and the path glob are checked on the parsed parts; a mismatch is policy.url_denied. The handler receives the canonical URL and must use it. block_private=True refuses every literal address that is not globally routable: RFC 1918, loopback, link-local, CGNAT 100.64.0.0/10 (includes Alibaba 100.100.100.200), ULA, IPv4-mapped IPv6. DNS names are resolved by the handler's connector, which re-checks the resolved address (the check is in the shared fetch helper, not in the handler). browser.navigate, devtools.* URL params and agents.* model endpoints use it.

15.5 Construction-time and start-time checks (IS-A1, IS-C2, B-2, B-18, IF-31)

SandboxServer(...) raises at construction: config.unknown_selector, config.unknown_preset, config.reserved_id, config.secret_inline, config.auth_secret_missing, config.auth_secret_weak, and (strict config) config.unknown_key. start_http/start() additionally raises config.open_non_loopback, config.open_exec, config.state_dir_unsafe, config.lint_errors. Lints (SandboxServer.check(), check-config):

Level Lint
error config.scope_required (allow of a path/command/url/agent op without its scope), anywhere/unrestricted scopes, shell=True with argv patterns, escape-capable/project-file/writer/network commands with ... or glob arguments, AgentScope(permissions="full") without unconfined_ok, follow symlinks
warn registered-but-unmounted custom op (not built-ins), experimental adapter mounted, principal selector that no configured token produces, core.effects_undeclared, shadowed statements, * selector, adapter permissions weaker than read-only

15.6 Agent sessions are exec-effect operations (IS-G1)

agents.<a>.run, agents.<a>.sessions.create, agents.<a>.sessions.send_event and the generic agents.sessions.create/send_event carry effects exec + net and agent_scope=True. Every allow statement that mounts one must carry an AgentScope(workdir, permissions, commands=(), models=(), max_turns=None, unconfined_ok=False): workdir is a PathScope-checked directory (the CLI's cwd and writable root), permissions is "read-only" (default and the only value the presets use), "workspace-write" or "full" (needs unconfined_ok), commands is the CLI's own command allowlist (passed as the native flag where the CLI has one), models the allowed upstream model names, max_turns a cap. permissions=None is refused at admission. The file and command scopes of other statements do not apply inside an agent process; the lint and the docs say so, and explain prints "agent child is not confined by path/command scopes". sessions.create is explicit-only and install is never implied (IS-L2): the implicit-install step of the pipeline runs only if the same principal could call agents.<a>.install (allow present, no deny) and install.mode permits, otherwise 424 install.required; implicit installs are globally limited (one at a time, install.max_per_hour) and audited with the triggering principal. LLM credentials (IS-S1): the child holds the key in its environment in 0.3.0; doctor and explain print "key is exposed to agent-executed code" per adapter. The loopback key-injecting proxy (llm.proxy) is deferred (post-0.3.0, tracked in the disposition table).

15.7 Installer trust (IS-L3, IS-L4, IS-L5)

ABX_TOOL_<NAME> and ABX_TOOLS_DIR are read once at start from the process environment and accepted only if the target and every parent are owned by root or the server uid and not group/other writable. install.use_path defaults to false in lazy mode. Trees not owned by root are re-verified against the recorded tree digest (size + sha256, cached per process) before ready. lock rejects a manifest whose resolved URL hosts are not in hosts or that lacks integrity (install.manifest_invalid); npm ci --ignore-scripts; playwright-browser archives are pinned by sha256 recorded at lock. Details: 06.

15.8 Read-only mode, tokens in URLs, ids, idempotency (IS-G2, IS-T1, IS-J1, IS-I1)