Two controls, in opposite directions, and neither depends on the other.
- Inbound. The environment a command runs with is rebuilt from a small allow-list, so a variable Husk has never heard of is dropped rather than forwarded.
- Outbound. Every tool result passes through
redact()before it re-enters the conversation, so an agent thatcats a.envsees the file and the model seessk-ant-…[redacted].
The environment scrub
scrubEnv runs before every exec, on every provider. It does not filter your
environment — it builds a new one.
Kept, and nothing else:
PATH HOME LANG LC_ALL TZ TERM SHELL USER LOGNAME TMPDIR PWD
SYSTEMROOT COMSPEC WINDIR PATHEXT NUMBER_OF_PROCESSORS PROCESSOR_ARCHITECTUREThen HUSK=1 is added, and finally anything in computer.env or the exec's own env
is written over the top — an explicit value always wins, because you asked for it.
Everything else is dropped: ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY,
GITHUB_TOKEN, and also EDITOR, NODE_OPTIONS, npm_config_registry and your shell
prompt. A smaller environment is a smaller blast radius, and agents rarely miss it.
Passing a credential deliberately
computer:
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}husk up scratch --env GITHUB_TOKEN=ghp_... # for one machine
husk exec scratch --env FOO=bar -- printenv # for one commandExplicit beats implicit. The decision is now in the file, where a reviewer can see it and a diff can catch it.
Redaction
redact() from @husk-ai/core matches nine formats:
| Pattern | Provider |
|---|---|
sk-ant-… (20+ chars) | Anthropic |
sk-… (20+ alphanumerics) | OpenAI and lookalikes |
gsk_… (20+) | Groq |
AIza… (30+) | |
ghp_… (30+) | GitHub personal access token |
github_pat_… (30+) | GitHub fine-grained token |
xox[baprs]-… (10+) | Slack |
AKIA + 16 uppercase alphanumerics | AWS access key id |
-----BEGIN … PRIVATE KEY----- … -----END …----- | PEM private key, any type |
A match is masked, not deleted, so the model can still reason about which credential it saw:
sk-ant-api03-AbCdEf... -> sk-ant...[redacted]
-----BEGIN RSA PRIVATE KEY-----… -> [redacted private key]The mask keeps the first min(6, floor(length / 4)) characters.
Where it runs
| Path | Redacted |
|---|---|
| Tool result content, before it enters the conversation | yes |
Tool error messages and Denied: … results | yes, same code path |
| Router warnings and fallback messages | yes |
Provider availability reasons in husk doctor | yes |
tool_delta events — live stdout as it streams | no |
Persisted run events in ~/.husk/runs/<id>/events.ndjson | no |
| The prompt you typed | no |
| The model's own output | no |
Transcripts cached by husk import | no |
The clamp runs before the redaction, so redaction always operates over bounded input rather than over a 40 MB log.
Turning it off
guardrails:
redactSecrets: falseThe only reason to do this is when redaction is corrupting legitimate output — a
build log full of base64 blobs that happen to match sk-. It is on by default and
should stay on.
What redaction misses
It is a pattern list. Specifically:
- Any credential format not in the table. A Stripe key, a database URL with a password in it, a JWT, an SSH private key in OpenSSH's newer format that does not carry the PEM header, a bearer token your own service issues.
- A secret split across a chunk boundary. Redaction runs on the assembled tool result, so this is rare, but a value spanning the elision marker of a truncated log can survive in halves.
- Anything the model infers. Redaction is textual. It does not stop a model from noticing that a file exists, or from reading a config value that is not credential-shaped but is still confidential.
Redaction is a seatbelt for the common case. It is not a data-loss-prevention product, and it is not a reason to point an agent at a directory full of production secrets.
The distiller redacts more
husk distill runs a second, wider redactor over the DistilledAgent on its way into
husk.yaml — because that file is the artifact people commit to a repository.
| Category | Replaced with |
|---|---|
| The nine credential formats above | the same masks |
| Your home directory, in every spelling | ~ |
| Email addresses | [email] |
C:\Users\alice\dev becomes ~\dev; /home/alice/src becomes ~/src. It runs over
the name, description, persona, every knowledge title, content and source, both sides of
every example, and every distiller note.
What Husk never stores
- No key is written to
~/.husk. Keys are read from the process environment on every invocation. There is nohusk login, no credential file and no keychain integration. - No telemetry. Absent from the codebase, not disabled by a flag. The only outbound requests are to the model provider you configured and to a container registry when a flavor's image is not already pulled.
Everything Husk does keep lives under one directory so you can delete it in one move:
~/.husk/
config.json settings, model aliases
.env local overrides
husks/ registered husk.yaml files
computers/ machine metadata and session bindings
workspaces/ the local provider's filesystems
runs/ run summaries and events.ndjson ← unredacted tool output
transcripts/ imported chats ← unredacted
data/ cache/Directories are created with mode 0o700.