Skip to content

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 config on the command line)
  • sigil init writes a #:schema line 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.json
#:schema https://runsigil.com/schemas/sigil-config.schema.json
default_service = "api"
[deploy]
backend = "compose"
health_check = "/health"
[deploy.compose]
compose_template = "docker-compose.yml"
[judge]
provider = "ollama"
model = "qwen3:14b"

Top-level key: the service used when a command is not given --service. There is no [service] table.

[deploy]
backend = "compose" # compose | container | kubernetes | process | external
health_check = "/health" # path polled for HTTP 200 once the environment is up
startup_timeout = 300 # seconds: the backend's start command (compose up, docker run, kubectl apply)
health_timeout = 60 # seconds: polling health_check for readiness
teardown_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.

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 scenarios
service = "api" # …because `service` is set
KeyRequiredMeaning
pathyesDirectory, relative to the project root (the directory containing .sigil/).
servicenoWhen 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.

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.

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.

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 | upgrade
tls_server_name = "mysql"
tls_ca_file = ".sigil/certs/mysql-ca.pem"
connect_timeout = "5s"
io_timeout = "10s"
max_connections = 2
max_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.

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.

Plugin limits nest inside the scenario budget and apply to each fresh scenario/environment lane:

[plugins.runtime]
max_memory = "64MiB"
max_table_elements = 100000
max_call_seconds = 30
fuel = 100000000
max_open_resources = 32
max_component_instances_per_scenario = 16
max_random_bytes_per_call = 4096
max_random_bytes_per_scenario = 65536

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

See Configuring Judges.

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 = true
auto_merge = true

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 | auto
min_satisfaction = 0.95 # default: [eval] pass_threshold
min_confidence = 0.80 # default: [eval] confidence_level
never_auto = ["security", "payment"] # domains that always classify R3

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

Optional Rego guardrails evaluated by an external opa binary.

[policy.api.opa]
enabled = true
policy_path = ".sigil/policy"
entrypoint = "data.sigil.decide"
required = false
timeout_seconds = 30

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

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 origin
expected_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] services

Paths 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" # literal
ALICE_PASSWORD = { from = "ALICE_PASSWORD" } # passthrough from sigil's process env
SERVICE_TOKEN = { from = "CI_SERVICE_TOKEN" } # renamed passthrough

A 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):

Terminal window
sigil run scenarios/ --env TEST_API_KEY=abc123 # explicit value
sigil run scenarios/ --env ALICE_PASSWORD # pass through from sigil's own
# environment — keeps secrets
# off the command line
[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 termination
budget_seconds = 600 # wall-clock budget for the whole eval
budget_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-evaluates
scenario_concurrency = 1 # parallel scenarios; must be 1 when [scenario.reset] hooks are set
allowed_origins = ["http://127.0.0.1:9090"] # extra origins scenarios may reach
services = { taxonomy = "http://twin-b:8080" } # named services for sigil.service("taxonomy")
denied_capabilities = ["exec"] # capabilities no scenario may declare or use

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

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.

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 | json
max_hint_length = 200 # max characters per failure hint

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

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-out
block_on = ["critical", "high"] # default
warn_on = ["medium"] # default
scanner_timeout_secs = 120 # per-scanner subprocess timeout

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

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 path
max_staleness_for_allow = "60s" # max time the pre-ALLOW sync may take

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

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

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 window

Override 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.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/tests
r1 = [] # empty by default
r2 = ["**/api/**", "**/handlers/**", "**/models/**"]
r3 = ["**/migrations/**", "**/auth/**", "**/billing/**", "**/crypto/**"] # highest risk

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