DEV Community

Emery Li
Emery Li

Posted on

Label Tool Arguments Before Any Model Hop

On a Tuesday afternoon, an engineer asked a coding agent to tidy a configuration parser that had grown messy. The agent planned a reasonable sequence of file reads, AST rewrites, and unit test commands that looked harmless on the transcript. The third tool call then bundled the adjacent dotenv file into the argument list, because the parser imported environment keys by name. A remote hop at that moment would have shipped live credentials with the request payload.

Local-first routing is often framed as a latency preference, yet the sharper constraint is the secret surface of tool arguments. Model weights can sit on a laptop, a browser tab, or a borrowed server without changing that surface. The hop that matters is the one that serializes tool names and argument values toward any process the engineer does not control. This article treats that serialization as a placement problem rather than a prompt-writing problem.

Tool loops leak more than prompts

Modern agents do not merely complete text; they emit tool calls that a runtime executes against the working tree. The usual wire shape is a JSON object with a tool name, a call identifier, and a map of arguments. Those arguments frequently contain absolute paths, file excerpts, stack traces, session cookies, and database DSNs copied from local config. A system prompt can forbid exfiltration and still lose, because the dispatcher sits below the model's stated intentions.

Offline laptops make this failure mode easy to ignore until the next brownout ends. The moment connectivity returns, a queued tool call may flush toward a remote endpoint with yesterday's secrets still in the payload. Latency compounds the mistake, because a slow remote loop encourages larger batched arguments, which are harder to review by hand. The control that actually works is a gate that inspects arguments before any network write occurs.

Four labels for every argument

Each argument value receives exactly one label before the dispatcher is allowed to run. The labels are secret, dirty-tree, bulky-public, and tiny-public, and they are assigned in that priority order. Secret always wins over the other three, even when the same string also looks like ordinary source code. Dirty-tree wins over the public labels whenever the value overlaps an uncommitted path in the working tree.

  1. Mark secret when the value matches credential shapes, private-key sentinels, or known secret filenames.
  2. Mark dirty-tree when the value names a path with uncommitted hunks, or embeds a diff from those hunks.
  3. Mark bulky-public when the value is large, already committed, and free of credential sentinels.
  4. Mark tiny-public when the value is a short committed identifier, a test name, or a documented flag.

These labels are content labels, not job-level health signals about load, sleep, or path delay. They do not replace hop counters, brownout runbooks, or working-set overflow rules that other checklists already cover. They answer a narrower placement question about whether this particular JSON may leave the machine. The decision table below maps the strongest label on a call onto a residency action.

Strongest label on the call Local unix-socket loop Free-server loop Action
secret allowed refused keep the call on the laptop
dirty-tree allowed refused finish the edit locally
bulky-public optional allowed remote hop may spend CPU
tiny-public preferred allowed local hop is usually cheaper

A reproducible classifier

The following Python module is a proposed gate, not a production security scanner. It uses filename hints, simple regular expressions, and a dirty-path prefix list supplied by the operator of the repo. It deliberately avoids network calls so the same check still functions during an offline afternoon. Engineers should treat matches as fail-closed signals and unmatched text as unknown, not as safe.

# tool_arg_gate.py
from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Literal

Label = Literal["secret", "dirty-tree", "bulky-public", "tiny-public", "unknown"]
Action = Literal["local", "remote-ok", "refuse"]

SECRET_NAME_RE = re.compile(
    r"(?i)(pass(word)?|secret|token|api[_-]?key|authorization|private[_-]?key|\.env|id_rsa)"
)
SECRET_VALUE_RE = re.compile(
    r"(?i)(-----BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY-----|"
    r"ghp_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})"
)
BULKY_CHARS = 8_192

@dataclass(frozen=True)
class Verdict:
    label: Label
    action: Action
    reason: str

def _strings(value: Any) -> Iterable[str]:
    if isinstance(value, str):
        yield value
    elif isinstance(value, dict):
        for key, item in value.items():
            yield str(key)
            yield from _strings(item)
    elif isinstance(value, list):
        for item in value:
            yield from _strings(item)
    elif value is not None:
        yield str(value)

def label_argument(value: Any, dirty_prefixes: tuple[str, ...]) -> Label:
    blob = "\n".join(_strings(value))
    if SECRET_NAME_RE.search(blob) or SECRET_VALUE_RE.search(blob):
        return "secret"
    for prefix in dirty_prefixes:
        if prefix and prefix in blob:
            return "dirty-tree"
    if len(blob) >= BULKY_CHARS:
        return "bulky-public"
    if blob.strip():
        return "tiny-public"
    return "unknown"

def decide(
    tool_name: str,
    arguments: dict[str, Any],
    dirty_prefixes: tuple[str, ...],
    remote_allowlist: frozenset[str],
) -> Verdict:
    labels = [label_argument(v, dirty_prefixes) for v in arguments.values()]
    if "secret" in labels:
        return Verdict("secret", "local", "credential-shaped argument stays on the laptop")
    if "dirty-tree" in labels:
        return Verdict("dirty-tree", "local", "uncommitted path or hunk stays on the laptop")
    if tool_name not in remote_allowlist:
        return Verdict("unknown", "refuse", f"tool {tool_name!r} is not in the remote allowlist")
    if "bulky-public" in labels:
        return Verdict("bulky-public", "remote-ok", "sanitized bulky payload may use a free server")
    return Verdict("tiny-public", "local", "tiny public payload is cheaper on a local socket")

def collect_dirty_prefixes(repo: Path) -> tuple[str, ...]:
    # Proposed helper: a trusted wrapper refreshes this file; the model does not.
    marker = repo / ".agent-dirty-prefixes"
    if not marker.exists():
        return ()
    lines = marker.read_text(encoding="utf-8").splitlines()
    return tuple(line.strip() for line in lines if line.strip())
Enter fullscreen mode Exit fullscreen mode

The helper that fills .agent-dirty-prefixes should run from a trusted Git wrapper, not from the model. A small shell snippet can refresh that file before every agent turn without copying hunks into the gate. The classifier then reads prefixes only, so it does not become a second copy of the working tree. That split keeps uncommitted text on the encrypted laptop disk even when a later hop is allowed.

#!/usr/bin/env bash
set -euo pipefail
repo="$(git rev-parse --show-toplevel)"
git -C "$repo" status --porcelain | awk '{print $NF}' > "$repo/.agent-dirty-prefixes"
Enter fullscreen mode Exit fullscreen mode

Numbered workflow for one agent turn

  1. Refresh .agent-dirty-prefixes from git status --porcelain before the model emits a tool call.
  2. Intercept the JSON tool call in the dispatcher, before sockets other than the local model are opened.
  3. Run decide() with the tool name, the argument map, dirty prefixes, and a tight remote allowlist.
  4. Execute secret and dirty-tree calls against local tools only, speaking to a unix socket or in-process function.
  5. When the verdict is remote-ok, strip argument keys that are not on a public schema, then send the remainder.
  6. Log SHA-256 hashes of argument blobs, tool names, and verdicts, and never log the raw secret strings.

The allowlist should contain tools that cannot read the filesystem, such as a pure formatter or a public documentation lookup. Tools like read_file, run_terminal, and apply_patch stay local even when a remote queue is idle and inexpensive to start. Unknown labels refuse the remote hop outright, and they do not default to a yes. That fail-closed choice is the difference between a gate and a dashboard that merely reports regret.

Latency, offline work, and when a free server wins

A local unix-socket loop usually wins on first-token wait and on offline afternoons, because no DNS or TLS handshake sits on the path. Secrets and dirty trees reinforce that default, since those payloads should not cross a network boundary at all. Interactive refactors of a hot module therefore stay on the laptop even when a remote queue is empty. The laptop also keeps stdout, stderr, and core dumps inside the same encrypted disk as the working tree.

A free server wins after the classifier returns remote-ok and the remaining work is bulky, repetitive, or thermally expensive on the laptop fan. Examples include formatting a committed public corpus, summarizing already-published API docs, or running a long public test matrix that does not need the dirty tree. The extra hop is then a cost the engineer chooses, not a leak the agent stumbles into during a tool loop. Offline laptops should queue those jobs rather than retry later with a wider, less reviewed payload.

Proposed measurement, not an executed benchmark, looks like a stopwatch around the dispatcher rather than a marketing table. Record local socket time, remote round-trip time, argument bytes after redaction, and whether the verdict blocked a hop. Compare those four numbers on the same committed fixture, and never on a live dotenv file from a developer laptop. If remote time is lower and the verdict is remote-ok, the free server is a legitimate placement. If the verdict is local or refuse, ignore the stopwatch entirely and keep the bytes at home.

MonkeyCode is relevant on that remote-ok path because the project offers free model access and a free server option for work that has already been sanitized. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The local gate above does not depend on that option; removing the product still leaves a usable dispatcher policy. The server is a placement target for bulky-public tool loops, not a bypass around the classifier or the dirty-prefix file.

Limitations and who should skip this gate

Regex credential detection is incomplete, and entropy heuristics misfire on minified vendor bundles that look like random keys. The dirty-prefix file can go stale if an editor writes the tree after git status and before the tool call. Encrypted secrets that look like bulky-public text will be mislabeled unless a filename hint catches them first. Teams that handle production customer data should use a reviewed policy engine, not this sample module.

Regulated environments that forbid any remote model, free or not, should keep the remote allowlist empty on every machine. Engineers without a content-addressed copy of committed files should not mark payloads bulky-public, because they cannot prove the bytes are already public. People chasing leaderboard latency numbers will dislike the fail-closed refusals, and they are not the audience for this gate. The module also does not claim to stop a malicious local plugin that reads files outside the dispatcher.

A short test plan

The fixture below is small enough to check in and boring enough to rerun on every change to the dispatcher. Each case states the expected label and the expected action in plain assertions. Failures should break the agent boot path, not emit a warning in a log that nobody reads during a busy afternoon.

# test_tool_arg_gate.py
from tool_arg_gate import decide

DIRTY = ("src/config.py",)
ALLOW = frozenset({"format_public_corpus", "summarize_docs"})

def test_dotenv_stays_local():
    v = decide("read_file", {"path": "/home/dev/project/.env"}, DIRTY, ALLOW)
    assert v.action == "local" and v.label == "secret"

def test_uncommitted_path_stays_local():
    v = decide("apply_patch", {"path": "src/config.py", "diff": "@@"}, DIRTY, ALLOW)
    assert v.action == "local" and v.label == "dirty-tree"

def test_unknown_tool_is_refused():
    v = decide("run_terminal", {"cmd": "ls"}, DIRTY, ALLOW)
    assert v.action == "refuse"

def test_sanitized_docs_may_go_remote():
    docs = "A" * 9000
    v = decide("summarize_docs", {"text": docs}, DIRTY, ALLOW)
    assert v.action == "remote-ok" and v.label == "bulky-public"
Enter fullscreen mode Exit fullscreen mode

Run the tests with python -m pytest test_tool_arg_gate.py -q after placing both files on PYTHONPATH. Replace the synthetic AAAA... blob with a committed public document if the team already has one in-tree. Do not paste live tokens into the test file to exercise SECRET_VALUE_RE; the private-key sentinel string is enough. Green tests mean the dispatcher policy is still fail-closed, not that the regexes found every secret on earth.

The Tuesday dotenv incident is a placement bug, not a model-quality bug in the transcript. Label the arguments, keep secret and dirty payloads on the laptop, and spend a free-server hop only on bulky-public work. Readers who already wrap their dispatcher can drop the classifier in without changing prompts or system messages. Those who want a remote target for the remote-ok branch can try MonkeyCode's free server after the gate, not before it.

Top comments (0)