Keep agents out of .env
Deny your agent .env reads, mask every penv run, seal keys with @hosts, and approve each value an agent asks to see.
Your coding agent reads what you read. penv adds four controls: harness rules that deny .env,
masking in every penv run, sealed runs for keys with @hosts, and your approval before any value
prints. Agent detection: coding agents.
penv guard
penv guard --checkpenv guard writes what every installed harness enforces, from your schema.
--check reports coverage only; penv check runs it too.
Harness rules
| Harness | File | Contents |
|---|---|---|
| Claude Code | .claude/settings.json | Read(./.env) and Read(./.env.*) denied, a sandbox denyRead block, a PreToolUse hook |
| Codex | .codex/config.toml | deny_read over **/.env and **/.env.*; shell_environment_policy with inherit = "core" |
| Cursor | .cursor/cli.json, .cursor/hooks.json | Deny rules; read and shell hooks that fail closed |
| Amp | .amp/settings.json | An empty guarded-files allowlist |
| Copilot CLI | .github/copilot/permissions-config.json | Read(**/.env) and Read(**/.env.*) denied |
| Gemini | .gemini/settings.json | A BeforeTool hook |
| Cline | .clinerules/hooks/PreToolUse | An executable hook running the penv binary |
| Windsurf | .windsurf/hooks.json | Hooks on running a command and on reading code |
Since beta.2 the rules also deny penv's local key file, ~/.config/penv/local.key: with it, an
encrypted .env reads as plain text. The hook refuses a penv/local.key path anywhere,
%APPDATA% included.
Merging is additive, idempotent, and never weakens an existing rule. guard --check reports each
file as current, stale, missing or overridden by the existing file; anything but current exits 3.
For Claude Code, guard also prints a block listing every sensitive key with "mode": "mask".
Paste it into your ~/.claude/settings.json; it lives outside the repository.
Copilot CLI publishes no schema for its permissions file, so that guard is experimental and outside the 1.0 stability promise.
Native Windows runs Claude Code without a sandbox: the deny rules and hook are the only controls,
and penv guard --check says so.
Hook
The installed hook is penv hook <harness>, the binary itself. A script would
fail open when its interpreter is missing. Claude Code, Cursor, Gemini, Cline and Windsurf run it;
Codex, Amp and Copilot CLI get rules only.
| Payload | Decision |
|---|---|
| Empty stdin | Allow |
| A shell line that runs another command | Decided again on the inner text |
A path matching .env, .env.* or penv/local.key | Refuse; .env.schema is allowed by name |
| A glob with two or more literal characters that could reach one of those files | Treated as naming it |
printenv, a bare env or set, node -e process.env, python -c os.environ | Refuse |
penv reveal or penv pull | Refuse |
A .env refusal names penv ls for key names and penv check for what is missing.
Agent session behavior
| Command or output | You at a terminal | Agent session |
|---|---|---|
| Output | Aligned text | One JSON object on stdout |
penv run output | Masked; --no-mask turns it off | Masked; --no-mask and --no-preload ignored |
Keys with @hosts | Values, unless you pass --sealed | Placeholders (sealed) |
penv reveal | Prints the value | Requests your approval, exits 4; a harness running the hook refuses it first |
penv pull | Writes .env | Exits 2; --i-am-human counts only when stdin and stdout are terminals |
penv login | Signs in | Refused; the fix names PENV_TOKEN |
penv push --prune | Deletes | Refused |
Point your agent at penv run instead of pull: values reach one child process, never disk.
Every cloud request from an agent session carries the
harness name and session id, so our audit log names both.
Masking
Since beta.2 penv run masks on every run, your terminal and CI included. It scrubs the child's
stdout and stderr as they stream, across chunk boundaries, and preloads masking into Node, Bun,
Deno and Python processes.
A match becomes the value's first two characters and a run of blocks.
| Masked | Detail |
|---|---|
| Values | Every resolved value except keys the schema marks public; drift keys (no schema block) included |
| Encodings | Raw; base64 in both alphabets, padded and unpadded, at every prefix offset; upper and lower hex; percent-encoded; JSON-escaped |
| Not masked | Values shorter than four characters; penv run and penv check name the key |
--no-mask and --no-preload work only when stdin and stdout are both terminals and no agent is
detected; otherwise penv ignores them and says so. Masking stops accidental leaks, not code written
to disguise a value. The GIT_EDITOR trigger:
environment variables.
Sealed runs
# @type=string(startsWith=sk_live_, minLength=32) @hosts=api.stripe.com
STRIPE_SECRET_KEY=A key with @hosts reaches the command as a placeholder shaped by its type. A proxy inside
penv run puts the real value into the request line, headers and body sent to the named hosts
only; a value echoed back returns as the placeholder.
| Case | Result |
|---|---|
| Agent session | Always sealed |
| You at a terminal | penv run --sealed |
postgres://, redis:// URL with @hosts naming its host | Local proxy; penv logs in with the real password |
@hosts on a signing secret (HMAC, WEBHOOK_SECRET, JWT_SECRET) | Refused; penv check fails it |
| Type with no room for a placeholder | Refused before the run, cannot_seal, exit 3 |
| Client that ignores the proxy variables | Sends the placeholder and fails |
@hosts is a penv addition: schema format.
Approve a reveal
Under Claude Code, Cursor, Gemini, Cline and Windsurf, the hook lets penv reveal through to your
approval from 1.0.0-beta.4. In 1.0.0-beta.3 it refuses penv reveal outright, so the agent never
reaches this flow.
Agent requests
penv reveal STRIPE_SECRET_KEYpenv records the key, machine, harness and session id, then exits 4 without the value:
{
"error": "approval_required",
"message": "STRIPE_SECRET_KEY needs a person to approve the reveal.",
"approval": "apr_example",
"url": "https://penv.cloud/approvals/apr_example",
"expiresAt": "2026-09-10T12:40:00Z",
"fix": "A person approves at https://penv.cloud/approvals/apr_example, then run penv reveal STRIPE_SECRET_KEY --approval apr_example."
}You decide
Open the URL, check the key, device, harness and session, then approve or deny. Either answer needs
secret:reveal at that environment and step-up. We expire a
request after ten minutes. See
approvals.
Agent redeems
penv reveal STRIPE_SECRET_KEY --approval apr_examplePrints the value once. penv checks the approval's key before spending it.
| Situation | Result |
|---|---|
| Same key asked twice in one session | We reuse the open request |
| Unanswered | Exit 4, approval_required |
| Denied | Exit 2, approval_denied |
| Expired or already spent | Exit 4, approval_expired or approval_redeemed; ask again |
| Id names another key | Exit 4, approval_mismatch; the id stays unspent |
A completed reveal writes four entries to our audit log:
secret.reveal_requested, secret.reveal_approved (or secret.reveal_denied), secret.read,
and secret.reveal naming the approver beside the session. When we refuse a redemption we add
secret.reveal_refused.
Limits
In cloud mode penv guard --check states the limit: penv cannot stop a process running as you from
looking. A guard is only as strong as the harness enforcing it.
Setup checklist
penv guard, then commit the written files.- Claude Code: paste the printed block into
~/.claude/settings.json. penv guard --checkuntil every file iscurrent.penv push, so no.envremains.- Add
@hoststo keys your agent's commands call an API with. - Tell your agent to use
penv run -- <command>.
Typed access for your language
Generate a typed env file from your .env.schema in TypeScript, Python, Go, Rust, Java, PHP or C#, keep its settings in .penv/config.toml, and check it in CI.
GitHub Actions
Exchange your GitHub Actions OIDC token for a short-lived credential from us and run one step with the values.