Docs
Guidesince 1.0.0-alpha.3

Keep agents out of .env

penv writes the rules your coding agent's harness enforces, and sends every reveal to a person before any value prints.

An agent runs as you, so it can read whatever you can read. penv narrows that in three places:

  • the harness config it writes, which denies the file outright.
  • the child output it scrubs on the way past.
  • the approval a person has to click before any value prints.
penv guard
penv guard --check

guard writes what every harness installed here enforces, from your schema. --check reports coverage and writes nothing, and it is what penv check runs for you on every check.

What each harness gets

Eight harnesses ship inside the binary. Each one is a folder holding a guard.toml and templates, so what a harness enforces is data rather than code, and a ninth is a folder somebody adds.

HarnessFile penv writesWhat goes in it
Claude Code.claude/settings.jsonRead(./.env) and Read(./.env.*) denied, a sandbox denyRead block, and a PreToolUse hook
Codex.codex/config.tomldeny_read over **/.env and **/.env.*, plus a shell policy that does not hand the child your environment
Cursor.cursor/cli.json, .cursor/hooks.jsonDeny rules, and read and shell hooks that fail closed
Amp.amp/settings.jsonAn empty guarded-files allowlist, so no file is exempt
Copilot CLI.github/copilot/permissions-config.jsonRead(**/.env) and Read(**/.env.*) denied
Gemini.gemini/settings.jsonA PreToolUse hook
Cline.clinerules/hooks/PreToolUseAn executable hook whose only line runs the penv binary
Windsurf.windsurf/hooks.jsonHooks on running a command and on reading code

Claude Code also has one block penv will not write for you, because it belongs in ~/.claude/settings.json outside your repository. guard prints it and says to paste it yourself. It names every sensitive key from your schema with "mode": "mask".

Merging is additive and idempotent. penv merges its fragment into a file you already have and never weakens a rule that is there. guard --check reports each file's status: current, stale or missing. Any status other than current sets exit code 3, so a pipeline notices.

Copilot CLI has published no schema for its permissions file. penv writes its best reading of the shape and says so in the guard's own description. Treat that one as best effort.

The hook is the binary itself

The hook penv installs is penv hook <harness>. It is never a script that needs an interpreter, because a missing interpreter fails open and a guard that fails open is worse than none.

The harness sends its payload on stdin. penv pulls out the command and the path, decides, and answers in the shape that harness's folder declares. A payload that has something to match on and cannot be read is a refusal. Only empty stdin is an allow. A shell line that runs another command is decided again on the inner text.

Three refusals, in penv's own words:

  • penv blocks reads of .env files. Run penv ls for the key names and penv check for what is missing; .env.schema is readable.
  • penv blocks dumping the environment, because it prints every value. Run penv ls for the key names.
  • penv reveal and penv pull print values, so they are refused in an agent session.

The patterns are .env and .env.*, so a new environment file is covered the day someone makes one. .env.schema is allowed by name, because your agent needs to read it. A glob with two or more literal characters that could reach one of those files counts as naming it.

Native Windows runs Claude Code without a sandbox, so there the deny rules and the hook are all there is. penv guard --check prints that line rather than implying coverage it does not have.

What changes when an agent is driving

BehaviorA personAn agent session
OutputAligned text at a terminalJSON on stdout, one object
Sensitive values in outputShownMasked
penv revealPrints the valueAsks a person, exits 4
penv pullWrites .envExits 2 unless a person passes --i-am-human

penv login is refused outright in an agent session, with the fix naming PENV_TOKEN for a machine credential. penv push --prune is refused too, because it deletes.

The alternative to a refused pull is penv run, which puts the values in one child process and leaves nothing on disk. Reach for that first.

Every cloud request an agent session makes carries the harness name and the session id, so the record of who did what can answer what one session touched.

Masking, and what it covers

penv run pipes the child's output and scrubs it as it streams, across chunk boundaries. Each secret is matched in six shapes: raw, base64 in both alphabets at each offset a prefix can push it to, upper hex, lower hex, percent-encoded, and JSON-escaped. A match is replaced with the value's first two characters and a run of blocks.

A value shorter than four characters is left alone, because masking it would swallow ordinary text.

A reveal, end to end

The agent runs penv reveal STRIPE_SECRET_KEY. penv creates a request carrying the key, this machine, the harness and the session id.

penv exits 4 and hands back the id, the console page and the expiry. The value is not in that answer.

{
  "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 open that page, see the key, the device, the harness and the session, and approve or deny. Approving needs the reveal permission and confirming it is you. The request expires ten minutes after it was made.

penv reveal STRIPE_SECRET_KEY --approval apr_example prints the value once. penv reads which key the approval names before it spends it, so an id can never release a value nobody released.

Asking twice for the same key in one session reuses the open request rather than filling your console with duplicates. A request nobody has answered is exit 4 again. A denial is exit 2. An id that expired or was already spent tells you to ask for a new one.

A completed reveal leaves four rows in the record: the request, the answer in the console, the read itself, and the reveal that names the approver beside the session that asked. A redemption that is refused writes a fifth.

What penv cannot stop

penv guard --check prints the claim, and it is only what is true. In cloud mode:

penv keeps secrets out of the files, the repo and the shell history your coding agent reads, and out of the output it captures. It cannot stop a process running as you from looking, so every value is short-lived, scoped and attributable to the session that used it.

Detection changes defaults and adds friction. It is never the last line of defense, and a harness that does not enforce a rule penv wrote is a harness that does not enforce it.

Do it in order

  1. penv guard in the repository, and commit what it wrote.
  2. Paste the user-scope block it printed into ~/.claude/settings.json if you use Claude Code.
  3. penv guard --check and fix anything reported stale or missing.
  4. penv push so there is no .env left to read.
  5. Tell your agent to use penv run -- <command> rather than reading a file.
  6. When the agent asks for a value, open the page it printed, check the key and the session, then approve or deny.

GitHub Actions