The Context Bundle Is a Customs Declaration

작성자

카테고리:

← 피드로
DEV Community · Riley Lin · 2026-09-15 개발(SW)

You cannot audit a model provider, but you can audit what you send it. The weekly argument about whether an assistant writes better code than you do skips the question that actually protects you: which bytes left the machine, in which class, and where that decision was written down. This article builds an egress gate that answers all three for a context bundle, plus a hash-chained ledger you can replay months later, in plain Node with no dependencies.

The declaration belongs to you, not the vendor

A hosted endpoint is a border crossing with a very quiet customs desk. Providers publish terms, subprocessors, and retention windows, and those documents move on their own schedule, so a page you read in March is not evidence about what happens in September. The part of the crossing you actually control is the declaration, which is the set of files and snippets you put into the request. If you cannot name the classes of material inside that bundle, no privacy page will save you.

Free access makes the crossing more frequent rather than safer. When the only cost is a key and a terminal, most of us paste an entire directory on the first afternoon, including the local .env, the terraform.tfstate, and last week’s incident notes. MonkeyCode’s free model access and free server option both lower that entry cost, and both add an egress path you should classify before you use them in a real workflow.

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

The public conversation still treats these tools mostly as a capability question, which is why security coverage arrives late. Capability debates are loud because everybody can run a demo, while egress mistakes are quiet because nothing visibly breaks on the day you leak. So build the boring control first, before you have a reason to regret not having it.

Four classes, one action each

The threat model below is the smallest one I have found useful for coding assistants, because every class maps to exactly one action and the gate never has to improvise at runtime.

class what lands in it action what the model receives live .env, kubeconfig, cloud keys, private key blocks, unexpired JWTs deny nothing at all until the bundle changes tenant internal hostnames, private IP ranges, account IDs, staging URLs deny nothing, unless you rename the values first personal customer emails, order identifiers, payment-shaped numbers redact a placeholder that carries a short digest ambient FIXME, TODO(security), review leftovers warn the file as written, with the hit logged

The two deny rows are where reasonable people start arguing, and the argument usually confuses secrecy with sensitivity. An internal hostname is not a secret in the cryptographic sense, yet it is a map of your infrastructure, and maps are how an attacker picks a target. The same logic covers a staging URL and a twelve-digit account ID: individually harmless, collectively a reconnaissance report you mailed out by hand.

The personal row is where redaction earns its place, because blocking it outright would make the workflow useless for anyone touching support tickets. Replacing an address with [personal:9f3c21ab77e041d8] keeps the sentence readable for the model while removing the identifier you cannot unshare. The ambient row exists only so that unresolved security notes show up in your ledger instead of hiding in plain sight.

A policy file you can read in one screen

Keep the rules in data, not in code, so a reviewer can read the policy without reading the gate. Patterns are plain regular expressions, which means they are as good as the list you maintain and no better.

{
  "classes": [
    {
      "id": "live",
      "action": "deny",
      "patterns": [
        "AKIA[0-9A-Z]{16}",
        "-----BEGIN [A-Z ]+PRIVATE KEY-----",
        "ghp_[A-Za-z0-9]{36}",
        "eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}"
      ]
    },
    {
      "id": "tenant",
      "action": "deny",
      "patterns": [
        "[a-z0-9-]+\\.internal\\b",
        "\\b10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b",
        "arn:aws:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:"
      ]
    },
    {
      "id": "personal",
      "action": "redact",
      "patterns": [
        "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}",
        "\\b\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4}\\b"
      ]
    },
    {
      "id": "ambient",
      "action": "warn",
      "patterns": ["TODO\\(security\\)", "FIXME"]
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

The ordering question that trips people up is overlap, because a JWT inside a log line can match more than one class. Resolve it by severity rather than by file order, which is why the gate below ranks deny above redact above warn. If you would rather have per-file overrides, add them as explicit exclusions instead of loosening a global pattern.

Refuse first, record second

The gate walks the bundle once, classifies every text file, and exits with a distinct status you can use in a shell pipeline. Exit code 0 means the bundle may be sent, 2 means you called it wrong, and 3 means something in the bundle belongs on your machine and nowhere else.

#!/usr/bin/env node
// egress-gate.mjs - classify a context bundle, then seal the decision.
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { createHash } from "node:crypto";

const argv = process.argv.slice(2);
const flag = (name, fallback = null) => {
  const i = argv.indexOf(name);
  return i === -1 ? fallback : argv[i + 1];
};

const bundle = flag("--bundle");
const policyPath = flag("--policy", "./egress-policy.json");
const ledgerPath = flag("--ledger", "./egress-ledger.jsonl");
const outDir = flag("--out");

if (!bundle) {
  console.error("usage: node egress-gate.mjs --bundle <dir> [--policy f] [--ledger f] [--out f]");
  process.exit(2);
}

const policy = JSON.parse(readFileSync(policyPath, "utf8"));
const classes = policy.classes.map((c) => ({
  ...c,
  compiled: c.patterns.map((p) => new RegExp(p, "g")),
}));

const sha = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
const rank = { allow: 0, redact: 1, deny: 2 };

function* walk(dir) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(path);
    else yield path;
  }
}

function classify(text) {
  const hits = [];
  const spans = [];
  for (const cls of classes) {
    for (const rx of cls.compiled) {
      rx.lastIndex = 0;
      let match;
      while ((match = rx.exec(text)) !== null) {
        if (match[0].length === 0) break;
        const digest = sha(match[0]);
        hits.push({ class: cls.id, action: cls.action, bytes: match[0].length, digest });
        if (cls.action !== "warn") {
          spans.push({ start: match.index, end: match.index + match[0].length, label: `${cls.id}:${digest}` });
        }
      }
    }
  }
  let redacted = text;
  for (const span of spans.sort((a, b) => b.start - a.start)) {
    redacted = redacted.slice(0, span.start) + `[${span.label}]` + redacted.slice(span.end);
  }
  return { hits, redacted };
}

let verdict = "allow";
const entries = [];

for (const file of walk(bundle)) {
  const text = readFileSync(file, "utf8");
  if (text.includes("\u0000")) continue; // binary payloads are out of scope, see limitations
  const rel = relative(bundle, file);
  const { hits, redacted } = classify(text);
  for (const hit of hits) {
    entries.push({ file: rel, ...hit });
    if (rank[hit.action] > rank[verdict]) verdict = hit.action;
  }
  if (outDir) {
    const dest = join(outDir, rel);
    mkdirSync(dirname(dest), { recursive: true });
    writeFileSync(dest, redacted);
  }
}

let prev = "genesis";
for (const entry of entries) {
  const chain = sha(prev + JSON.stringify(entry));
  appendFileSync(ledgerPath, JSON.stringify({ ...entry, prev, chain }) + "\n");
  prev = chain;
}

console.log(JSON.stringify({ bundle, verdict, findings: entries.length, ledger: ledgerPath, out: outDir }, null, 2));
process.exit(verdict === "deny" ? 3 : 0);

Enter fullscreen mode Exit fullscreen mode

Two design choices are worth defending. The gate records decisions even when it denies the bundle, because a blocked attempt is exactly the event you want in your history, and it stores only a truncated digest of the matched value instead of the value itself. It also writes the redacted tree while it walks, so treat ./ctx-redacted as scratch output and only point an assistant at it after a clean exit code.

The ledger chains every decision to the last one

Each line carries the previous line’s chain value, so removing or editing an entry breaks every line after it. That property is cheap to build and expensive to fake later, which is the whole point of writing the decision down at all.

// verify-ledger.mjs - re-derive the chain from the file itself.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";

const sha = (v) => createHash("sha256").update(v).digest("hex").slice(0, 16);
let prev = "genesis";

for (const line of readFileSync(process.argv[2], "utf8").trim().split("\n")) {
  const { prev: recorded, chain, ...entry } = JSON.parse(line);
  const expected = sha(prev + JSON.stringify(entry));
  if (recorded !== prev || chain !== expected) throw new Error(`ledger broken at ${entry.file}`);
  prev = chain;
}

console.log("chain ok:", prev);

Enter fullscreen mode Exit fullscreen mode

Run it with node verify-ledger.mjs ./egress-ledger.jsonl, and keep the output next to the diff that used the bundle. One honest caveat about this technique: the chain depends on canonical key order in JSON.stringify, so renaming a field in the gate invalidates old lines. That fragility is the feature, because it forces you to version the format deliberately instead of rewriting history quietly.

Three fixtures, three verdicts

The tests below build throwaway bundles in a temp directory, run the gate as a subprocess, and assert on both the exit code and the ledger contents. The second assertion in each case matters more than the first, because a classification that still leaks the raw value into the log has made things worse.

import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const HERE = dirname(new URL(import.meta.url).pathname);
const GATE = join(HERE, "egress-gate.mjs");
const POLICY = join(HERE, "egress-policy.json");

function sandbox(files) {
  const root = mkdtempSync(join(tmpdir(), "egress-"));
  const bundle = join(root, "ctx");
  for (const [name, body] of Object.entries(files)) {
    const dest = join(bundle, name);
    mkdirSync(dirname(dest), { recursive: true });
    writeFileSync(dest, body);
  }
  return { bundle, ledger: join(root, "ledger.jsonl"), out: join(root, "ctx-redacted") };
}

function gate(args) {
  try {
    return { code: 0, stdout: execFileSync("node", [GATE, ...args], { encoding: "utf8" }) };
  } catch (err) {
    return { code: err.status ?? 1, stdout: err.stdout ?? "" };
  }
}

test("a live credential stops the run before egress", () => {
  const { bundle, ledger } = sandbox({ "src/config.js": "export const key = \"AKIAIOSFODNN7EXAMPLE\";" });
  const { code } = gate(["--bundle", bundle, "--policy", POLICY, "--ledger", ledger]);
  assert.equal(code, 3);
  const written = readFileSync(ledger, "utf8");
  assert.match(written, /"class":"live"/);
  assert.equal(written.includes("AKIAIOSFODNN7EXAMPLE"), false);
});

test("a customer email becomes a digest marker", () => {
  const { bundle, ledger, out } = sandbox({ "docs/report.md": "Contact [email protected] about the rollout." });
  const { code } = gate(["--bundle", bundle, "--policy", POLICY, "--ledger", ledger, "--out", out]);
  assert.equal(code, 0);
  assert.match(readFileSync(join(out, "docs/report.md"), "utf8"), /\[personal:[0-9a-f]{16}\]/);
  assert.equal(readFileSync(ledger, "utf8").includes("[email protected]"), false);
});

test("a clean bundle leaves no ledger file at all", () => {
  const { bundle, ledger } = sandbox({ "src/math.ts": "export const add = (a: number, b: number) => a + b;" });
  const { code } = gate(["--bundle", bundle, "--policy", POLICY, "--ledger", ledger]);
  assert.equal(code, 0);
  assert.equal(existsSync(ledger), false);
});

Enter fullscreen mode Exit fullscreen mode

Run the suite with node --test gate.test.mjs, then drive a real bundle from a manifest rather than by copying a working tree, which keeps later commits from silently widening the request:

git ls-files 'src/**/*.ts' 'docs/**/*.md' > ctx-manifest.txt
node egress-gate.mjs --bundle ./ctx --policy ./egress-policy.json \
  --ledger ./egress-ledger.jsonl --out ./ctx-redacted
echo $?

Enter fullscreen mode Exit fullscreen mode

On a bundle holding one leaked key, the gate prints "verdict": "deny" and exits 3, and the ledger holds a line that names the file, the class, and a digest instead of the credential. On a bundle with a single support email, the verdict is redact, the exit code is 0, and only the placeholder survives in ./ctx-redacted.

What this does not cover

A regex gate is a seatbelt, not a roll cage. It only catches formats you wrote down, so a novel token shape, a base64 blob of a config file, or a secret split across two lines will pass with a green exit code. Binary files are skipped by design in the snippet above, which means a database dump, a screenshot, or a binary archive can carry sensitive material straight through your gate without a single finding.

Redaction is also lossy in a way that is easy to underestimate, because structure itself carries information. A redacted schema still reveals your table names, a redacted stack trace still reveals module paths, and a redacted customer ticket still reveals that the feature exists. Nothing here constrains what the provider retains, how long it keeps it, whether a human reviews it, or which subprocessors sit behind the endpoint, so read the current terms yourself instead of trusting a blog post from any month.

Finally, the gate covers egress only, and the response path is a separate problem. A model can echo a real token back into a diff, a chat transcript can end up committed, and a pasted stack trace can be regenerated into a test fixture. Treat returned text as untrusted input, and keep transcripts out of the repository.

Who should not use this

Do not adopt this workflow as a compliance control if you operate under a regime that expects approved data-loss-prevention tooling and documented legal review, because a hand-maintained pattern list will not satisfy an auditor and should not try to. Skip it as well if nobody on the team will own the policy file, since a stale rule set is worse than no rule set, because it hands out confidence you have not earned.

You should also skip it if your job is genuinely to debug production data, because the gate will block you several times a day and you will learn to bypass it. In that case, change the workflow instead of the tool: reproduce the failure against a sanitized fixture you build deliberately, and bring that fixture to the model. If you want to try the whole loop, the free tier is enough to run the fixtures above end to end, and you should check the current allowances yourself before you depend on them, since free limits move faster than articles do.

원문에서 계속 ↗