When a coding agent runs a command, the interesting leak is not the prompt you typed. The leak is the stdout that the tool ships back into the model context, often without anyone reading it first. You already treat prompts as data leaving the machine, yet stdout from grep and test runners still travels wide open. That return path is a second prompt, and it deserves the same trust-boundary review you give the first one.
You should think of the agent loop as an airport checkpoint rather than a casual chat box. You inspect the suitcase on the way to the gate, then ignore the trolley that comes back from the carousel. Command output is that trolley: it re-enters the sterile area carrying whatever the runway collected. Environment dumps, connection strings in stack traces, and customer identifiers in failing fixtures all ride that trolley.
If the model sits on a remote host, that trolley also leaves your building as ordinary HTTPS traffic. A free inference server does not change the physics of the copy; it only changes who operates the disk. You should draw the trust boundary at the wrapper around the tool, not at the vendor's landing page. Everything that wrapper emits is, for practical purposes, a document you chose to publish to the model.
Most agent tools look harmless because they resemble commands you already run in a terminal. Invocations such as env, git remote -v, docker inspect, and a failing pytest log are daily furniture in a debugging session. Furniture still has drawers, and those drawers often hold tokens, internal hostnames, and fragments of production records. The model did not steal those fragments; your tool loop handed them over as context that might help it see the failure.
Logs make the same mistake in slower motion, because request traces and assistant session files keep a second copy of every tool result. A platform that records prompts for review will also record the stdout you fed the model, including secrets you thought lived only in memory. You cannot unsend a log line after the model has already tokenized it. If you would not paste that line into a public issue tracker, you should not allow the tool loop to paste it either.
You can close the return path with a local filter that sits between subprocess and the model client. The filter is not encryption; it is a seatbelt that stops obvious secrets from becoming prompt tokens. The listing below is a proposed Python wrapper you can run on your own laptop. Treat it as a starting point for a threat model, not as a finished compliance program.
#!/usr/bin/env python3
"""Proposed local filter: redact tool stdout before any model client sees it."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from typing import List, Tuple
# Policy sketch, not a complete deny list. Extend it from your own threat model.
HIGH_RISK_PREFIXES = (
"env",
"printenv",
"docker inspect",
"kubectl get secret",
"aws secretsmanager",
"git config --list",
"curl http://169.254.169.254",
)
PATTERNS: List[Tuple[str, str]] = [
(r"AKIA[0-9A-Z]{16}", "[REDACTED_AWS_KEY_ID]"),
(r"(?i)aws_secret_access_key\s*=\s*\S+", "aws_secret_access_key=[REDACTED]"),
(r"(?i)(api[_-]?key|token|secret|password|passwd)\s*[:=]\s*\S+", r"\1=[REDACTED]"),
(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
"[REDACTED_PRIVATE_KEY]",
),
(r"(?i)(postgres|mysql|mongodb|redis)://[^\s'\"']+", "[REDACTED_DB_URL]"),
(r"(?i)bearer\s+[a-z0-9._\-]+", "Bearer [REDACTED]"),
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "[REDACTED_EMAIL]"),
]
MAX_CHARS = 4000
def is_high_risk(command: str) -> bool:
lowered = " ".join(command.split()).lower()
return any(lowered.startswith(prefix) for prefix in HIGH_RISK_PREFIXES)
def redact(text: str) -> str:
redacted = text
for pattern, repl in PATTERNS:
redacted = re.sub(pattern, repl, redacted, flags=re.DOTALL)
if len(redacted) > MAX_CHARS:
redacted = redacted[:MAX_CHARS] + "\n[TRUNCATED_TOOL_OUTPUT]"
return redacted
def run_filtered(argv: List[str]) -> int:
command_text = " ".join(argv)
if is_high_risk(command_text):
print("REFUSED: command matches a high-risk prefix.", file=sys.stderr)
print("Describe the failure to the model instead of dumping the environment.", file=sys.stderr)
return 2
completed = subprocess.run(argv, capture_output=True, text=True, check=False)
combined = (
f"$ {command_text}\n"
f"exit={completed.returncode}\n"
f"stdout:\n{completed.stdout}\n"
f"stderr:\n{completed.stderr}\n"
)
sys.stdout.write(redact(combined))
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Run a command and print a redacted transcript for a model."
)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
argv = args.command[1:] if args.command and args.command[0] == "--" else args.command
if not argv:
print("usage: tool_stdout_filter.py -- <command> [args...]", file=sys.stderr)
return 1
return run_filtered(argv)
if __name__ == "__main__":
raise SystemExit(main())
Try the two invocations below against a scratch clone so you can compare the raw transcript with the filtered one. The env command should be refused by policy, which is the correct outcome for a floor sweep of process memory. The test command should return a truncated, placeholder-filled transcript that you can actually read before a model does. That reading step is the control; automation without a human glance will miss the pattern that regex did not know.
python tool_stdout_filter.py -- env
python tool_stdout_filter.py -- pytest -q tests/test_login.py
Classification belongs in the same wrapper, and it should be written as policy rather than as a vibe. Commands that print process environments, cloud metadata, kubernetes secrets, or docker configs are high risk even when you only wanted a version string. Commands that compile a single file or run unit tests on synthetic fixtures are lower risk after redaction, though they are never zero risk. You should keep this process on your side of the boundary, because a filter after the HTTP request has already lost.
The model only needs the shape of a failure, not the living credentials that happened to be in scope when the test process started. If the redacted output is no longer enough to debug the issue, you debug that issue locally instead of widening the pipe. A thinner packet is a successful security outcome even when it feels like a worse debugging experience.
After the wrapper reduces the transcript, you still need a destination if a remote model should reason about the failure. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can receive an already-redacted transcript when you are experimenting on throwaway code rather than regulated records. Availability of a free remote path does not make the filter optional; it only gives you a place to send the leftover puzzle.
Limitations need to stay in view, because regex is a blunt instrument and models are hungry for context. Encoded secrets, novel token formats, screenshots, and binary files will walk around this wrapper without triggering a match. A determined agent allowed to read arbitrary paths can still open private keys and return a paraphrase that reconstructs the secret. You should deny file-read tools against known secret locations, and you should never treat a successful redaction log as evidence that nothing left.
This approach is the wrong tool if you handle data that cannot touch a third-party model at all. Healthcare records, payment cards, and customer exports belong in an air-gapped workflow, not in a filter in front of a free server. It is also the wrong tool if you need a cryptographic guarantee, because a placeholder in stdout is not a vault. Teams with a private on-metal model should still filter tool output and should not replace that path with a public endpoint.
The durable habit is small, slightly inconvenient, and easier to keep than an incident review. You let the agent propose a command, run it through your filter, and send only residue that belongs on a whiteboard. Tool output is a second prompt, and those second prompts are how quiet incidents usually start. If you want to see how small a sanitized packet can get, try the wrapper on a throwaway repo first.
Top comments (0)