Keeping coding agents away from .env files, SSH keys and cloud credentials
The deny rules, ignore files and sandboxes each coding agent offers for secrets, where they stop, and the arrangement that removes the credential instead of hiding it.
An agent reads a secret the same way your shell does: it opens the path or inherits the environment variable. The reliable way to stop it is to take the path out of what the agent can read and the standing credential out of what it can use. A rule that asks the model not to look is a convention.
Where the secrets actually are
| Path | What it is | How an agent reaches it |
|---|---|---|
.env, .env.local, .env.* | Application secrets, often production | cat, a shell command, a file-read tool, or a grep |
~/.ssh/id_ed25519, ~/.ssh/config | Keys and host aliases | cat, scp, git over SSH |
~/.aws/credentials, ~/.aws/config | Long-lived cloud keys and roles | cat, the AWS CLI, an SDK call |
~/.config/gh/hosts.yml | GitHub OAuth token for the CLI | gh commands, or a direct read |
~/.npmrc | Registry token | npm publish, or a direct read |
~/.kube/config | Cluster credentials and contexts | kubectl, or a direct read |
| MCP config files | Inline env blocks with API keys | The MCP server itself, launched with those values |
The last row is the one teams forget. mcp.json, ~/.cursor/mcp.json and the Claude Desktop config on macOS all accept an env block, and people paste keys into it because it works. That key then belongs to a process the agent starts on every launch.
Four controls, strongest first
- Remove the standing credential. If there is no long-lived key on the machine, reading a file gets the agent nothing.
- Deny the read at the operating-system level, where a subprocess cannot argue with it.
- Deny the read in the agent client, which covers the client's own file tools.
- Exclude the path from context, which keeps it out of prompts and index results.
Most teams deploy 4 and stop. It is the weakest: it controls what gets suggested, not what cat can print.
Claude Code
Permission rules are the first line. They live in ~/.claude/settings.json, .claude/settings.json or managed settings, and deny outranks ask and allow, as the permissions reference describes:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(**/.env)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)",
"Read(~/.config/gh/**)",
"Read(~/.kube/**)"
]
}
}
Paths use gitignore syntax, so Read(.env) and Read(**/.env) are equivalent, and Read(//**/.env) anchors at the filesystem root. A Read deny also blocks Edit and Write on the same path and covers @ mentions and the file list an IDE shares.
Two surprises. .claudeignore has no effect in Claude Code, so its entries belong in Read deny rules. And the rules stop at the client's own file handling. Anthropic's documentation is direct about the gap: deny rules "don't apply to a command that reads files without naming them, such as grep -r pattern ." and they do not cover "arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself."
That gap is what the sandbox closes. It applies at the OS level to Bash, PowerShell and the processes they start:
{
"sandbox": {
"enabled": true,
"filesystem": {
"denyRead": ["~/"],
"allowRead": ["."]
},
"credentials": {
"files": [
{ "path": "~/.aws/credentials", "mode": "deny" },
{ "path": "~/.ssh", "mode": "deny" }
],
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" },
{ "name": "NPM_TOKEN", "mode": "deny" }
]
}
}
}
denyRead blocks the read, allowRead reopens the project, and a narrower deny holds inside a wider allow, so a broad allow cannot silently re-expose a secret. There is no built-in credential list, so every file and variable you care about has to be named. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB strips credentials from all subprocesses whether or not the sandbox is on, and permissions.blockReadsOutsideWorkingDirectories is the broader switch for keeping reads inside the project.
Permission rules are enforced by Claude Code, not the model, so instructions in CLAUDE.md do not change them. A user who controls the client can change the rules unless managed settings hold them.
Cursor
.cursorignore uses gitignore syntax, as Cursor's ignore-file documentation describes. It is still worth writing:
**/.env
**/.env.*
**/*.pem
**/*.key
**/id_rsa
**/credentials.json
Cursor already ignores .env* by default, along with everything in .gitignore, and there is a global ignore list in settings. The file controls code access only: "the terminal and MCP server tools used by Agent cannot block access to code governed by .cursorignore." The documentation also says complete protection is not guaranteed.
So the ignore file is a context control, and the read control is a hook. beforeReadFile fires before a file read and can return {"permission": "deny"}, per the hooks reference. Enterprise hooks can be pushed from the system path, so the rule is not something a developer edits out:
{
"version": 1,
"hooks": {
"beforeReadFile": [
{ "command": ".cursor/hooks/secret-paths.sh", "failClosed": true }
]
}
}
Enterprise hooks come from /etc/cursor/hooks.json on Linux, /Library/Application Support/Cursor/hooks.json on macOS and C:\ProgramData\Cursor\hooks.json on Windows. Even then, a shell cat is not a file read, so the terminal still needs the sandbox and egress layers.
Codex
Codex's older sandbox modes govern writes and network, not reads. In workspace-write, commands can read the whole disk and can only write inside the workspace and temporary directories, so a read-only path is still readable and cat ~/.aws/credentials succeeds.
Read isolation is a separate setting. Codex's permission profiles take per-path rules, and deny blocks reads as well as writes. They are in beta and do not compose with sandbox_mode: if sandbox_mode appears in any loaded config, Codex uses the older settings instead.
default_permissions = "restricted"
[permissions.restricted]
extends = ":workspace"
[permissions.restricted.filesystem]
":minimal" = "read"
"~/.ssh" = "deny"
"~/.aws" = "deny"
"~/.kube" = "deny"
Extending :workspace keeps the workspace writable. On Linux, WSL and Windows, deny globs are enumerated at process start, so use exact paths for credentials. Verify under the profile: cat ~/.aws/credentials should now fail.
Egress is separate again. Keep the network off; when a workflow needs the registry, enable the proxy and allow only that host:
[features]
network_proxy = true
[permissions.restricted.network]
enabled = true
[permissions.restricted.network.domains]
"registry.npmjs.org" = "allow"
The older sandbox_mode and network_access keys still apply to installed clients; the approvals and security page documents both models.
GitHub Copilot
Content exclusions keep matched files out of Copilot's context on the GitHub surfaces that honor them. They are a context control, not a read control, so they belong in the same category as an ignore file. For local sessions the control point is the CLI's preToolUse hook, which is repository scoped and can deny a tool call.
Where all of this stops
- The path list is long, per client and per operating system, and keeping it current is a maintenance job.
- Client path rules are enforced by the client. Any process that can read the file as your user can still read it.
- Environment variables are the quiet path. An agent that inherits
AWS_SECRET_ACCESS_KEYnever needs to open~/.aws/credentials. - The dangerous pattern is a read followed by a send. Blocking
.envmatters less than blocking the egress that would carry it, and a developer can add an MCP server with an inline key in under a minute.
The arrangement that holds
The durable answer is to stop putting long-lived credentials where agents run. Alyria's secrets service, Umbra, seals endpoint secrets to the device's hardware key and issues short-lived leases that Alyria Beacon injects at the moment of use, so nothing standing sits on disk for an agent to find. Enterprises can bring their own KMS key, or hold one Alyria never sees, and revoke it at will.
The other half is the flow: policy that looks at what already happened in the session. Lyra sequence rules express exactly that. One rule can say: after reading a secret, deny network egress for the rest of the session.
The rule is enforced by the local policy service in Alyria Beacon, on the endpoint, so it applies to a stdio MCP server, a shell command and a model call alike. There is no cloud round trip and no dependence on the agent cooperating. A blocked sequence produces a record like this:
{
"decision": "deny",
"plane": "C",
"rule": "alyria.lyra/sequence.secret-then-egress",
"sequence": [
"file.read ~/.aws/credentials",
"net.connect api.example.com"
],
"reason": "secret-tier read followed by egress",
"outcome": "connection refused"
}
Alyria Beacon also flags secrets already sitting in agent configs, so the first report shows where the keys are today. Deploy is read-only and starts in monitor mode.
A checklist that fits on one page
- Inventory the config files with inline credentials, starting with every MCP config on the machine.
- Put the obvious paths in each client's deny rules:
.env,~/.ssh,~/.aws,~/.config/gh,~/.kube. - Turn on the sandbox for shell commands, not just the file tools, and strip credential variables from subprocess environments.
- Add an egress allowlist, deny everything else by default, and move long-lived keys to short-lived leases.
- Test with a real read and a real connection, and keep the record.
Questions this usually raises
Is .cursorignore or .claudeignore enough? No. Cursor's own documentation says the terminal and MCP tools are not covered by .cursorignore, and Claude Code ignores .claudeignore entirely. Treat both as context controls and pair them with a sandbox.
Should I deny reads of the whole home directory? That is a reasonable starting point on a managed laptop. Allow the project directory back with allowRead, then add the directories people need. Expect some breakage and fix it with narrow allows rather than removing the deny.