Keeping credentials out of AI agent logs
"Put it in an environment variable" is where most advice about AI agent credentials stops. The advice is correct. It is also not enough. An agent writes down what it did — that is how it remembers across steps — and the credential that ends up in those notes usually does not arrive through the command you wrote. This is what actually leaks, and how to check for it in a way that is worth believing.
The transcript is a durable artefact, not scrollback
An agent works in a loop: run a tool, read the result, decide the next step. For that loop to survive more than one step, the result has to be recorded. So agent harnesses keep a transcript of what was run and what came back — generally verbatim, generally on disk, generally for as long as the session is worth resuming.
That is a design necessity, not a defect. But it changes the threat model in a way that ordinary automation does not prepare you for. In a shell script, output goes to a terminal and scrolls away, or into a CI log with a retention policy somebody chose deliberately. In an agent, the output is the memory. Every byte a tool prints is a byte written down, by design, to a file that is meant to outlive the step.
So "could this command leak a credential?" is the wrong question. The question is: what will be written down as a result of this step — including the parts I did not type and did not choose?
1. Command lines are public. Environments are not.
It is worth knowing why the environment-variable advice works, because the reason tells you precisely how to undo it by accident.
On Linux, a running process publishes its command line at /proc/<pid>/cmdline and its environment at /proc/<pid>/environ. Those two files are not equally protected. On a stock procfs, we measured:
$ stat -c '%a %n' /proc/$PID/cmdline /proc/$PID/environ
444 /proc/12345/cmdline
400 /proc/12345/environ
444 is world-readable. 400 is readable by the owner and root, and nobody else. That single difference is the entire reason environment variables are the recommendation — the kernel restricts one and publishes the other. Run the command above on your own systems rather than taking ours: some deployments mount /proc with hidepid, which changes who can see other processes at all.
Which means the difference between these two lines is not style:
# credential in argv — readable by any local user, for the life of the process
myprog --password="$MY_TOKEN"
# credential stays in the environment — not published to other users
myprog # myprog reads MY_TOKEN itself
Two things make this bite harder than it first looks.
It is not about flags. We tested whether a string that is only ever part of a command — passed to no program as a credential, written to no file — is exposed anyway. It is. The string appears in the invoking shell's own /proc/<pid>/cmdline, because that shell was itself started with the whole command as an argument. Our positive control matched exactly one process, and it was the shell holding the entire command text; our negative control, a string never typed anywhere, matched none. So the rule is not "avoid --password". The rule is that the text of the command is public, whatever role the secret was playing inside it.
The window is the life of the process, not an instant. A long file transfer or a long-running server keeps its argv readable for the whole duration. "It is only exposed for a moment" is a claim about duration, and it is worth measuring rather than assuming.
A useful consequence for the most common case — confirming a credential is present without printing it:
[ -n "${MY_TOKEN:-}" ] && echo "set" # prints "set", or nothing
env | grep MY_TOKEN # prints the value. Don't.
And the trap that catches people who are already being careful:
grep -r "$MY_TOKEN" ./logs/ # the secret is now in THIS command's argv
Searching for a secret puts the secret on a command line. If you need to scan for a value, have the scanner read it from the environment itself and emit only counts or booleans — never interpolate it into a command.
2. Most leaks arrive through output, not input
This is the failure that is hardest to defend against, because nothing you wrote was wrong.
Take a pattern common in automation — a token embedded in a git remote URL:
git remote add origin "https://alice:TOKEN@example.com/r.git"
Git writes that into .git/config. We measured the result: mode 600, owner-only, exactly as you would hope. So far, reasonable.
Then, later, in a completely unrelated step, something runs an ordinary read-only diagnostic:
$ git remote -v
origin https://alice:TOKEN@example.com/r.git (fetch)
origin https://alice:TOKEN@example.com/r.git (push)
The token is printed back in full, by a command that takes no credential, changes nothing, and looks entirely innocuous in review. In an agent, that output has just been written into the transcript. Nobody typed a secret. A guard on what you type would not have fired.
This is a general shape rather than a git quirk. Tools echo their own configuration back. They put connection strings into error messages. They log the URL they are about to fetch. A credential you handled correctly on the way in can come back out through a channel you never classified as a credential channel.
Two consequences worth acting on:
Guarding input and guarding output are different jobs over different populations. A check that inspects commands before they run cannot see a value that only ever appears in a result. If your harness offers a hook for one, check specifically whether it offers a hook for the other — and whether that hook can rewrite what gets recorded, or only observe it. Do not assume the two are symmetric; verify it in the harness you actually run before you rely on it.
Wrap the tool rather than trusting it. If a tool must handle a credential, put a wrapper between it and the transcript: feed the secret on standard input rather than as an argument, and pipe everything the tool says through a redactor before anything records it. The wrapper is an enforcement point. A rule in a document is not.
3. State the rule as a consequence, not as a list
The natural way to write this policy is a list of forbidden commands: do not use env, do not pass --password, do not print the config file.
A blocklist can only ever cover the tools that were in mind on the day it was written, which makes it structurally one step behind the thing it is protecting against.
A rule stated as a consequence does not have that failure mode:
No command may cause a credential's bytes to reach standard output, standard error, or a command line.
It is harder to enforce mechanically, and it is the only form that generalises. It changes the review question from "is this command on the list?" — unanswerable for a tool nobody has seen — into "what does this print?", which is answerable for anything, by running it once and looking.
That is the practical technique, and it costs about a minute: before a tool touches a real credential, run it once with a decoy value and read every byte it emits. The tools that echo will identify themselves.
4. An audit with no controls is not evidence
This is the step most often skipped, and it is the one that decides whether any of the rest meant anything.
Sooner or later somebody scans for the value and reports: zero hits, we are clean.
A zero is not a result. Zero is what a working scan returns when there is nothing to find — and it is equally what you get from a scan that searched the wrong directory, did not recurse, lacked permission to read the files, was blind to the compressed ones, was handed a subtly wrong needle, or hit a shell alias that quietly changed its own flags. Every one of those failures reports zero, confidently, in exactly the same format as success.
So a scan becomes evidence only when it carries two controls:
- A positive control — a string you know is present in the population being searched. If the scan does not find it, the scan is broken and the zero beside it means nothing. This proves the scan reached the files, could read them, and matches the way you believe it does.
- A negative control — a string you know is absent. If the scan finds it, the scan is matching things it should not, and its hits are not trustworthy either. This proves the scan discriminates.
Report all three numbers together. A zero on its own is a number. A zero between a positive control that fired and a negative control that stayed silent is a measurement.
We are firm about this because it caught our own scan. Preparing this article, we ran exactly the check described above, and the negative control came back with one hit — for a string chosen precisely because it had never been used for anything. The scan was matching the scanner: the search process had the needle inside its own command line, so it found itself. It would have gone on reporting that false positive indefinitely, and a scan that cannot distinguish a finding from its own reflection is worse than no scan, because it produces a number people act on.
The controls are not ceremony. They are the only reason to believe the number.
Two further things worth building into any scan:
- Ask what it cannot see. A plaintext search does not look inside compressed archives, and a database does not usually keep its contents anywhere a file-level search will reach. Enumerate the places a value could live before choosing the tool, and record which of them the scan does not cover. A bounded answer is honest; an unbounded "zero" is not.
- A count is a measurement with a timestamp. If anything is still writing to the population, the number starts decaying the moment you take it. Record when you measured, and whether the source was closed first.
5. Permissions are not isolation, and only rotation is a remedy
Two closing points that are commonly held backwards.
Mode bits separate users, not processes. Tightening a file to 600 protects it from other accounts. It does nothing about anything else running as you: another process in the same container, a sidecar, a build step, a colleague on a shared service account. Note that this applies to the 400 on /proc/<pid>/environ too — it is real protection from other users and no protection at all from a process sharing your uid. Before concluding that a permissions change fixed an exposure, answer a concrete question: who else runs as this uid? If the answer is "several things", the change bought close to nothing while feeling like it bought a great deal. That gap is the dangerous part.
Suppression and rotation do different jobs, in that order. Closing the source stops the pile growing. It does not shrink the pile. Everything already written stays exactly as readable as it was, and every copy of it — backups, archived logs, a transcript somebody synced elsewhere — stays valid. A credential that has been written down somewhere readable has to be treated as compromised, and the only thing that makes it stop mattering is invalidating it.
The order is: stop the source, enumerate where it reached, then rotate. Treat the work as unfinished until the rotation is done — not until the scan comes back green.
The short version
- The transcript is a durable artefact. Ask what a step will write down, not only what it runs.
/proc/<pid>/cmdlineis world-readable;/proc/<pid>/environis not. That is why environment variables are the advice, and passing the value as an argument undoes it.- The whole command text is public — so searching for a secret leaks the secret.
- Most leaks arrive through tool output. Wrap the tool and redact what it says before anything records it.
- Write the rule as a consequence, not a blocklist. Every blocklist is one tool behind.
- A scan without a positive and a negative control is not evidence.
- Permissions separate users, not processes. Rotation is the only remedy for what already leaked.
None of this is exotic, and none of it requires a product. It is the difference between a practice that sounds right and one you can demonstrate on request.
Running agents against your own systems?
We design and build AI and IT systems for SMBs — privacy-first, no vendor lock-in, and every claim we make about them is one we can show you the measurement for.
Let's talk →