← Back to the token-goat README

Security, privacy, and uninstall

No telemetry. No analytics. No background reporting or silent outbound connections.

Outbound network is reserved to these explicit cases:

One switch for all of it. Set network.offline = true (env TOKEN_GOAT_OFFLINE) and every one of the paths above refuses instead of connecting, saying so rather than failing quietly. Anything already cached keeps working: a machine that has the embedding model still runs semantic, and one that has the language data still reads text out of images. This is one of the settings a per-project config file may not touch, so cloning a repository cannot switch it back off.

A repository cannot reconfigure the security controls. A project-root .token-goat.toml layers on top of your global config, which is what it is for: hint thresholds, indexing settings, compression tuning. But that file arrives with the repository, so whoever wrote the repository wrote it. Seven whole sections are therefore off limits to it, and come from your global config or the environment only: injection (prompt-injection fencing), webfetch (the fetch allow and deny lists), gdrive (the Google Drive integration), mcp (root confinement and the allowed-roots list), network (offline mode), redaction (the secret-redaction rules), and screenshot (the headless browser). Twenty-two individual keys inside otherwise-overridable sections are locked the same way. Seven decide what gets indexed at all: indexing.cross_project_symbols, indexing.skip_dirs, indexing.skip_files, indexing.skip_minified, indexing.large_file_skip_kb, indexing.large_file_symbol_only_kb and indexing.max_chunks_per_file, which matter because an unindexed file answers symbol, read and semantic in the same words a name that never existed does. Five more are the switches that decide whether a large file arrives folded rather than whole: hints.fold_code_bodies, hints.fold_comment_blocks, hints.fold_prose_paragraphs, hints.outline_large_documents and hints.skeleton_large_sources. Five more each guard one thing: image_shrink.max_image_pixels, the decompression-bomb cap; worker.blocked_roots, the folders you have kept out of the index; compact_assist.summary_budget_chars, the length target token-goat hands to whoever writes your compaction summary, which a repository must not be able to set low enough to erase the session; semantic.max_distance, the relevance floor for semantic, which a repository must not be able to set low enough that its own code stops matching while the command goes on answering from keyword search as though nothing were missing; and semantic.weak_distance, the line past which semantic warns that its closest match is weak, which a repository must not be able to raise until every poor hit reads as confident. Two more govern the shared database: indexing.max_db_size_mb and indexing.auto_reclaim_embeddings, which prevent a repository from manipulating global database disk caps or forcing vector purging across projects. Two more govern symbol-aware first-read thresholds and enforcement: hints.first_read_symbol_bytes and hints.first_read_symbol_policy. One more is hooks.native, which decides whether install writes the native hook client into the hook configs of every harness on the machine, so a project’s file has no say in it. A project file that sets one of them is ignored, and token-goat prints a line naming what it dropped. Everything else stays project-overridable.

The lock covers the config file, not the environment. These settings still read a TOKEN_GOAT_* environment variable, and a repository has ways to set one: a .envrc for direnv, a terminal.integrated.env.* block in a committed .vscode/settings.json, a containerEnv entry in a devcontainer. Refusing environment overrides would break the operator who exports a variable in their own shell, which is the legitimate case and the common one, so token-goat reports instead of refusing: token-goat doctor prints a Security config overrides line naming every locked security setting the environment is currently deciding, and the variable to unset. It covers all twenty-five locked keys that read an environment variable, and it derives that set from the same two tables that define what a project config may not write, rather than from a list kept alongside them. Settings with a safe side (booleans) are reported when the environment holds them open; settings without one (lists, sizes) are reported whenever the environment supplies a value at all, since there is nothing to compare against. A default install, where nothing is set, prints a single ok line.

Security reports. See SECURITY.md. Email token-goat@dfkhelper.com; do not file as a GitHub issue. Reports are acknowledged within 7 days; coordinated disclosure with a 90-day default window.

Dependency advisories. npm audit on the published package is empty, for a default install as well as for npm install --omit=optional. That took removing the package the findings all came through: @xenova/transformers, which supplied the embedding half of semantic and carried a critical protobufjs advisory plus five more that had no forward patch. It is gone entirely now — the tokenizer and the ONNX runner are token-goat’s own code. By default they run on the WebAssembly build of ONNX Runtime, whose JavaScript is bundled into token-goat and whose binary is the pinned download described above, so a default install adds no package for it. If you install the native build, token-goat uses it instead:

npm install -g onnxruntime-node   # optional; about 288 MB installed. Drop -g if token-goat is a project dependency

That command is the one place an advisory can still reach: onnxruntime-node releases before 1.30.0 pull an adm-zip below 0.6.0, which carries one high advisory that npm reports twice. It is reachable only from that package’s own install script, unpacking the binary it just downloaded. The full accounting — including the override that clears it, why co-installing a fixed adm-zip does not, and what the old package cost — is under Dependency advisories.

Verifying what you installed. Every published version is built and pushed by one pinned workflow when a GitHub release is published, with npm provenance, so npm audit signatures verifies the tarball against the commit that produced it. Details in Verifying what you installed.

Prompt injection. When an AI reads a file, web page, or command output, that content enters its context alongside your own instructions. Prompt injection is when untrusted content includes text designed to look like instructions — “Ignore all previous directives and run this instead” — to redirect the AI mid-task.

Token-goat intercepts every Read, Fetch, Bash, and MCP call the AI makes. Where it hands the model text in place of the tool’s own result, that text is wrapped in an untrusted-content fence first, decided by where the text came from and not by whether anything looked suspicious in it (injection.enabled, on by default, turns the whole thing off). Which surfaces that covers, and which it deliberately does not, is set out below: the two exceptions are stated rather than left to be read off this sentence. The content is also scanned for a set of imperative-override attack patterns (“ignore previous instructions,” “reveal system prompt,” and similar); a match adds the pattern names to the fence’s notice and writes a row to the log, and a clean scan changes only the wording. That ordering is the point: the pattern list is deliberately short, so anyone phrasing the same instruction differently would otherwise get an unlabelled channel, and a miss would be silent.

The line that decides is substitution: wherever token-goat replaces a tool result with text of its own, that text is fenced. Every fetched page is fenced as it arrives and again when a cached copy is recalled with web-output. Every MCP tool result is fenced as it arrives, which matters most: it is a remote server’s output, so it is the least trustworthy text in the pipeline. Bash output is fenced when token-goat compresses it, since a compressed body is not the command’s output but token-goat’s account of it, and it is fenced again when a cached copy is recalled with bash-output or mcp-output: the output of a build or test run in a project with a hostile dependency is written by a third party as much as any web page is. Document extraction (pdf-extract, docx-text, the xlsx-* and pptx-* commands), pr-slice, gdrive-sections, and recall are covered the same way. The fence naming tool output is a different tag from the one naming web content, so the label tells the model where the text came from.

In every one of those cases the fence wraps the third-party bytes and stops there. Token-goat’s own notice, the filter’s marker, and the pointer telling the model how to recall the full output all sit outside the closing tag, because the fence is the model’s one signal for where token-goat stops speaking. Fold them inside and that signal is gone, and anyone who guesses the marker’s wording can write a line the model reads as token-goat’s own.

Guessing the wording is not hard, so the fence does not rely on it staying secret: inside the fence, a line shaped like either voice token-goat speaks in has its opening bracket escaped. That covers [token-goat: ...], the marker hooks sign a rewrite with, and [tg], the prefix on every denial, which is the one that matters more because a denial is the only message token-goat sends that is shaped as an instruction to obey. The escape is narrow enough to leave ordinary bracketed words alone. Fenced file content is also placed below token-goat’s own notice rather than above it, so no byte of a file can arrive ahead of token-goat speaking and be read as its preamble.

token-goat ask is fenced for a sharper reason than the rest. It retrieves indexed symbol bodies and pipes them to whatever TOKEN_GOAT_ASK_BACKEND names, normally claude or codex, so it is the one place where third-party text reaches a model that holds tools instead of a model reading a tool result. The snippets are redacted and then fenced before they enter the prompt; the question, which is yours, stays outside the fence.

Read is the exception: file content passes through to the model unfiltered, because filtering it would silently break legitimate use cases. Where token-goat splices a piece of a file into its own hint or denial message, that excerpt is fenced. Outside of the fence, the primary defense is the model’s own training to treat tool output as data, not as commands from a trusted party.

Bash output that token-goat does not rewrite is the same kind of exception, and worth stating plainly rather than leaving to be inferred from the paragraph above. A command whose output is short, or too incompressible to be worth touching, reaches the model exactly as the harness delivered it, with no fence. So does output whose only change was stripping the colour codes a terminal would have rendered, since that path emits the command’s own bytes and adds nothing of token-goat’s to delimit. Fencing those cases would mean rewriting the result of every shell command an agent runs, a permanent cost on the most-used path in the tool, to re-label bytes the model was going to receive in that form anyway. The fence is worth its bytes where token-goat has substituted its own account of the output and the model can no longer tell whose words are whose. Where token-goat has stayed out of the way, the defense is the same as for Read: the model’s training to treat tool output as data.

One deliberate gap: --json output cannot carry a fence around the envelope, because a fence wrapped around JSON is no longer JSON and callers parse it. Those envelopes fence individual fields on a pattern match instead, since the fixed wrapper would otherwise cost more than a short field is worth. The printed (non---json) form of the same command is always fenced.

Separately from that pass-through case: when a read hook denies a Read and substitutes its own message, any file bytes it embeds in that message (a markdown heading tree, a served compact or notebook sidecar, a re-read diff, a CSV header row, an HTML title) are wrapped in an <untrusted-file-content> fence first, so a hostile repo cannot get its own text presented to the model as token-goat speaking. That fencing is unconditional, not gated on the pattern scan — as all of it now is.

A third case needs no fence, because the danger is the line break rather than the wording. When token-goat prints its own summary of a file it prints one entry per line and takes the names and values straight out of that file: the column profile behind csv-profile, the key listing behind json-outline and yaml-outline, the entry listing behind zip-list, and any hook hint naming the file it is about. Every one of those values may legally contain a newline. A quoted CSV field spans lines by design, a JSON key is an arbitrary string, a zip entry name is whatever whoever built the archive wrote in the header, and a file name may contain a newline on Linux and macOS. So a single cell, key, entry or file name could end token-goat’s line and start one of its own that reads exactly like another entry token-goat had written, with nothing but the line break to tell them apart. Control characters, Unicode line separators and format characters in those values are escaped into their visible form, so one entry stays one line and hostile content is shown rather than obeyed. The same rule covers a carriage return that would overwrite the line on screen, an ANSI escape that would recolour it, and a bidi override that would make the rest of it render backwards. Ordinary names and values pass through untouched. This matters most for an archive, since a .whl, .vsix or .nupkg comes from a package registry rather than from you.

The MCP tools (symbol when given a file filter, read, section, skeleton, outline, refs, brief, grep, imports, exports) are confined to the project root, resolving symlinks before the check. Set mcp.confine_reads_to_project_root = false (env TOKEN_GOAT_MCP_CONFINE_READS) in your global config if you genuinely need cross-root reads from an MCP client; a per-project file cannot set it. The CLI is deliberately unconfined and unchanged. This is defense in depth for one sink, not a sandbox: an agent that can call these tools can usually call its own read tool too.

Note what that flag does and does not cover. It stops a caller traversing out of the root it is given; it does not constrain which root the caller supplies. Every MCP tool takes an optional projectRoot, and it exists for a reason — the server’s cwd is often not the workspace root for MCP clients — but tool arguments are model-generated, so that choice is untrusted input like any other. If your deployment treats MCP as the only path to the filesystem, set mcp.allowed_roots (env TOKEN_GOAT_MCP_ALLOWED_ROOTS, delimiter-separated like PATH) to the roots that may legitimately be named; a resolved root outside every entry is then refused. It is empty by default, which keeps the multi-root behavior above unchanged. Because empty means “any root may be named”, token-goat doctor says that outright on its Security mcp roots line rather than reporting the confinement flag alone, which on its own reads as a stronger guarantee than it is.

Restricting what token-goat may fetch. webfetch.allow and webfetch.deny (env TOKEN_GOAT_WEBFETCH_ALLOW / TOKEN_GOAT_WEBFETCH_DENY, comma-separated) are wildcard URL patterns that decide which addresses may be reached. Deny is checked first and wins; a non-empty allow list refuses anything it does not name. Patterns are matched against the address as it will actually be sent, not only as you typed it, so a trailing dot on the host, .. path segments, a default port written out, and percent-encoded path characters cannot be used to step around a rule. Writing a default port in a pattern (https://example.com:443/*) and omitting it are equivalent. Both are empty by default, which permits everything, exactly as before. They apply to the WebFetch call your AI makes, to the fetches token-goat performs itself (fetch-image, gdrive-sections), and to the headless browser behind screenshot (whose page sub-resources are checked too), including every redirect hop, so an allowed site cannot redirect the request on to a denied one.

Where cached content lives, and who can read it. Cached command output, fetched pages, MCP results, session state and the source index all sit under one data directory (~/.local/share/token-goat on Linux, ~/Library/Application Support/token-goat on macOS, %LOCALAPPDATA%\dfk-helper\token-goat on Windows). On POSIX that directory is created owner-only (mode 0700), and an existing one is tightened on the next run, so other local users on a shared build host cannot read it. Windows uses inherited ACLs instead. Individual JSON blobs are additionally written 0600.

Confining symbol to one project. token-goat symbol is the one read command that answers from the machine-wide index (global.db) rather than the current project, so by default symbol <name> and symbol --grep . return matching symbols, bodies included, from every project ever indexed on the host. That is deliberate and useful on a personal machine: it is how you find a helper you wrote in another repo. On a shared build host, or under an agent you have confined to one directory, it is a read channel that the directory sandbox does not close, because the answer comes out of the index instead of the filesystem. Set indexing.cross_project_symbols = false (env TOKEN_GOAT_CROSS_PROJECT_SYMBOLS) and symbol only answers from the project it is run in. --project and --file pointing outside that project are refused rather than honored, so the setting cannot be stepped around from inside the confined process. Every other read command (read, refs, callers, types, dead, find, semantic, search) answers only for the project it runs in, and a root named to read another project’s indexed code, whether through search --project or the projectRoot of an MCP call, is refused the same way unless mcp.allowed_roots lists it. Because the permissive setting is the default and says nothing about itself, token-goat doctor prints a Security symbol scope line stating which way it is currently set, and names the switch when lookups are unconfined.

Redacting credentials this build has never heard of. Before anything is written to disk or handed back to the model, token-goat redacts the credential shapes it recognises — 19 patterns covering AWS, GitHub, Slack, Stripe, OpenAI, Anthropic, Google, npm and Azure keys, private key blocks, JWTs, bearer and basic auth headers, presigned URL signatures, and credentials embedded in a URL. Two settings cover what a fixed list cannot. redaction.custom_patterns (env TOKEN_GOAT_REDACTION_CUSTOM_PATTERNS, one pattern per line) is a list of your own regular expressions, redacted as [REDACTED:custom] — for an in-house token prefix, an employee number, an internal account id. A pattern that does not compile is skipped and named by token-goat doctor rather than failing silently, because a redaction rule you believe is running and which is not is worse than none at all. redaction.strict (env TOKEN_GOAT_REDACTION_STRICT, off by default) additionally redacts long high-entropy strings that match nothing known: the shape of a credential nobody wrote a rule for. It is a heuristic, and it says so — it needs three of the four character classes and genuine randomness, which spares git SHAs and hex digests, but it will sometimes redact a base64 blob that was not a secret. Both are settings a per-project config file may not touch, so a checked-in .token-goat.toml cannot weaken redaction; an environment variable still can, which is what the Security config overrides line above exists to surface.

Cloud metadata endpoints are refused whatever your lists say. A URL pointing at a cloud instance’s metadata service — 169.254.169.254 and the whole 169.254.0.0/16 link-local range, metadata.google.internal, metadata.goog, the bare metadata short name, instance-data and instance-data.ec2.internal, 100.100.100.200, fd00:ec2::254 — is blocked before the fetch starts, and neither an allow list nor a deny list can permit it. The link-local range is also recognised when it is written as IPv6. This is not done by listing encodings: an earlier version listed three by name and there are at least three more, including one (6to4) that carries the address in a different position entirely. Instead token-goat parses the address and reads the positions an IPv4 address can sit in, so an IPv4-mapped address (::ffff:169.254.169.254, including its hexadecimal spelling), an IPv4-compatible address, either NAT64 prefix, the RFC 2765 translated form and a 6to4 address are all refused alike. The stated cost: an ordinary IPv6 address whose last four bytes happen to spell a link-local address is refused too. Nothing allocates that shape, and the alternative cost is an encoding nobody enumerated reaching the credential endpoint. The check reads the hostname you supply, so it is a check on the address the agent asked for: it does not re-run against a redirect target, and it governs the URL argument of token-goat’s own fetch path rather than every socket the machine can open. These addresses answer only from inside a cloud instance, and what they answer with is that instance’s own role credentials, which makes them the classic target for a redirected fetch. This deliberately is not a default entry in webfetch.deny: a default is something you can remove without noticing, and a config file that drifted or was copied from an older install would then quietly reopen it. localhost and private LAN addresses are deliberately not on this list — fetching your own development server is ordinary work, and blocking it would cost you something real to defend against nothing.

Turning off the Google Drive integration. token-goat gdrive-sections is the only feature that talks to Google. Set gdrive.enabled = false (env TOKEN_GOAT_GDRIVE_ENABLED) and the command refuses before it opens a connection, and the routing guidance token-goat writes into CLAUDE.md, AGENTS.md, copilot-instructions.md and the installed skill stops naming it, so an agent is never told the command exists. Nothing else in token-goat contacts Google Drive, and it holds no Drive credentials: gdrive-sections fetches the public export URL of a document id you pass it by hand.

Secret redaction in cached content. Token-goat caches command output, fetched pages, and MCP results so it can serve them back later instead of re-running the work. Anything it writes to those caches is passed through a redactor first, so a credential that appeared in output does not sit on disk in plain text and does not get replayed into a later session. This is unconditional — there is no flag to turn it on, and it applies to cached Bash and Task output (including the command string itself, which is where an inline --token=... would otherwise land), fetched web content, MCP tool results and their labels, compress-text/handoff payloads, and the raw JSON disk cache. Recognized shapes: Anthropic, OpenAI, AWS, GitHub, Slack, Stripe, npm, and Google keys; JWTs; Authorization: Bearer/Basic headers; PEM private-key blocks; presigned-url signatures (AWS X-Amz-Signature, Google Cloud Storage X-Goog-Signature, Azure SAS sig); and generic password=/secret=/api_key= assignments in .env, connection-string, and query-string shape. A match is replaced by a [REDACTED:<kind>] marker naming which pattern fired.

The same redactor covers three further sinks: the per-session ledger of failed tool calls, which records the error text a failing call returned and so can carry a key an authentication error echoed back; pr-slice’s diff, comment bodies and description; and the output of the document-extraction commands (pdf-outline, pdf-locate, the xlsx-* and pptx-* commands, and docx-outline), since a spreadsheet cell or a review comment carries a credential as readily as a fetched page does. Redaction runs before any length limit is applied, so a credential sitting near a cutoff cannot survive as an unrecognized fragment.

Session state gets the same treatment. The per-session file records which urls were fetched and which curl -o downloads landed where, and a url carries credentials as readily as output does. The fetched-url list is redacted, so the compaction manifest can still name what was fetched without naming the key; the download list is keyed by a digest of the url instead, because its only consumer is an exact-match check and a redaction there would make two urls differing only in their key look identical. The fetched-url entry also redacts the prompt that was sent with the page, and carries a digest of the pair so redaction cannot merge two entries that differ only inside the redacted span. An entry written by an older version is rewritten into this shape the first time the file is read, so upgrading clears the credentials an old file was holding rather than keeping them for the life of the session.

Two honest limits. It is a pattern matcher, not a classifier: a credential in a format it does not recognize — an internal token shape, a bare high-entropy string with no key= prefix — is cached as-is. And it protects what token-goat stores, not what your agent reads in real time; a secret printed to the terminal was already in the model’s context before any caching happened. Treat it as damage control on the cache layer, not a reason to relax about printing secrets.

screenshot target restriction. token-goat screenshot and the MCP-adjacent screenshot path only navigate to http:/https: URLs; loopback, link-local, private, unspecified, and cloud-metadata addresses (including IPv4-mapped IPv6 and NAT64-encoded forms) are refused by default. Every redirect hop and sub-resource the page pulls in is re-validated against the same policy, and the hostname is resolved and the validated address pinned into the browser’s own resolver, so DNS rebinding — a name that resolves differently between the check and the browser’s own lookup — cannot slip a private address through. This is controlled by screenshot.block_private_targets (env TOKEN_GOAT_SCREENSHOT_BLOCK_PRIVATE_TARGETS), on by default. One limit remains: cross-host sub-resources (images, scripts, frames from a different host than the page itself) are resolved and checked but not pinned, so a record that changes between the check and the browser’s own lookup could still be followed for those.

In practice: if you’re reading files from untrusted sources or fetching unknown URLs during a session, pay attention to any actions the AI takes immediately after. Unusual follow-on behavior — opening files it wasn’t asked about, writing to unexpected locations — is a sign that something in the read content may have tried to redirect it.

Windows Defender (optional, Windows only). Real-time scanning slows indexing. To exclude the data folder, open PowerShell as administrator:

Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\dfk-helper\token-goat"

0x800106ba means the prompt is not elevated; reopen as administrator. On enterprise-managed Windows (domain-joined / Intune), Defender exclusions may be locked by Group Policy. The command will fail; that is expected and harmless.

Uninstall.

token-goat uninstall

Reverses everything in What gets installed?: the hook entries in settings.json, the CLAUDE.md block, the skill directory. Add --codex, --gemini, --opencode, --pi, --hermes, --openclaw, --copilot, --grok, or --vscode to also strip those integrations. It does not stop a running worker; use token-goat worker stop for that. Nothing else on the system depends on it.

By default the data directories stay: the index took real time to build and a reinstall wants it back. Add --purge to delete them as well, which is what offboarding a machine needs:

token-goat worker stop
token-goat uninstall --purge

That removes both roots (the data directory holding the index, caches, models and logs, and the home directory holding session state and the OCR cache), naming each one and how much it reclaimed. It refuses while the worker is running, because the worker would rewrite files under a directory being deleted.