This document captures the threat model the codebase is hardened against, which controls live where, and which decisions were made deliberately so future contributors don't undo them. It is intentionally short on prescriptions and long on context.
Please email security reports to ersinkoc@gmail.com. Do not file public GitHub issues for unpatched vulnerabilities.
The agent reads instructions from three layers, and each layer has a different trust posture:
| Source | Trust | Why |
|---|---|---|
| User typing in the REPL | high | Local human operating their own machine. |
| Local config files / env vars | high | User-owned; same trust as the process itself. |
| LLM-generated tool inputs | none | Treat as adversarial. Prompt injection is real. |
Web pages fetched by fetch |
none | Anything reachable over HTTP can carry hostile content. |
| MCP server responses | low | Third-party; could be compromised or malicious. |
| File contents read by tools | low | A repo may carry hostile content (.env, plants). |
The single most important rule: anything the LLM emits as a tool input is adversarial. A prompt-injection attack can flip an otherwise-honest model into emitting a dangerous shell command or a metadata-service URL without the user noticing. The controls below reduce specific attack paths, but approval of an arbitrary shell-capable action still grants the invoking user's authority; they are defense in depth, not a sandbox.
-
bashtool (packages/tools/src/bash.ts)- Runs via the user's shell — gives the model full local execution.
- Child env is sanitized (packages/tools/src/_env.ts): allowlist (PATH, HOME, LANG, …) plus a secret-name strip
(TOKEN/SECRET/PASSWORD/AUTH/BEARER/COOKIE/PRIVATE substrings, KEY with
word boundary). Provider API keys, GitHub PATs, AWS creds never reach
the child. Override with
WRONGSTACK_BASH_ENV_PASSTHROUGH=1if you explicitly need passthrough. - POSIX process-group kill on timeout/abort so
bash -c "sleep 9999 & disown"doesn't orphan a grandchild.
-
exectool (packages/tools/src/exec.ts)- Strict allowlist (
node/npm/pnpm/git/tsc/…); no escape hatch. The previousallow_unknownflag was dropped — for arbitrary commands usebash(which is more clearly gated). cwdparameter validated to resolve insidectx.projectRoot.- Same env sanitization as
bash.
- Strict allowlist (
-
gittool (packages/tools/src/git.ts)- No raw
argsfield. Removed because it allowed-c core.sshCommand=…and--upload-pack='sh -c …'RCE. findGitDiris bounded byctx.projectRootso a non-git project doesn't walk up into an unrelated parent repo.
- No raw
-
patchtool (packages/tools/src/patch.ts)- Diff
+++targets pre-validated againstprojectRootbefore invoking GNU patch.stripforced ≥1 (rejectsstrip:0absolute-path escapes). - Temp diff file written into a
0700 mkdtempprivate directory rather than a predictable timestamp name in the user's tree. LC_ALL=Cset so applied-count detection (grep "patching file") isn't fooled by a localized GNU patch.
- Diff
-
replacetool (packages/tools/src/replace.ts)- Uses
lstatto detect symlinks andrealpathto validate the resolved target is still insideprojectRoot. Writes throughrealPath, never through the original (which could be a planted symlink).
- Uses
-
greptool (packages/tools/src/grep.ts)- Native walker skips symbolic links.
- User-supplied regex compiled through
packages/tools/src/_regex.ts — 512-char
cap and rejection of obvious super-linear constructs like
(a+)+. - Subject line capped at 64 KB before sync regex eval.
rgstdout buffer capped at 1 MB.
- HTTPS only by default.
http://requiresWRONGSTACK_FETCH_ALLOW_PRIVATE=1. - Private/loopback/multicast/CGNAT/metadata blocking via numeric
comparison (not substring regex):
- IPv4: 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16, 192.0.0/24, 224/4 (multicast), 240/4 (reserved).
- IPv6: full expansion to 8 groups; blocks ::, ::1, fc00::/7, fe80::/10,
ff00::/8, and all IPv4-mapped forms (including Node's normalized
::ffff:7f00:1form for::ffff:127.0.0.1).
- Redirect target re-validated every hop. A public host's 302 to AWS IMDS will be refused at hop 2.
- DNS pre-resolution. Hostnames are resolved via
dns.lookupand each record checked. Known limitation: this is best-effort against DNS rebinding — Node'sfetchdoes its own lookup and could in principle see a rebound IP. For a hard guarantee we'd need an undici Agent with a pinnedlookupcallback. Acceptable risk today given that the redirect re-check catches the trivial bypasses; reconsider if WrongStack is used in a hostile multi-tenant context. - Body cap 128 KB, timeout 20s, 5 redirect max.
- AES-256-GCM vault (packages/core/src/security/secret-vault.ts)
for configuration fields recognized by
isSecretField()inconfig-secrets.ts. The key file is exclusive-created with mode0o600; that mode is meaningful on POSIX, while Windows access control depends on the directory/filesystem ACL. - Per-field decrypt — one corrupted ciphertext doesn't kill boot; affected field is zeroed and logged.
- Field-name boundary — recognition is regex-based, but a JSON-key-anchored
pass now also encrypts a bare
tokenfield (and other common secret names seen at the value boundary), sohq.tokenand similar opaque fields are encrypted by this config walker. Treat the configuration file itself as sensitive even when recognized fields useenc:v1:values. - Plaintext migration on every boot for users coming from earlier versions: detects unencrypted secret-bearing keys and rewrites the config encrypted.
- Secret scrubber (packages/core/src/security/secret-scrubber.ts)
recognizes known credential shapes (Anthropic / OpenAI / GitHub / GCP /
Slack / Stripe / AWS / Twilio / JWT / mongo-postgres-mysql-redis URIs /
Bearer / generic high-entropy
*_KEY=patterns). UI, HQ, and session paths invoke it selectively; it is not an automatic boundary around every write. Chunked processing bounds work on large strings.
packages/core/src/security/permission-policy.ts
- Trust rules: per-tool allow/deny glob patterns persisted to
~/.wrongstack/trust.jsonvia atomic write. - Glob metacharacters in tool input are escaped before pattern match
— a crafted bash command
git **cannot itself act as a glob. - Per-tool
subjectKey(e.g. bash →command, fetch →url) declares which input field is the trust subject. Without this the policy heuristic could mismatch across tools — an HTTP tool whosepathmeans request-path would have been checked against filesystem trust rules. - Capability-based gating (2026-06-13): tools declare
capabilities(e.g.['fs.write'],['net.outbound']). TheAutoApprovePermissionPolicyuses these to allowlist by what a tool can do rather than by what it is called. This prevents a renamed tool from bypassing trust rules.
- Tool-call argument validation (packages/providers/src/_tool-input.ts):
every stream parser routes tool args through
parseToolInputso the result is always aRecord<string, unknown>. Provider responses withargs: null, an array, or invalid JSON are wrapped under__rawinstead of crashing the tool executor. - SSE parser (packages/providers/src/sse.ts) caps the pending-line buffer at 256 KB and normalizes CRLF incrementally to avoid O(n²) blowup.
- Pending RPC drain on child exit /
close()so callers don't hang on a dead transport. - SIGTERM → SIGKILL escalation on stuck children.
- Slot-scoped disconnect listeners — fixes a Set-keyed-by-arrow-fn bug that accumulated listeners across reconnect cycles.
- HTTP error bodies capped at 1 KB in error messages.
packages/core/src/plugin/api.ts
- Capability-based mutation authorization (2026-06-13): plugins can only
wrap or unregister tools they don't own if they declare matching capabilities
in
toolMutateCapabilities. - Official plugins bypass — first-party plugins bundled with WrongStack are trusted and can mutate any tool.
- Tool owners bypass — a plugin can always mutate its own registered tools.
- External plugins are restricted — if a tool declares
capabilities: ['fs.write'], an external plugin must list'fs.write'in itstoolMutateCapabilitiesto wrap or unregister it. No overlap = mutation denied with a clear error message. - No-capability tools are immutable — tools without a
capabilitiesarray cannot be mutated by external plugins at all. This is a safe default: legacy tools are protected until explicitly tagged.
wstack --hq starts a project-independent HTTP/WebSocket command center on
port 3499 by default. It binds 127.0.0.1 unless --host changes the bind,
accepts telemetry, persists event/snapshot/time-series data, and exposes a
control plane. Treat the entire HQ data directory and endpoint as sensitive.
Implementation: packages/cli/src/hq-server.ts,
packages/cli/src/hq-server/auth.ts, and
packages/core/src/hq/.
Current controls include separate browser/client token lists, scoped token
capabilities, optional scrypt-backed browser password login, signed HttpOnly
SameSite=Lax session cookies, protocol-version checks, a 1 MiB WebSocket
payload cap, live auth.json reload, response security headers, publisher and
server redaction, and persistent event/snapshot/time-series stores. First run
creates least-privilege browser (control.enqueue) and client
(telemetry.publish) tokens.
These controls have important boundaries:
- A missing
auth.jsonand an existing file with empty token arrays are explicit OPEN MODE. Corrupt, unreadable, or unsupported-version files now fail closed during startup; live-reload failures preserve the last-known-good auth state. Treat auth-load failures as operator-visible security faults and repair the file rather than replacing it with an empty document. - Origin validation is now strict scheme/host/port matching: requests with
no
Origin,Origin: null, orfile:origins, pluslocalhost/127.0.0.1/::1on any port, are still accepted (the browser endpoints intentionally allow the loopback range), but a different port or scheme on the loopback range is rejected. - Password login has no attempt throttling. The cookie carries a client-side
Max-Age, but the server-side session map does not expire entries bycreatedAt; a copied signed cookie remains accepted while that server process retains the session unless logout removes it. - HQ itself speaks plain HTTP/WS. Put TLS and any stronger authentication or rate limiting in a trusted reverse proxy before non-loopback exposure.
- Tokens are stored in plaintext inside
auth.json(atomic write,0o600on POSIX). Browser and client lists prevent cross-channel replay, but a stolen token grants its declared capabilities. - Redaction is pattern-based, not a proof that arbitrary secret values cannot
persist. Explicit HQ URLs default
rawContentto true unless configuration disables it; review the operator policy before connecting a remote HQ.
See HQ remote/relay deployment for operational guidance.
- Multi-tenant hostile environments are not the target. The agent runs with the invoking user's privileges and has full filesystem and network access. The threat model is "untrusted LLM output", not "untrusted operator".
- No syscall sandboxing. A sufficiently determined model+user combination can still run anything — we only raise the bar against prompt injection.
- DNS rebinding is best-effort, not airtight (see fetch notes above).
re2not pinned in. User regexes go through a heuristic ReDoS filter and length cap, not a fully safe regex engine. A determined attacker can probably craft a pattern that slips through both checks; catastrophic backtracking still hangs only one worker, not the whole process.- Session soft-deny state is in-memory only. The permission policy
tracks per-session soft-denies (
sessionDenied/sessionAllowedmaps) to let a user approve or deny once and have that decision apply for the retry loop within the same session. This state is intentionally not persisted — it is discarded on clean exit and on process crash. If the agent restarts mid-session (crash,wrongstack restart, leader election), the user may be re-prompted for decisions they already made. This is a deliberate UX trade-off to avoid polluting the persisted trust file with transient session decisions. - Tool output is trusted on the way back. A malicious file in the
repo, or a tampered MCP response, can carry prompt-injection content
that the next LLM turn might act on. The user is the last line of
defense via the
confirmpermission prompt. - HQ supports explicit open mode. See HQ command center. A missing auth file or empty token lists enable OPEN MODE; malformed, unreadable, and unsupported-version files fail closed. Password login is shipped but not throttled. Do not expose HQ directly to an untrusted network.
- Configured hooks are privileged operator code. Shell-hook validation
checks the first command token, then executes the full string with
shell: true; shell operators or arguments can therefore perform actions beyond the allowlisted token. HTTP hooks accept any HTTPS URL (plus loopback HTTP), do not apply the built-infetchtool's private-address/DNS checks, and may send hook payloads to private or public destinations. Project config strips hooks, but user-installed hook configuration must be treated as code. - Mailbox bridge tokens are bearer capabilities, not agent identities. One
bridge token authorizes every route; authenticated callers choose
from,readerId, registration ids, and message type. The server reserves the sender idhq(only HQ itself may use it) and validatesreaderId/agentIdshapes, but it does not bind the token to a sender identity or separate authorization for control mails. Bind loopback unless a trusted proxy adds identity-aware policy. - Mailbox persistence is coordination state, not an integrity log. The shared JSONL/registry files use normal filesystem creation permissions and have no record signature/hash chain. File locks reduce overlapping writes; they do not authenticate writers or make records tamper-evident.
- Some local logs intentionally retain tool data. Standard/full session
audit events and the hash-chained tool audit sidecar can include raw tool
input, output, and side-effect input.
DefaultSecretScrubberrecognizes known credential shapes inside string values but does not redact an arbitrary opaque value merely because its JSON key is namedtokenorsecret. Protect~/.wrongstack/projects/as sensitive data and configure retention accordingly. - Concurrency is not an isolation boundary.
Agent.run()now rejects a second concurrent call on the same instance, because one mutableContextis not a shareable concurrency boundary. Mailbox registry/cache coordination and TUI project switching still have known cross-process/lifecycle race windows; do not use local presence records as authoritative authentication or locking.
The following items were reviewed during the May and June 2026 security-check
audits and explicitly accepted as non-blocking:
-
Postinstall git-hooks setup (
"postinstall": "git config core.hooksPath .githooks"in root package.json):- This only affects developers who clone the repo. It is not a runtime security boundary.
- Listed as "maintainer call / won't fix" in both audits. Changing it would harm contributor experience with no meaningful security gain for end users.
-
Some remaining name-string + denylist authorization checks (e.g.
AutoApprovePermissionPolicy.DENYand parts of plugin tool mutation rules):- These were pragmatic and effective, but have now been superseded by explicit capability allowlists (see Capability-based gating above and
docs/plans/security-hardening-2026-06.mdP1). - The old denylist checks remain as defense-in-depth but are no longer the primary control.
- These were pragmatic and effective, but have now been superseded by explicit capability allowlists (see Capability-based gating above and
-
Install-script allowlist maintenance:
pnpm-workspace.yamlcurrently allows lifecycle builds for@biomejs/biome,better-sqlite3,electron,esbuild, andnode-pty. Any addition requires security review; the native/runtime download rationale is documented beside each entry.
Future scans should treat the above as known and accepted rather than new findings.
The two rules that keep things safe:
-
Adversarial in, friendly out. Validate every value that originated from the LLM, the network, or a third-party MCP server. Friendly internal callers don't need validation.
-
Match the scope of authorization to the scope of action. A
trustentry forbash:git statusshould not auto-allowbash:rm -rf /. The policy escapes glob metacharacters and usessubjectKeyto enforce this; new tools should declaresubjectKeyrather than rely on the policy's fallback heuristic.
The earlier phased HQ plan is retained in
docs/plans/hq-command-center-2026-06.md
as design history. Current source has browser/client tokens, password bootstrap
via wstack --hq --password <value>, live auth reload, capability scopes, and
persistent event/snapshot/time-series storage. Token lifecycle commands are:
wstack hq token create [label]
wstack hq token create --client [label]
wstack hq token list [--client]
wstack hq token revoke [--client] <id-prefix>There is no wstack hq auth set-password or auth reset subcommand. Password
bootstrap is applied when creating a previously missing auth.json; manage an
existing password-auth file with deliberate operator procedures and restart as
needed. The security limitations and supported deployment posture are described
in HQ command center, above.
- CHANGELOG.md — security-relevant changes by version
- README.md — usage and configuration
- docs/plans/hq-command-center-2026-06.md — HQ command center architecture and phased plan (Access Control section)
- docs/subcommands/hq.md —
wstack --hquser command reference (flags, routes, env vars, deployment)