Spens logoSpens

The spens config file

Reference for .spens.config.json, the workspace-level configuration for spens

Spens reads an optional .spens.config.json from the workspace root. spens init generates one for you; you can also write it by hand. To use a different config file for a single session, pass --config PATH (e.g. spens node-24 pi . --config .spens.config.ocr.json) — see CLI reference.

A full example:

{
  "egress": "enforced",
  "nono_override": "profile_override.json",
  "additional_capture_urls": ["*opencode.ai*"],
  "exclude_capture_urls": ["*models.opencode.ai*"],
  "env": ["OPENAI_BASE_URL"],
  "pre_sandbox_commands": ["npm install"],
  "extra_mounts": [
    {"source": "~/.cache/npm", "dest": "~/.npm", "permissions": "rw"},
    {"source": "~/datasets", "dest": "/datasets", "permissions": "ro"}
  ],
  "domain_rules": [
    {"pattern": "*api.github.com*", "allow": ["GET"]},
    {"pattern": "*pypi.org*", "allow": ["*"]},
    {"pattern": "*api.anthropic.com*", "allow": ["GET", "POST"]}
  ],
  "inject_headers": [
    {
      "placeholder": "ANTHROPIC_API_KEY",
      "env_var": "ANTHROPIC_API_KEY",
      "for_domains": ["*api.anthropic.com*"]
    }
  ],
  "pricing": {"live_fetch": false},
  "otel": {
    "endpoint": "http://localhost:4317",
    "protocol": "grpc",
    "service_name": "spens",
    "include_messages": true
  }
}

An example lives in config_example/.spens.config.json in the spens repository.

Field reference

FieldDescription
egress"enforced" (default) or "legacy". Enforced: the agent runs on an isolated internal Docker network whose only neighbor is the interceptor, so all egress, including DNS, is forced through it and non-proxied protocols fail closed. Legacy: the agent shares the interceptor's network namespace and proxy env vars are advisory only. Not recommended; kept for migration
nono_overridePath to a custom nono profile JSON file, replacing the auto-generated combined profile. The generated profile is always preserved inside the image as spens-base.json, so your override can use "extends": ["spens-base"] to layer your own rules on top of Spens internals (proxy env vars, cache sanitization, placeholders, base-pack extensions) instead of recreating them
additional_capture_urlsGlob-style URL patterns to additionally capture in the interceptor. The old typo addition_capture_urls is still accepted for backward compatibility
exclude_capture_urlsGlob-style URL patterns to skip in the interceptor
envEnvironment variable names to forward from the host to the agent container with their real values. Use for non-secrets, or for secrets that must be usable inside the container (and so cannot be proxied)
mcpMCP server injection options. servers is a path (relative to the workspace) to a file of servers in the common mcpServers JSON shape; mode (default merge) is the default for --mcp-mode
pre_sandbox_commandsShell commands to run inside the container before the sandbox starts the agent (e.g. npm install). They run unsandboxed as the unprivileged agent user, and their network traffic goes through the interceptor
extra_mountsExtra host paths to bind-mount into the agent container. An array of objects, each with a source (host path; supports ~, and the {workspace} / {spens_dir} tokens, with relative paths resolved against the workspace), a dest (absolute container path or ~-relative to the agent home), and optional permissions of "ro" (default) or "rw". A source that does not exist on the host, and any malformed entry, is skipped with a warning. Honored even with --bypass-config-mounts. See Extra mounts
domain_rulesPer-domain method restrictions. Each rule has a glob pattern (matched against the request hostname only) and an allow list of HTTP methods, or ["*"] for all methods. When multiple rules match, the most specific pattern governs the request (see Default network policy). Fails closed: a hostname that matches no rule is blocked, and omitted or empty domain_rules means deny-all
inject_headersSecret substitution rules. See How inject_headers works
pricingCosting options. {"live_fetch": false} stops spens fetching the pricing dataset at the end of a session; prices then come from the vendored snapshot and the built-in table. See Pricing
otelOpenTelemetry export options (endpoint, protocol, service_name, include_messages, headers). Also configurable with the standard OTEL_* environment variables, which win over the config file. No endpoint configured anywhere means nothing is sent. See OpenTelemetry export
default_env, default_agentDefault environment and agent for the workspace, read by spens-acp when launching sessions from an editor

Extending the generated nono profile

When you use nono_override, the generated profile is replaced at runtime. The generated profile is still preserved inside the image as spens-base.json, so your override can extend it rather than recreating all the Spens internals. For example, a profile_override.json that only adds an extra allowed folder:

{
  "extends": ["spens-base"],
  "allow_folders": ["/tmp/custom-cache"]
}

This inherits the proxy environment variables, cache sanitization, placeholder injection, and any base-pack extensions that Spens generates, and layers your own rules on top.

Default network policy

Two mechanisms control the agent's network access, and both fail closed:

  • Non-HTTP protocols always fail closed. The agent's Docker network has no route to the internet except through the interceptor, and the interceptor refuses tunnels that do not speak HTTP. SSH, raw sockets, and other non-HTTP protocols cannot get out, regardless of configuration.
  • HTTP/HTTPS filtering depends on domain_rules. If domain_rules is omitted or empty, every domain is denied (fail closed). Traffic is still routed through the interceptor, so any request that is allowed by an explicit rule is captured and logged. Once any rule is configured, the policy stays fail closed: a hostname that matches no rule is blocked, and HTTPS tunnels to unmatched hosts are refused before they are established.

If you want allow-by-default HTTP egress, add a wildcard rule such as {"pattern": "*", "allow": ["*"]} to domain_rules. spens init generates a ruleset for your stack and provider; answering manual for both the stack and the provider produces an empty ruleset, which means deny-all.

When several rules match a hostname, the most specific one governs the request, regardless of the order the rules are declared in. For example, with a public-GET catch-all {"pattern": "*", "allow": ["GET"]} plus {"pattern": "*api.anthropic.com*", "allow": ["GET", "POST"]}, POST requests to api.anthropic.com are allowed while every other host is still limited to GET. Specificity is the number of domain labels in the pattern, so api.anthropic.com overrides anthropic.com, which overrides *. If two matching rules are equally specific (e.g. *.anthropic.com and anthropic.com), the first one declared wins.

Extra mounts

extra_mounts binds additional host paths into the agent container, on top of the workspace and the agent template's own configuration files. Each entry has three fields:

{
  "extra_mounts": [
    {"source": "~/.cache/npm", "dest": "~/.npm", "permissions": "rw"},
    {"source": "/srv/datasets", "dest": "/datasets", "permissions": "ro"}
  ]
}
  • source — host path. ~ expands to your home directory; the {workspace} and {spens_dir} tokens expand to the session's workspace and spens state directory, and a plain relative path resolves against the workspace.
  • dest — path inside the agent container. Use an absolute path, or ~/... to place it under the agent user's home.
  • permissions — "ro" (default) mounts read-only; "rw" lets the agent write to the host path.

The agent runs untrusted code, so prefer "ro" unless the agent genuinely needs to write back to the host. These mounts are explicit and user-controlled: unlike the agent template's configuration files, they are still mounted when --bypass-config-mounts is set. A source that does not exist is skipped with a warning rather than failing the session, and malformed entries (missing source/dest, a relative dest, or an unknown permissions value) are dropped with a warning.

How inject_headers works

inject_headers rules keep real secrets out of the agent container. Each rule has three parts:

  • placeholder — the literal string the agent is given as its "key"
  • env_var — the host environment variable that holds the real secret
  • for_domains — the hostnames the secret may be substituted on

A worked example. Suppose your host environment has ANTHROPIC_API_KEY=sk-ant-secret123 and your config contains the rule from the example above. At runtime:

  1. The agent container is started with ANTHROPIC_API_KEY=ANTHROPIC_API_KEY. The variable name is real, but its value is the literal placeholder string. The agent reads it and sends it in its API requests, but it holds no secret.
  2. The interceptor container is started with the real value of ANTHROPIC_API_KEY from your host.
  3. When the agent sends a request whose hostname matches for_domains (here, api.anthropic.com), the interceptor replaces every occurrence of the placeholder string with the real secret before forwarding the request upstream.
  4. On any other hostname, the placeholder is left untouched, so the real secret can never be attached to a request to a destination you did not authorize.
  5. Real secret values are masked out of the request log and trace files before anything is written to disk.

placeholder and env_var do not have to share a name. If your host stores the secret under ANTHROPIC_KEY, write "placeholder": "ANTHROPIC_API_KEY", "env_var": "ANTHROPIC_KEY" — the agent still sees ANTHROPIC_API_KEY, and the interceptor reads the real value from ANTHROPIC_KEY.

A rule without for_domains (or with an empty list) authorizes no hostnames, so its placeholder is never substituted.

On this page