Configuration
Every project gets a .sigil/sigil.toml (or wherever you point --config). This page covers the sections you are most likely to touch; the authoritative, complete reference is the published JSON Schema — it is generated from sigil’s own config types and drift-tested in CI, so it can never lag the binary:
- https://runsigil.com/schemas/sigil-config.schema.json (also
sigil schema configon the command line) sigil initwrites a#:schemaline at the top of the generated file; editors with TOML schema support (Taplo, VS Code’s Even Better TOML) then offer completion and flag unknown keys.
#:schema https://runsigil.com/schemas/sigil-config.schema.jsonMinimal example
Section titled “Minimal example”#:schema https://runsigil.com/schemas/sigil-config.schema.jsondefault_service = "api"
[deploy]backend = "compose"health_check = "/health"
[deploy.compose]compose_template = "docker-compose.yml"
[judge]provider = "ollama"model = "qwen3:14b"default_service
Section titled “default_service”Top-level key: the service used when a command is not given --service. There is no [service] table.
[deploy]
Section titled “[deploy]”[deploy]backend = "compose" # compose | container | kubernetes | process | externalhealth_check = "/health" # path polled for HTTP 200 once the environment is upstartup_timeout = 300 # seconds: the backend's start command (compose up, docker run, kubectl apply)health_timeout = 60 # seconds: polling health_check for readinessteardown_timeout = 600 # seconds: the backend's teardown command
[deploy.compose]compose_template = "docker-compose.yml" # required when backend = "compose"A deploy has three bounded phases with three separate bounds; none covers another, and a deploy can take their sum in the worst case. startup_timeout is honored by the compose, container, and kubernetes backends (the process backend spawns and returns, so it warns if this is set); health_timeout alone bounds readiness — raising startup_timeout does not give a slow-booting service more time to come up. teardown_timeout is generous on purpose: cutting teardown short leaks containers, so expiry is a last resort, and when it happens sigil logs a greppable SIGIL_TEARDOWN_LEAK warning naming the environment to reclaim. Exceeding any bound tears the environment down and fails the operation — fail-closed, never a silent success.
Per-backend sub-tables: [deploy.compose], [deploy.container], [deploy.kubernetes], [deploy.process]. backend = "external" uses pre-deployed services and requires --pr-endpoint / --baseline-endpoint on sigil eval. See the schema for every field.
[[scenarios]]
Section titled “[[scenarios]]”Where scenario source lives. Optional — with no entry, sigil looks in
scenarios/ at the project root. Note the double brackets: this is an array of
tables, so a monorepo can declare several roots.
[[scenarios]]path = "scenarios" # holds one subdirectory per service
[[scenarios]]path = "services/api/scenarios" # IS the api service's scenariosservice = "api" # …because `service` is set| Key | Required | Meaning |
|---|---|---|
path | yes | Directory, relative to the project root (the directory containing .sigil/). |
service | no | When set, this directory is that service’s scenarios, with no service-name level. When absent, it holds one subdirectory per service. |
Discovery reads every root. Writes — sigil init, generation staging,
promotion — go to the first entry.
[keys]
Section titled “[keys]”age recipients for encrypted holdout scenarios. Every .lua.age holdout is
encrypted to every recipient listed here.
[keys]ci = "age1..."reviewer = "age1..."Manage with sigil keys add <name> <age1...> or sigil keys add-self; either
creates the table if absent. After adding a recipient, run sigil keys rotate
as a current key-holder to re-seal existing holdouts to it.
Promoting a scenario to holdout fails closed when no recipients are configured: an unencrypted holdout would be indistinguishable from a visible scenario.
[plugins]
Section titled “[plugins]”WebAssembly plugins are reviewed project dependencies. A bare requirement name
always means the matching repository in the official sigil-plugins GitHub
organization; third-party sources require both an explicit source and opt-in.
[plugins]allow_third_party = false
[plugins.require]codec = "=1.1.2"mysql = "^0.2"
[plugins.github]token_env = "SIGIL_GITHUB_TOKEN"
[plugins.trust]install_allowlist = ["github:sigil-plugins/*"]holdout_allowlist = ["github:sigil-plugins/*"]
[plugins.trust.capability_allowlist]network = ["github:sigil-plugins/*"]secrets = ["github:sigil-plugins/*"]random = ["github:sigil-plugins/*"]entropy = ["github:sigil-plugins/*"]log = ["github:sigil-plugins/*"]Use sigil plugin install NAME[@VERSION] followed by sigil plugin add NAME[@VERSION] for an explicit acquisition and exact dependency workflow.
In Sigil 0.35.0, add can acquire a missing package through verified remote
installation, so it is not a cache-only command. If the
directory has no .sigil/sigil.toml, the first official add creates a minimal
schema-linked config. It updates [plugins.require],
.sigil/sigil.plugins.lock, and the managed
.sigil/types/wasm/ stubs as one transaction. plugin install changes only
the per-user cache and does not authorize a project.
Third-party requirements use a source-bearing table and must be admitted by
allow_third_party, install_allowlist, and every allowlist for a capability
their manifest requests:
[plugins]allow_third_party = true
[plugins.require]protobuf = { version = "=1.2.4", source = "github:acme/sigil-protobuf" }token_env names an optional environment variable used only by explicit
plugin-manager network commands. Its value is never stored, logged, forwarded
to redirected asset hosts, or exposed to a component.
Only the exact plugins selected by scenario capabilities must be loadable by
the running Sigil host. Plugin-free selections do not read the lock or store.
When a plugin is selected, the complete lock must still canonically cover
[plugins.require]; selected incompatibility fails closed. Reports, replay
inputs, and plugin_set_hash contain only the selected identities. Full-project
plugin lock, plugin sync, and default generate-types still require support
for every locked dependency.
[plugins.grants.<name>]
Section titled “[plugins.grants.<name>]”A package manifest can request host services, but the request itself grants nothing. Operator-owned grants name the exact secret names and network endpoints that one plugin may receive:
[plugins.grants.mysql]secrets = ["MYSQL_USER", "MYSQL_PASSWORD"]
[plugins.grants.mysql.network.database]target = "singlestore-pipelines.sql:3306"tls = "upgrade" # disabled | direct | upgradetls_server_name = "mysql"tls_ca_file = ".sigil/certs/mysql-ca.pem"connect_timeout = "5s"io_timeout = "10s"max_connections = 2max_bytes = "16MiB"Guest code sees only the endpoint name database, never its host, port, DNS,
or certificate path. Route service names are exact and may contain dots:
singlestore-pipelines.sql never falls back to singlestore-pipelines; the
matching [eval] services, named --endpoint, or --endpoints-from entry
supplies the published socket. Secret values come from the same
[scenario.env] allowlist as sigil.env, but only the names listed for this
plugin are visible. Names are resolved on demand when a selected operation asks
for them. Trusted human direct-run output names a missing --env entry; JSON,
eval feedback, and ledger evidence omit it. TLS never falls back to plaintext.
SigV4 grants live under [plugins.grants.<plugin>.sigv4.<grant>] and bind one
network endpoint, access-key and secret-key names, region, service, signed
authority, methods, canonical URI prefixes, header names, and per-query-field
rules. Fixed values, decimal ceilings, encoded prefixes, or bounded opaque
values must be admitted explicitly; the host validates the canonical request
against that allowlist before signing or I/O. Continuation-token contents are
not added to host diagnostics or evidence, though scenario Lua receives a
returned token and can explicitly log or attach it.
Semantic gRPC profiles
Section titled “Semantic gRPC profiles”Sigil 0.35.0 adds manifest schema 4 and Host API 1.3 without changing Host API
1.0–1.2 imports. A manifest requests grpc-unary, while the operator admits
its source separately:
[plugins.trust.capability_allowlist]grpc-unary = ["github:sigil-plugins/temporal"]This allowlist grants neither an endpoint nor an RPC. Profiles under
[plugins.grants.<plugin>.grpc.<profile>] bind a named network endpoint,
h2c or h2-tls transport, exact protocol authority, byte/deadline ceilings,
closed request metadata, RPC aliases, and the temporal-workflow-v0 request
policy. Raw network/secret imports cannot coexist with semantic gRPC authority.
A missing capability allowlist defaults to official sources; grpc-unary = []
denies all. See semantic gRPC for the three supported
methods, TLS/bearer restrictions, replay rules, and the separate Temporal
candidate acceptance gate.
[plugins.runtime]
Section titled “[plugins.runtime]”Plugin limits nest inside the scenario budget and apply to each fresh scenario/environment lane:
[plugins.runtime]max_memory = "64MiB"max_table_elements = 100000max_call_seconds = 30fuel = 100000000max_open_resources = 32max_component_instances_per_scenario = 16max_random_bytes_per_call = 4096max_random_bytes_per_scenario = 65536Fuel bounds guest compute; epoch interruption and host-side deadlines bound wall time and cancellation. See the published schema for every package, runtime, trust, grant, and endpoint ceiling, and Using WebAssembly Plugins for the operational workflow.
[judge]
Section titled “[judge]”See Configuring Judges.
[generate]
Section titled “[generate]”Optional override for scenario generation (sigil scenario generate):
[generate]provider = "anthropic"model = "claude-opus-4-7"context_specs = ["docs/specs/auth.md", "docs/specs/billing.md"][ci]status_context = "sigil/api"comment = trueauto_merge = true[policy.<service>]
Section titled “[policy.<service>]”Per-service decision policy, keyed by service name. The mode caps the decision independently of trust: only auto can ever ALLOW, so a clean evaluation in shadow or advisory returns REVIEW, and an unset mode renders as unset with the same REVIEW ceiling in both sigil decide and sigil trust.
[policy.api]mode = "shadow" # shadow | advisory | automin_satisfaction = 0.95 # default: [eval] pass_thresholdmin_confidence = 0.80 # default: [eval] confidence_levelnever_auto = ["security", "payment"] # domains that always classify R3The mode is set by the operator (sigil policy set --service api --mode auto); it is not promoted automatically. Trust is earned separately. Since 0.36.0, sigil decide records each trust step in the ledger and gates on it: a new service starts at NONE and earns LOW only while its mode is explicitly shadow (an unset mode does not earn trust), so start every service there. auto can ALLOW an R0/R1 change only once the service has earned MEDIUM. The earned-trust thresholds are fixed, not configurable; see Trust Model.
[policy.<service>.opa]
Section titled “[policy.<service>.opa]”Optional Rego guardrails evaluated by an external opa binary.
[policy.api.opa]enabled = truepolicy_path = ".sigil/policy"entrypoint = "data.sigil.decide"required = falsetimeout_seconds = 30An OPA denial BLOCKs with opa_policy_denied:<reason>, and --break-glass does not lower it. With OPA enabled and a policy_path set, any OPA error (timeout, rego error, crash, or no opa binary) makes the decision REVIEW with rationale opa_error, even when required = false. With required = true, sigil decide fails with the error instead, and sigil ci then fails closed. OPA is skipped only when it is disabled, or when no policy_path is set and required = false.
[scenario] — reset hooks and sigil.env()
Section titled “[scenario] — reset hooks and sigil.env()”The singular [scenario] table is unrelated to the plural [[scenarios]] array above: it holds per-scenario reset hooks and the environment allowlist.
[scenario.reset]
Section titled “[scenario.reset]”HTTP requests the runner sends before each scenario to drop in-memory state on the service under test (rate-limit buckets, caches, sessions) so scenario N does not leak into scenario N+1. One hook against the deploy’s primary URL, or several with the array-of-tables form, each optionally naming an [eval] services entry:
[scenario.reset]method = "POST"path = "/__sigil_test_reset" # relative; joined onto the target's originexpected_status = 204 # default: any 2xx
# or several, fired in declaration order — the first failure fails the scenario[[scenario.reset]]method = "POST"path = "/__sigil_test_reset"
[[scenario.reset]]method = "POST"path = "/__sigil_test_reset"service = "twin-b" # must be declared in [eval] servicesPaths stay relative per target, so a reset can never leave the pinned origin set; a service that [eval] services does not declare is a config error at load time. Hooks fire under sigil scenario run and under sigil eval — in eval each side (PR deploy, then baseline deploy) is reset before that side runs, and a failed reset fails that side’s scenario without running its body (fail-closed, never ALLOW). Because a reset wipes state shared by the whole scenario set, sigil eval refuses hooks when [eval] scenario_concurrency > 1 — set it to 1 or drop the hooks. sigil run reads no sigil.toml; it takes the same hooks from the command line — --reset [NAME=]METHOD:/path (repeatable) or --resets-from <json> (an array of entries with exactly these fields) — validated by the same rules. See CLI → sigil run.
[scenario.env] — environment variables in scenarios
Section titled “[scenario.env] — environment variables in scenarios”sigil.env("KEY") reads from a strict per-key allowlist — anything not named is invisible to the scenario and returns nil, including variables set in sigil’s own environment.
Under sigil eval the allowlist is [scenario.env] (singular — [scenarios.env] parses as a stray key on the first [[scenarios]] entry and does nothing):
[scenario.env]API_VERSION = "v2" # literalALICE_PASSWORD = { from = "ALICE_PASSWORD" } # passthrough from sigil's process envSERVICE_TOKEN = { from = "CI_SERVICE_TOKEN" } # renamed passthroughA passthrough whose source variable is unset is not inserted — sigil.env(KEY) returns nil and a warning names the key, because a silent "" turns a missing credential into a login failure that reads like a product bug. It is read from the control snapshot, like everything else the eval consumes, so a PR cannot widen its own allowlist.
Under sigil run the allowlist is the repeatable --env flag (docker-style):
sigil run scenarios/ --env TEST_API_KEY=abc123 # explicit valuesigil run scenarios/ --env ALICE_PASSWORD # pass through from sigil's own # environment — keeps secrets # off the command line[eval]
Section titled “[eval]”[eval]confidence_level = 0.95 # default min_confidence for [policy.<service>]pass_threshold = 0.90 # default min_satisfaction for [policy.<service>]progressive = true # staged execution: cheap tiers first, budget and early terminationbudget_seconds = 600 # wall-clock budget for the whole evalbudget_tokens = 50000 # judge + intent token budget for the whole eval (0 = unmetered)max_eval_age = "24h" # max age of a cached eval.complete before decide re-evaluatesscenario_concurrency = 1 # parallel scenarios; must be 1 when [scenario.reset] hooks are setallowed_origins = ["http://127.0.0.1:9090"] # extra origins scenarios may reachservices = { taxonomy = "http://twin-b:8080" } # named services for sigil.service("taxonomy")denied_capabilities = ["exec"] # capabilities no scenario may declare or useEvery scenario runs once per lane (PR and baseline). pass_threshold and confidence_level are the default min_satisfaction and min_confidence for any [policy.<service>] that does not set its own. progressive runs scenarios in cost-ordered stages; stage_fail_rate_threshold ends the eval early when a stage fails badly, and catastrophic_fail_rate skips the LLM stage when deterministic scenarios mostly fail. budget_seconds and budget_tokens cap the whole eval, not a single scenario. Judge and sigil.intent calls share the token budget; once it is spent, further calls make their step unevaluable, and eval.complete records real per-scenario and per-model token use. Since 0.36.0, an eval that left scenarios unrun (budget cut, early termination, or a --tag / --exclude-tag filter) cannot ALLOW: sigil decide REVIEWs it with partial_scenario_set. If a suite regularly exceeds budget_seconds, raise the budget. max_eval_age bounds how long sigil decide may reuse a prior eval.complete record instead of requiring a fresh sigil eval. It is separate from [ledger] max_staleness_for_allow, which bounds ledger sync time rather than the eval’s own age. See the schema for live_runners.
Endpoint pinning confines scenario HTTP to the deployed service’s origin by default — a holdout or contract scenario cannot exfiltrate over an arbitrary base_url. allowed_origins adds extra origins that sigil eval / sigil scenario run / sigil generate may reach (typically sidecars like a metrics endpoint on another port). Entries must be bare origins (scheme://host[:port], at most a trailing /); a malformed entry is a hard config error at load (fail-closed). The deployed service’s own origin is always allowed and need not be listed. (sigil run, which has no project config, pins to --endpoint and ignores this list; use its --allow-origin / --allow-cross-origin flags instead.)
services declares named services reachable from Lua as sigil.service("name") — a second twin, say — with each origin validated like allowed_origins and folded into the pin set. The same map is what [scenario.reset] hooks with service = "…" target. During direct sigil scenario run, a plugin target such as minio:9000 may use the exact service name minio only when the URL’s effective port is also 9000. For a different published port, bind the full target with [eval.plugin_routes]. The primary endpoint never grants plugin authority. See Direct runs and named network services.
denied_capabilities is an operator-side denylist on top of the per-scenario policy.capabilities declaration. Nothing is denied by default; an unknown capability name is a hard config error. Enforcement is fail-closed at three layers: a scenario that declares or calls a denied capability fails lint (E007) before it executes, the runtime installs a denying stub for it regardless of what the scenario declared, and sigil.intent never exposes it as a tool. A blocked scenario reports failure_class = "capability". The motivating case is exec: sigil.exec runs sh -c on the host running sigil, not inside the deployed container, so deny it wherever third-party or agent-authored scenarios run. The sigil run equivalent is the repeatable --deny-capability <NAME>.
[eval.plugin_routes]
Section titled “[eval.plugin_routes]”Bind a reviewed logical target to its externally published socket route for
configured sigil scenario run:
[eval.plugin_routes]"minio:9000" = "http://127.0.0.1:49172""singlestore-pipelines.sql:3306" = "mysql://127.0.0.1:33061"These entries are plugin-only: they create no sigil.service() handles, HTTP
origin pins, reset targets, or deployed-lane routes. Keys and URLs are validated
at config load. In sigil run, use --plugin-route SERVICE:LOGICAL_PORT=URL
or structured --endpoints-from entries with url and logical_port.
[feedback]
Section titled “[feedback]”Shapes the lossy feedback sigil eval reports back to the coding agent — the isolation wall enforces that this can never leak holdout scenario content.
[feedback]format = "json" # text | jsonmax_hint_length = 200 # max characters per failure hintThe 0.36.0 release removed the [holdout] section (percentage, rotation_interval) and the [feedback] keys include_status_codes, include_step_names, and include_timing. Nothing read them. Which scenarios are holdouts is set by the file extension (.lua.age); see Writing Scenarios.
[security]
Section titled “[security]”Optional SAST / secret / dependency scanning gates run alongside the eval. No scanner is configured by default, so scanning is off until you name a tool.
[security]sast_tool = "semgrep"secret_scanner = "trufflehog"dep_scanner = "trivy"sast_mode = "required" # disabled | advisory | required (default: required)secret_mode = "required"dep_mode = "advisory" # explicit opt-outblock_on = ["critical", "high"] # defaultwarn_on = ["medium"] # defaultscanner_timeout_secs = 120 # per-scanner subprocess timeoutsast_mode / secret_mode / dep_mode control whether a scanner’s own execution failure is fail-closed. Since 0.36.0 each defaults to required: a named scanner that is missing, crashes, times out, or produces output sigil cannot parse fails the gate. advisory keeps its findings visible without blocking on a tooling failure, and disabled skips it regardless of *_tool.
block_on lists the severities that BLOCK and warn_on those that force REVIEW. Each severity covers itself and everything above it. The defaults are ["critical", "high"] and ["medium"]; set block_on = [] explicitly to never block on findings. Valid severities are critical, high, medium, low, and info. Any other value, including the old "HIGH:auth" form, is a config error at load. sbom was removed in 0.36.0.
[ledger]
Section titled “[ledger]”The git-backed append-only record of every eval, decision, trust, override, and incident event, and the freshness gate sigil decide enforces before any ALLOW. See Ledger.
[ledger]repo = ".sigil" # ledger repository pathmax_staleness_for_allow = "60s" # max time the pre-ALLOW sync may takemax_staleness_for_allow is the freshness gate. Before it returns ALLOW, sigil decide syncs the ledger; since 0.36.0 this value bounds the time since that sync started (ledger_stale_for_allow when exceeded). Decide then re-checks the pulled events, and REVIEWs if the chain is broken, trust dropped, or the sync brought a newer eval of the same artifact (eval_superseded_by_sync). It never downgrades to ALLOW. The key lives here, under [ledger], not under a [policy] table. sigil ledger sync validates it with the same parser.
The 0.36.0 release removed backend, remote, auto_sync, and sync_retry. Nothing read them.
[attestation]
Section titled “[attestation]”Optional signed in-toto attestations over decisions. Off unless a signing key is set.
[attestation]signing_key = "/path/to/key.pem"trusted_public_keys = ["<hex-encoded-ed25519-pubkey>"]Since 0.36.0, when signing_key is set, sigil decide signs an in-toto attestation for each decision after it records the decision, and reports the file path as decision.attestation. sigil verify <eval-id> then checks it against trusted_public_keys. Without a key the decision is unsigned and otherwise unchanged; a key that fails to load only warns. The attestation subject records each digest under its real algorithm, and eval.complete records the evaluator_digest (BLAKE3 of the sigil binary) and judge_config_hash. format was removed in 0.36.0.
[observability]
Section titled “[observability]”[observability]metrics_textfile = "/var/lib/node_exporter/sigil_{service}_{command}.prom"New in 0.36.0. sigil eval and sigil decide write Prometheus text-format metrics to this file at the end of each run, for the node_exporter textfile collector. {service} and {command} in the path are substituted. The file is replaced atomically and holds only that run’s values, and a failed write never changes the command’s result. Unset means no file.
The 0.36.0 release removed log_level, log_format, trace_enabled, and metrics_endpoint. Nothing read them.
[browser]
Section titled “[browser]”Controls the in-process Chrome-for-Testing runner backing sigil.browser.* scenario calls.
[browser]backend = "native" # native (the "cli" value is accepted for old configs but every call errors)headless = true # false launches a visible windowOverride headless ad hoc without editing config: SIGIL_BROWSER_HEADLESS=0 (or false / no) forces headful for one run; any other value, including unset, keeps the configured setting.
[risk]
Section titled “[risk]”[risk.path_rules] classifies a PR’s changed paths into risk tiers r0–r3 (glob patterns, matched in that order) for policy and reporting.
[risk.path_rules]r0 = ["**/*.md", "**/test/**", "**/*.test.*", ".github/**"] # docs/testsr1 = [] # empty by defaultr2 = ["**/api/**", "**/handlers/**", "**/models/**"]r3 = ["**/migrations/**", "**/auth/**", "**/billing/**", "**/crypto/**"] # highest riskA PR’s risk class is the highest tier any changed path matches (a path matching no rule at all classifies r0); migration files and [policy.<service>] never_auto domains always force r3, and total changed lines over 500 bump the class up one tier. Since 0.36.0, any .sql file with a path component that starts with migration, at any depth (including migrations/0001_init.sql at the repository root), is R3.
The changed paths are the PR’s own changes, <base>...<pr-ref>. sigil decide uses the control ref as the base. sigil risk classify takes --base (default origin/main), and accepts an explicit A..B range as given. Before 0.36.0 the diff ran from HEAD, so in CI (where HEAD is the PR merge commit) most PRs classified R0.