A complete walkthrough — install, see a decision, go to production. The compressed 60-second path lives in the README. This page covers everything else: troubleshooting, auth passthrough, the observation-mode workflow, Docker volume mounts, and building from source.
curl -fsSL https://raw.githubusercontent.com/enforcegrid/steer/main/install.sh | shThe script detects your OS and architecture, downloads the matching tarball from GitHub Releases, verifies its SHA256 against the published SHA256SUMS file, and installs the binary to /usr/local/bin (if writable) or $HOME/.local/bin. It also drops a default policy bundle and a starter steer.yaml under ~/.config/steer/. (At runtime, the binary additionally honors $XDG_CONFIG_HOME if set; the installer script itself writes to $HOME/.config/steer/.)
Inspect before running:
curl -fsSL https://raw.githubusercontent.com/enforcegrid/steer/main/install.sh -o install.sh
less install.sh
sh install.shPin a version:
STEER_VERSION=v0.1.0 curl -fsSL https://raw.githubusercontent.com/enforcegrid/steer/main/install.sh | shCustom install directory:
STEER_INSTALL_DIR=$HOME/bin curl -fsSL https://raw.githubusercontent.com/enforcegrid/steer/main/install.sh | shDirect download fallback (no curl ... | sh): pull the tarball + SHA256SUMS from the latest release, verify, extract.
# Foreground — decisions print to this terminal:
docker run --rm -p 8080:8080 ghcr.io/enforcegrid/steer
# Background + tail logs:
docker run -d --name steer -p 8080:8080 ghcr.io/enforcegrid/steer
docker logs -f steerTo mount your own policies and config:
docker run --rm -p 8080:8080 \
-v $(pwd)/steer.yaml:/app/steer.yaml:ro \
-v $(pwd)/policies:/app/policies:ro \
ghcr.io/enforcegrid/steerRequires Rust 1.86+:
git clone https://github.com/enforcegrid/steer.git
cd steer
cargo build --release
./target/release/steer --config steer.example.yaml --port 8080After install, steer resolves config in this order:
--config <path>if provided$XDG_CONFIG_HOME/steer/steer.yaml(default:~/.config/steer/steer.yaml)./steer.yaml(current working directory)
Start it with no flags — the install script bootstrapped a working config:
steerOr override the port:
steer --port 9090You should see something like:
INFO steer: loading config config=/Users/you/.config/steer/steer.yaml
INFO steer: steer starting version="0.1.0" addr=0.0.0.0:8080 fail_open=false
INFO steer: config wiring resolved policy_mode=enforce policy_dir=/Users/you/.config/steer/policies audit_backend=stdout audit_format=json
INFO steer: listening on 0.0.0.0:8080
If you see warnings like Cedar policy references PII pattern 'X' that is not compiled — that's the Cedar↔YAML consistency check. It means a policy enumerates a pattern name that isn't enabled in pii.patterns. Fix the yaml or remove it from the policy.
Point any coding agent or SDK at http://localhost:8080/v1 and send:
Steer terminal:
[BLOCK] POST /v1/chat/completions model=gpt-4o-mini block=default-exfiltration-request-block matched=markdown_img_data_url latency=0.7ms
The client receives an HTTP 400 — the request never reached the upstream LLM.
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"my key is sk-proj-abc123def456ghi789jklmnopqrstuvwx"}]}'[BLOCK] POST /v1/chat/completions model=gpt-4o-mini block=default-secrets-block matched=openai_key latency=1.1ms
[FLAG] POST /v1/chat/completions model=gpt-4o-mini flag=default-pii-flag matched=email latency=0.9ms
The request still reaches the upstream — flagging logs without blocking.
[ALLOW] POST /v1/chat/completions model=gpt-4o-mini latency=0.4ms
Steer's default behavior is passthrough: your Authorization or x-api-key header reaches the upstream LLM unchanged. Steer reads, stores, and substitutes your API key only when you opt in by setting upstream.api_key (or providers.<name>.api_key) in steer.yaml.
For Anthropic upstreams (base_url containing anthropic.com), the rule is stricter: if upstream.api_key is set, Steer ALWAYS overrides the inbound x-api-key header with the configured value, even if the client sent a perfectly valid Anthropic key. This is deliberate — it prevents eg_sk_live_… style Steer credentials from being forwarded to Anthropic by mistake in multi-tenant deployments.
The implication for OSS single-user setups: if you're configuring Steer with upstream.base_url: https://api.anthropic.com, your upstream.api_key must be a real, valid Anthropic key. If you want the client to supply its own key (passthrough), leave upstream.api_key empty:
upstream:
base_url: https://api.anthropic.com
api_key: "" # empty = passthrough; client's x-api-key reaches AnthropicIf the substituted key is wrong, expired, or contains a copy-paste artifact (newline, surrounding ${...} that didn't resolve), every Steer-proxied call returns 401 even though the same key works direct. Steer warns at startup about common misconfigurations:
WARN config sanity check field=upstream.api_key
message=value looks like an unresolved env-var placeholder ("${ANTHROPIC_API_KEY}")
Every audit entry carries an auth_source field telling you which path was taken:
"config"— Steer'supstream.api_keywas forwarded"client_passthrough"— the inbound header was forwarded as-is- field absent — auth resolution failed and
proxy.fail_openengaged
Filter for unexpected substitutions:
jq 'select(.auth_source == "config" and .response.status_code == 401)' audit.jsonlcurl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'Steer's pipeline scans the request body for policy violations, but the auth header itself is opaque to the policy engine. See docs/providers.md for the per-provider details.
Steer ships enforce-by-default so you see blocks fire on a fresh install. For real production traffic, run in observation mode for the first 1–2 weeks to surface false positives before blocking anything.
Edit ~/.config/steer/steer.yaml:
policy:
mode: observe # rewrites every @enforcement("block"|"steer") -> @enforcement("flag")Restart Steer. Every decision is still logged. Would-have-blocked events carry enforcement.observed: true. Filter them:
# All would-have-blocked events from the last hour:
jq 'select(.enforcement.observed == true and .timestamp >= (now - 3600 | todate))' audit.jsonl
# Group by rule_id to see which policies are loudest:
jq -r 'select(.enforcement.observed == true) | .enforcement.rule_id' audit.jsonl | sort | uniq -c | sort -rn
# Just the false-positive candidates with the matched payload:
jq 'select(.enforcement.observed == true) | {rule_id: .enforcement.rule_id, model: .request.model, patterns: [.labels[]?.metadata.pattern]}' audit.jsonlOnce the noisy stream is quiet for your traffic, flip back to mode: enforce and restart. Same binary, same policies, two postures.
For audit log persistence beyond stdout:
audit:
backend: file
log_path: /var/log/steer/audit.jsonl
format: jsonThe file backend is fail-loud: if /var/log/steer/ doesn't exist or isn't writable, Steer refuses to start. Silent fallback to stdout would compromise the audit trail.
| Symptom | Cause | Fix |
|---|---|---|
steer: command not found |
Install dir not on $PATH |
export PATH="$HOME/.local/bin:$PATH" and source ~/.zshrc (or ~/.bashrc) |
| Empty audit log file | Wrong path or permissions; tail -f started before traffic arrived |
Verify audit.log_path exists and is owned by the steer process; send one test request |
audit log file ... Refusing to start |
audit.backend: file with unwritable path |
Create the parent dir, fix ownership, or switch to backend: stdout |
| Port already in use | Another process on 8080 | steer --port 9090 or lsof -i :8080 to find the squatter |
| Policy not firing on a request | Pattern not in pii.patterns or detector didn't match |
Check startup logs for consistency warnings; run steer in foreground with audit.format: compact and send a test request — the compact line lists every detector that fired (matched=...) |
error: max retries exceeded from install.sh |
GitHub API rate limit (anonymous, 60/hr) | STEER_VERSION=v0.1.0 curl ... | sh skips the API call |
permission denied writing audit.jsonl in Docker |
Container UID 1000 vs host UID | Mount with :Z on SELinux, or --user $(id -u):$(id -g) |
| Decisions print but upstream returns 500 | Upstream provider down or rate-limited | Check audit.jsonl entries — response.status_code shows the upstream response |
Cedar evaluation failed 503s |
Policy syntax error after a hot-reload | Tail Steer's stderr; the offending file path is logged. Fix or remove the file. |
See docs/providers.md for per-tool walkthroughs:
- Cursor, Cline, Continue.dev — IDE plugin base URL override
- Claude Code (
ANTHROPIC_BASE_URL) — subscription-default alias gotcha - Aider —
--openai-api-baseflag - OpenAI / Anthropic SDKs —
base_urlparameter
- Authoring custom Cedar policies — your first policy in 10 minutes
- Architecture and audit-record schema — what the runtime does, what the audit emits
- Performance and capacity planning — benchmarks and methodology
- Compliance framework coverage — what evidence Steer produces, what it doesn't
- Enterprise features — SSO, cryptographically chained audit, support