cd /news/ai-safety/tool-stdout-is-a-secret-envelope · home topics ai-safety article
[ARTICLE · art-124830] src=dev.to ↗ pub= topic=ai-safety verified=true sentiment=· neutral

Tool Stdout Is a Secret Envelope

A developer warns that tool stdout is a secret envelope, arguing that command output, test logs, and stack traces can leak sensitive data to remote coding assistants. They propose a local redaction gate that filters high-risk patterns before any tool output is sent to a model, treating all tool results as untrusted data. The article includes a Python script as a proposed preflight filter and notes that MonkeyCode offers free model access and a free server option, with a disclosure that the piece is part of product outreach.

by read8 min views2 publishedSep 9, 2026

When you wire a coding assistant to your shell, the dangerous payload is rarely the prompt you typed. Secrets ride out in command output, test logs, and stack traces that tools dump without asking. You should treat every tool result as untrusted data that must cross a redaction gate before it reaches a remote model. That gate is a local program you control, not a hopeful instruction buried in a system prompt.

Picture the coding assistant as a contractor who stands just outside your office glass all afternoon. You can hand papers through a slot, yet you cannot unsay anything once the page leaves the room. Remote inference works the same way, because the model and any request store sit beyond your process boundary. A free server does not change that geometry; it only makes the slot easier to use during ordinary weekday work.

The threat model stays small if you draw it on one page and refuse to romanticize agents. Your laptop still holds secrets, customer fixtures, and deployment wiring that must never travel with a prompt. The remote model is an untrusted peer that may train on nothing and still keep logs you never see. Between those two rooms sits the agent loop, which will happily cat a file because a stack trace named its path.

You already know not to paste a production env file into a chat box like a tourist. The quieter failure is docker compose config, kubectl describe, env, and a failing test that prints a signed URL. Those commands are useful, so you run them, and the tool layer forwards the stdout as if it were just another comment. The model then helps by quoting the secret back, which copies it into yet another persisted log line.

If you practice this workflow against a remote coding assistant, keep the product in its place as a convenience, not as a vault. MonkeyCode offers free model access and a free server option that can host that loop while you iterate on local gates. Disclosure: This article was prepared as part of MonkeyCode's product outreach, stated here before any workflow advice continues. The disclosure does not change the boundary, because the server still sits outside your laptop, so redaction stays local.

Draw four boundaries even if your diagram looks boring on a whiteboard in a quiet hallway. One boundary is the agent process versus the files it can open without extra confirmation from you. Another is the network cable, where a completion request leaves and you lose practical recall of the bytes. Persistence and people complete the map, because transcripts and operators may outlive the debugging session you meant to keep brief.

Secrets are not only API keys sitting in a file named env, though that file remains a classic mistake. They also hide in PEM blocks, bearer headers, database URLs, session cookies, and cloud credential process exports. Customer fixtures can be worse, because a unique email in a failing test can identify a real person. You should assume any string that grants access or names a private person is ineligible for the model.

Hope is not a control, so you want a local gate that fails closed when it sees a high risk pattern. The script below is a proposed filter you run on your machine before any tool stdout is attached to a request. It refuses known credential shapes and sensitive path names, then writes a sanitized bundle or exits with an error. Treat it as a seatbelt rather than a vault door, because regular expressions miss encodings and novel token formats.

#!/usr/bin/env python3
"""Proposed local preflight gate for assistant context. Run this on your machine."""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

DENY_NAME_PARTS = (
    '.env',
    'id_rsa',
    'id_ed25519',
    'credentials',
    'kubeconfig',
    'serviceaccount',
    '.pem',
    '.p12',
    'terraform.tfstate',
)

PATTERNS = (
    ('aws_access_key', re.compile(r'AKIA[0-9A-Z]{16}')),
    ('pem_block', re.compile(r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----')),
    ('bearer', re.compile(r'(?i)bearer\s+[A-Za-z0-9._\-]+=*')),
    ('jwt', re.compile(r'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}')),
    ('db_url', re.compile(r'(?i)(?:postgres|mysql|mongodb|redis)://[^\s]+')),
    ('generic_secret_assign', re.compile(r'(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*\S+')),
)

PLACEHOLDER = '[REDACTED:{label}]'

def path_denied(path: Path) -> str | None:
    lowered = str(path).lower()
    for part in DENY_NAME_PARTS:
        if part in lowered:
            return part
    return None

def scan_text(text: str) -> list[str]:
    hits = []
    for label, cre in PATTERNS:
        if cre.search(text):
            hits.append(label)
    return hits

def redact_text(text: str) -> str:
    out = text
    for label, cre in PATTERNS:
        out = cre.sub(PLACEHOLDER.format(label=label), out)
    return out

def main() -> int:
    parser = argparse.ArgumentParser(description='Fail closed on secret-shaped context.')
    parser.add_argument('paths', nargs='*', type=Path)
    parser.add_argument('--stdin', action='store_true')
    parser.add_argument('--redact', action='store_true', help='Print redacted text instead of refusing.')
    parser.add_argument('--max-bytes', type=int, default=200000)
    args = parser.parse_args()

    chunks: list[tuple[str, str]] = []
    if args.stdin or not args.paths:
        data = sys.stdin.read(args.max_bytes + 1)
        if len(data) > args.max_bytes:
            print('gate: stdin exceeds max-bytes', file=sys.stderr)
            return 2
        chunks.append(('<stdin>', data))

    for path in args.paths:
        if not path.exists():
            print(f'gate: missing path {path}', file=sys.stderr)
            return 2
        denied = path_denied(path)
        if denied:
            print(f'gate: refuse path {path} (matched {denied})', file=sys.stderr)
            return 2
        data = path.read_text(encoding='utf-8', errors='replace')
        if len(data.encode('utf-8')) > args.max_bytes:
            print(f'gate: {path} exceeds max-bytes', file=sys.stderr)
            return 2
        chunks.append((str(path), data))

    failed = False
    for name, text in chunks:
        hits = scan_text(text)
        if not hits:
            if args.redact:
                sys.stdout.write(text)
            continue
        failed = True
        print(f'gate: {name} hit {",".join(hits)}', file=sys.stderr)
        if args.redact:
            sys.stdout.write(redact_text(text))
    if failed and not args.redact:
        return 2
    return 0

if __name__ == '__main__':
    raise SystemExit(main())

You can wrap a noisy command so the raw bytes never reach the clipboard or the assistant tool channel. The shell function below runs the command, stores stdout in a temp file, and only prints the gated result. If the gate exits nonzero, you keep the original output local and send a short note that redaction refused the payload. That refusal is the success case, not an inconvenience, because a blocked secret never becomes a completion prompt.

gate_run() {
  raw="$(mktemp "${TMPDIR:-/tmp}/gate.XXXXXX")"
  if ! "$@" >"$raw" 2>&1; then
    echo "gate_run: command failed; output kept in $raw" >&2
  fi
  if python3 gate_context.py --stdin <"$raw"; then
    cat "$raw"
    rm -f "$raw"
    return 0
  fi
  echo "gate_run: refused to forward stdout; inspect $raw locally" >&2
  return 2
}

Run the same gate against files the agent wants to attach, not only against live command output from your terminal. A typical preflight looks like listing candidate paths, grepping for secret-looking names, and then handing survivors to the filter. You should still read the sanitized bundle, because a false negative looks like ordinary application text until someone replays the log. Keep the original transcript in a local directory that your assistant allowlist cannot read, and rotate it like any other secret store.

find . -type f \( -name '.env*' -o -name '*credential*' -o -name 'id_rsa*' \) -print
python3 gate_context.py app.log testdata/sample_trace.txt

Agentic loops make this worse because the model can request another tool call after you thought the context was clean. If the error message still contains a relative path, the next call may open the unsanitized file and skip your wrapper. You close that hole by making the only legal tool a wrapper that always runs the gate, never a raw cat. Think of it as a mail room: every envelope goes through the scanner, including replies that look like innocent follow-up questions.

A proposed local test is enough to catch regressions when you add a new secret pattern next month. Put a fake AWS-style token and a tiny PEM block in a fixture file that never leaves your repository's testdata folder. Assert that the gate exits nonzero on the raw fixture and exits zero after you replace those blocks with placeholders. Label this unexecuted until you run it; the value is the habit of testing the control, not a published benchmark.

from pathlib import Path
import subprocess
import sys

def test_refuses_pem_and_akia(tmp_path: Path) -> None:
    fixture = tmp_path / 'trace.txt'
    fixture.write_text(
        '-----BEGIN PRIVATE KEY-----\nMIIFAKE\n-----END PRIVATE KEY-----\n'
        'AWS_ACCESS_KEY_ID=AKIAIOSAMPLEEXAMPLE\n',
        encoding='utf-8',
    )
    proc = subprocess.run(
        [sys.executable, 'gate_context.py', str(fixture)],
        check=False,
    )
    assert proc.returncode != 0

This approach will not save you if the secret is split across chunks, base64 wrapped, or rendered inside a screenshot. It also will not satisfy a regulator who forbids sending customer content to any third party inference provider at all. Teams handling health records, cardholder data, or classified material should not use a remote coding model for those files. People who cannot run the gate on their own laptop should not paste production output into a browser chat either.

The contractor outside the glass can still help you rename a function, once the envelope contains only what you meant to share. Keep the threat model on one page, and keep raw stdout in a directory the agent cannot list. You will still ship bugs, yet you will ship fewer accidental keys, which is the incident most teams can actually prevent. That is enough of a win to keep the mail room boring, which is exactly how a trust boundary is supposed to feel.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

── more in #ai-safety 4 stories · sorted by recency
── more on @monkeycode 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/tool-stdout-is-a-sec…] indexed:0 read:8min 2026-09-09 ·