DEV Community

Riley Lin
Riley Lin

Posted on

Pack a Working Set, Not the Whole Tree

Remote coding models are useful, but they should never receive your entire repository as a courtesy. You would not hand a visiting contractor a badge that opens every cabinet on the floor. The same instinct belongs in the packer that gathers files before a prompt crosses the network.

The practical conclusion is simple enough that you can act on it this week without new tooling. Name the files the model may see, scan that bundle for secrets, and refuse to send anything outside the list. Everything else in this article is a walkthrough of that export boundary, including a small script you can run before any remote session.

The threat is over-inclusion, not the model brand

Most accidental leaks in public incident writeups start with a helpful agent reading one directory too far. Your environment file, a fixture with real customer emails, or an internal runbook can ride along because the packer followed an import. The remote server then stores or logs that text under a retention policy you do not actually control. Agentic editors now ask for broad filesystem tools by default, which makes over-inclusion the common case rather than a rare misclick.

Think of the context window as a shipping crate sitting on a loading dock you still own. You choose the crate's contents, and the carrier only moves whatever you sealed before the pickup truck arrives. If the crate contains a private key, arguing about the carrier's privacy page does not unsend the crate. Classification therefore happens on your side of the dock, before any remote model session begins for the day.

A compact threat model keeps you honest about that dock without hiring a consultancy to draw posters. Assets include application source, live secrets, production hostnames, and customer-derived fixtures that sit under testdata directories. Actors include you, the editor plugin, the packer script, the remote inference server, and anyone who can read its logs. The trust boundary is the moment bytes leave localhost, whether you pasted a snippet or an agent called read_file.

You do not need a formal STRIDE workshop to put names on the usual failures around this boundary. Spoofing looks like a plugin talking to a different endpoint than the one you approved in settings. Tampering looks like a packer following a symlink out of the repository and into your home directory. Information disclosure is the default failure when the working set is the whole tree, and an unrestricted shell tool is elevation by another name.

What must never enter the crate

Start with files that are never source, even when they live in the same directory as source. Environment files, cloud credential documents, private keys, and registry tokens are credentials, not comments you forgot to delete. Generated dumps from local databases often contain real names even when the application code never prints those names. If a file would be an incident on a public gist, it does not belong in a remote context window either.

Test fixtures deserve the same suspicion you already apply to production data exports leaving the building. A realistic CSV checked in during a deadline can hold live emails, phone numbers, or leftover session cookies. If a model only needs structure, synthesize a fake row with the same columns and none of the real people. The analogy is a fire drill that uses cardboard boxes, not a drill that wheels the real safe into the parking lot.

Conversation history is a second crate that people forget once the current prompt finally looks clean enough. A secret pasted five turns ago still sits in the thread the remote server will resend as context. If you discover a leak in an earlier message, later caution in the same chat cannot retract those bytes. You start a new session from a cleaned working set, and you rotate the credential as if it had been emailed.

A packer you can fail in CI

The helper below is a local gate, not production security software you should market as a control. It reads a manifest of relative paths, rejects escapes from the repo root, and scans the concatenated bundle for high-risk patterns. You should extend the pattern list for your stack, because the point is a gate that fails closed.

#!/usr/bin/env python3
"""working_set.py — allowlist packer with a closed-fail secret scan."""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

DENY_NAMES = {
    ".env",
    ".env.local",
    "id_rsa",
    "id_ed25519",
    "credentials.json",
    "serviceAccount.json",
}

DENY_SUBSTRINGS = ("/secrets/", "/.aws/", "/.ssh/", "/.gnupg/")

SECRET_PATTERNS = [
    re.compile(r"AKIA[0-9A-Z]{16}"),
    re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"),
    re.compile(r"(?i)api[_-]?key\s*[:=]\s*['\"][^'\"]{8,}['\"]"),
    re.compile(r"(?i)secret\s*[:=]\s*['\"][^'\"]{8,}['\"]"),
    re.compile(r"ghp_[A-Za-z0-9]{20,}"),
    re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
]


def load_manifest(path: Path) -> list[str]:
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict) or "paths" not in data:
        raise SystemExit("manifest must be JSON with a top-level 'paths' array")
    return list(data["paths"])


def resolve_allowed(repo: Path, relpaths: list[str]) -> list[Path]:
    resolved: list[Path] = []
    for rel in relpaths:
        candidate = (repo / rel).resolve()
        if repo.resolve() not in candidate.parents and candidate != repo.resolve():
            raise SystemExit(f"path escapes repo: {rel}")
        if not candidate.is_file():
            raise SystemExit(f"missing file: {rel}")
        resolved.append(candidate)
    return resolved


def is_denied(repo: Path, file_path: Path) -> str | None:
    if file_path.name in DENY_NAMES:
        return f"denied filename: {file_path.name}"
    try:
        rel = file_path.relative_to(repo).as_posix()
    except ValueError:
        return "path outside repo"
    parts = rel.split("/")
    for token in DENY_SUBSTRINGS:
        fragment = token.strip("/")
        if fragment in parts:
            return f"denied path fragment: {rel}"
    return None


def scan_text(label: str, text: str) -> list[str]:
    hits = []
    for pattern in SECRET_PATTERNS:
        if pattern.search(text):
            hits.append(f"{label}: matched {pattern.pattern}")
    return hits


def main() -> int:
    parser = argparse.ArgumentParser(description="Pack an allowlisted working set")
    parser.add_argument("--repo", type=Path, default=Path("."))
    parser.add_argument("--manifest", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    repo = args.repo.resolve()
    files = resolve_allowed(repo, load_manifest(args.manifest))
    findings: list[str] = []
    chunks: list[str] = []
    for file_path in files:
        reason = is_denied(repo, file_path)
        if reason:
            findings.append(f"{file_path}: {reason}")
            continue
        text = file_path.read_text(encoding="utf-8", errors="replace")
        rel = file_path.relative_to(repo).as_posix()
        findings.extend(scan_text(rel, text))
        chunks.append(f"\n/* FILE: {rel} */\n{text}")
    args.out.write_text("".join(chunks), encoding="utf-8")
    if findings:
        sys.stderr.write("working set rejected\n")
        sys.stderr.write("\n".join(findings) + "\n")
        return 2
    print(f"packed {len(files)} files into {args.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

A tiny manifest keeps the allowlist reviewable in pull requests, which is the whole reason it exists as a file. You want that file to look boring, because boring means someone thought about the boundary before the session started. Commit the manifest beside the module it describes, so review happens where the code already changes.

{
  "paths": [
    "src/billing/prorate.py",
    "src/billing/prorate_test.py",
    "docs/billing-rules.md"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Run it from the repository root before you open a remote chat, and keep the output on a private temporary path. The commands below also remind you that an environment file should stay off the manifest on purpose. If the command prints a rejection, you fix the bundle locally instead of negotiating with the model about redaction.

python3 working_set.py --manifest working-set.json --out /tmp/working-set.txt
stat .env >/dev/null 2>&1 && echo "remember: .env stays off the manifest"
wc -l /tmp/working-set.txt
Enter fullscreen mode Exit fullscreen mode

You can wire the same command into CI so a new helper file cannot join the crate without a review. The test below writes fixtures into a temporary directory and expects a non-zero status when a fake key is present. If the dirty case ever returns zero, your gate is theater and you should treat that as a packer incident.

# test_working_set.py — run with: python3 test_working_set.py
import json
import subprocess
import sys
import tempfile
from pathlib import Path

HELPER = Path(__file__).with_name("working_set.py")


def pack(repo: Path, paths: list[str], contents: dict[str, str]) -> subprocess.CompletedProcess:
    for rel, body in contents.items():
        target = repo / rel
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(body, encoding="utf-8")
    manifest = repo / "working-set.json"
    manifest.write_text(json.dumps({"paths": paths}), encoding="utf-8")
    out = repo / "bundle.txt"
    return subprocess.run(
        [
            sys.executable,
            str(HELPER),
            "--repo",
            str(repo),
            "--manifest",
            str(manifest),
            "--out",
            str(out),
        ],
        capture_output=True,
        text=True,
    )


def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        repo = Path(tmp)
        clean = pack(
            repo,
            ["src/ok.py"],
            {"src/ok.py": "def add(a, b):\n    return a + b\n"},
        )
        assert clean.returncode == 0, clean.stderr
        dirty = pack(
            repo,
            ["src/ok.py", "src/leak.py"],
            {
                "src/ok.py": "def add(a, b):\n    return a + b\n",
                "src/leak.py": 'api_key = "supersecretvalue123"\n',
            },
        )
        assert dirty.returncode == 2, dirty.stdout
        print("working_set tests passed")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Where a free remote coding server fits

Once the crate is sealed, you still have to choose a destination that matches the sensitivity of those files. A local-only model keeps the crate in the building, which remains the right default for regulated source. Many teams still want a remote assistant for ordinary application code that has already been stripped of secrets.

MonkeyCode is one option in that second bucket for ordinary application code that may leave the machine. It is an open-source coding product that offers free model access and a free server option after you have packed the working set. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free remote path does not change the threat model; it only makes the export cheaper, which can tempt a wider crate.

Use the packer first, then paste or attach only the output file into the remote session you actually need. If the task is explaining one prorate function, the model does not need Terraform, Docker secrets, or last quarter's analytics export. Cheap inference is not a reason to widen the crate, and free does not mean the uploaded bytes later vanish. If that workflow already matches how you handle non-sensitive modules, you can try the free server option on a public sample first.

Read the project's own policy for logs and retention instead of assuming a free endpoint keeps no history. Your working-set manifest is the record of what you intended to export, so keep it beside the session notes. If the policy is unclear for your industry, you do not send the crate, packed or otherwise.

Limitations, and who should skip this

This packer is a seatbelt, not an airbag farm, and it will miss secrets that do not match the regular expressions. Custom tokens, long-lived session strings, and sensitive prose will sail through unless you add patterns for them. It will not stop an agent with unrestricted shell tools from reading files you never packed into the crate. If your editor plugin can search the disk for private keys, the manifest is advisory until those tools are disabled.

Teams under contractual data residency rules should not send source to any free remote server, packed or not. The same refusal applies to health records, payments code, and unpublished security work that is not already public. Those codebases belong on models that stay inside your tenancy, with a paper trail your counsel already approved.

The approach also fails if humans bypass it with screenshots, stack traces, or a quick paste of inspect output. The working set is a habit, and habits break when people are stuck and the model is one paste away. You should still rotate anything that might have crossed the boundary, because least privilege does not create a time machine. If this workflow feels heavy for a throwaway kata on public sample code, skip it and keep that sample public.

The crate metaphor holds only if you remember who packs it, because the model merely unpacks what you shipped. Name the files, scan the bundle, and send that bundle alone when the destination is a remote coding server. Everything else can stay on the dock you already control, including the secrets the model never needed.

Top comments (0)