Security model

Threat model

wraptool treats the whole workspace as untrusted. The adversary is not just the agent (or its operator) but everything the agent sets in motion: the build, test, and dependency code its tool calls trigger. Prompt injection can steer the agent; a malicious postinstall hook or a compromised transitive dependency pulled in during a routine version bump runs drive-by, with the agent’s reach, the moment you build or test. Any of them may:

  • Probe for any reachable secret in its process environment.
  • Send arbitrary tool calls hoping one slips through the policy.
  • Stuff shell metacharacters into arguments to escape command parsing.
  • Exfiltrate data via stdout/stderr.

wraptool’s job is to make the set of reachable host capabilities finite, audited, and credential-free from the workspace’s point of view. Run under wraptool up’s throwaway container (see Deployment patterns) this bounds the blast radius for both the agent and the code it triggers.

Scope — what wraptool does and does not protect

Be honest about the boundary. The container has network access and the full source tree, and in-container code can read the .mcp.json config and its bearer token — so it can invoke the same policy-allowed host tools the agent can. wraptool therefore:

  • Protects host credentials — SSH keys, gcloud, kubeconfig, and the rest of the host environment never enter the container or the agent context.
  • Bounds which operations run — privileged/remote actions go through constrained CLI subcommands or explicitly named tools from local MCP servers. MCP arguments pass through unchanged, so that policy is intentionally coarser.

It does not protect source confidentiality or network egress, and a compromised dependency inherits the agent’s tool access. The intended design is to keep raw host credential files out of the workspace and mediate host operations through policy — not to make the workspace harmless. Resources deliberately shared with the container, including the MCP token and harness-pool state, are outside that confidentiality boundary. The known limitations below further qualify policy and scrubbing.

On the Guix runtime, two resources intentionally cross project/container boundaries for developer convenience: --network exposes host localhost services, and the writable harness-pool HOME shares harness authentication, configuration, and skills across projects. Neither is a security boundary. Host CLI credentials such as SSH keys, kubeconfig, and cloud credentials remain outside the container. See Guix: convenience versus isolation.

Trust boundary

flowchart LR
  subgraph untrusted ["Untrusted"]
    ai["AI assistant<br/>+ harness"]
  end
  subgraph trusted ["Trusted host"]
    wt["wraptool<br/>process"]
    creds[("Credentials<br/>SSH / gcloud / kubeconfig")]
    cli["CLI binaries"]
    upstream["Local stdio<br/>MCP servers"]
  end
  ai <-- "MCP / SSE" --> wt
  wt --- creds
  wt --> cli
  wt --> upstream

  style untrusted fill:#fee,stroke:#E60028
  style trusted   fill:#efe,stroke:#2a7

The MCP transport (SSE / Unix socket) is the only path across the boundary. The agent sees:

  • The schema of allowed CLI tools and allowed upstream MCP tools.
  • CLI stdout/stderr and MCP text results, after heuristic scrubbing.
  • Structured error messages on denial.

The MCP response does not directly include the wraptool config or child environment. Wrapped tools can still print environment values, filesystem paths, or file contents available to their host process, so policy and tool-specific hardening remain part of the boundary.

Defenses

No shell execution for CLI tools

Every command runs via exec.CommandContext with an explicit argument array. There is no sh -c, bash -c, or string template that an attacker could inject into.

Argument sanitization

Every argument is rejected if it contains a shell metacharacter — the full set is ;|&$`(){}\, <, >, and newline/carriage-return. There is no shell to inject into (see above); this is a defence-in-depth layer.

Two further guards close off option injection, where a value is smuggled in as its own argument token:

  • Positional arguments must not start with -, which would otherwise be parsed as an unintended flag.
  • Flag values must not start with - either (SanitizeFlagValues). Each flag is emitted as two argv tokens — flag then value — so a valueless flag handed, say, --upload-pack=/tmp/x would land as its own option token and bypass the flag allowlist entirely. Rejecting leading-- values closes that path (the F2 finding).

Credential scrubbing

Output is filtered before being returned to the agent. This is heuristic, defense-in-depth redaction, not a security boundary:

  • Key-name matching. Any field named token, secret, password, passwd, credential, or ending in key — in shell (key=..., key: ...) or JSON ("key": ...) notation — has its value replaced, regardless of what the value looks like.
  • A handful of value-shape patterns with no key name attached: Authorization: Bearer ... / Basic ... headers, AWS access key IDs (AKIA...), SSH private key block markers (-----BEGIN ... PRIVATE KEY-----), GitLab token prefixes, and environment-style assignments whose name contains API_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, or AUTH — an ALL_CAPS name outside that list (MY_PAT=..., DATABASE_URL=...) passes through.
  • Any value listed in the tool’s resolved env.

There is no pattern for API-key prefixes like sk-... or pk_...: a bare secret with no recognizable key name attached — for example a standalone sk-ant-... value with nothing labeling it — passes through unredacted.

False negatives are still possible — never put unscrubbable secrets in stdout of a wrapped command.

Output limits

stdout and stderr are each capped independently at 1 MiB per CLI call. Excess is dropped with a marker. Proxied MCP-server output does not currently have the same cap.

Timeout enforcement

Every call uses context.WithTimeout based on the tool’s timeout (default 30s). Hung processes are killed.

Minimal child environment

Child processes get HOME, PATH, and LANG plus the configured env / env_files. HOME and PATH are inherited from wraptool’s own environment; no other parent variable is copied. Host HOME can expose normal user-global configuration and credential files to the wrapped process. Override it and tool-specific config paths when the process requires a dedicated profile.

PATH is the tool binary’s own directory prepended to the server’s inherited PATH (os.Getenv("PATH"), falling back to /usr/bin:/bin only when it is unset). Inheriting the full server PATH is by design: a tool must find its genuine runtime dependencies — e.g. git shelling out to ssh, which on a store-based distro like Guix lives in a separate prefix, not beside the git binary. Prepending the binary’s own directory additionally resolves co-located helpers — e.g. kubectl invoking its kubeconfig exec credential plugin gke-gcloud-auth-plugin. PATH names executables, not secrets; inheriting it restores real dependencies without exposing any credential.

Auth on network transports

Both network transports read their token from auth_token_file, and requests without a matching Authorization: Bearer ... header are rejected with 401 (no header) or 403 (bad token), using constant-time comparison. On sse that token is unconditional; on unix, whose filesystem socket is already restricted to mode 0600, it stays optional.

When mcp.auth_token_file is unset, sse generates a token at ~/.config/wraptool/mcp-token (mode 0600; the Linux path — see platform paths for the other platforms), logs where it went, and requires it — a first run still comes up, but never unauthenticated. wraptool up provisions the same token for both container runtimes, and wraptool up and wraptool init both write it into the harness configuration they generate.

A loopback bind is not a boundary against the browser, which is why the token is unconditional there. The MCP library serves the event stream with Access-Control-Allow-Origin: *, so a page the developer merely visits can open an EventSource to 127.0.0.1, read the session endpoint, and POST tool calls as a CORS simple request that triggers no preflight. The sse listener therefore also checks two headers that a browser sets and a page cannot forge, on both /sse and /mcp:

  • An Origin naming anything other than localhost, 127.0.0.1, or ::1 is rejected with 403. null — a sandboxed frame or a file:// page — is rejected too. Those three names are accepted on any port, so a page served by a local development server clears this check; the token, which such a page cannot read cross-origin, is what stops it.
  • A Host naming neither the configured bind nor a loopback name is rejected with 403, which mitigates DNS rebinding: the browser resolves an attacker-controlled name to loopback but still sends that name. A wildcard bind (0.0.0.0, used for a Dev Container’s bridge gateway) is reached under an address the server cannot predict, so the Host check does not apply there. The check compares names literally: a specific-address bind reached through a DNS alias (mcp.listen: 172.18.0.1 dialed as host.docker.internal) is rejected — point the client at the bound address itself.

These checks survive a leaked token. They do not apply to unix, which no browser can reach.

ImportantUpgrade note

A client configured against a tokenless listener now receives 401. Re-run wraptool init for that harness — or wraptool up, which rewrites the harness configuration itself — so the generated configuration carries the Authorization header.

Hot-reload safety

In-flight commands keep using the policy they started with. The swap is atomic; partially-applied policy is never observable.

Help flags are always available

-h and --help are seeded into every allow rule at config load (config.SeedHelpFlags), so they are permitted and introspectable on any allowed subcommand. They are handled as ordinary allowed flags, not a policy special-case: the policy engine and the MCP schema builder both just read the resulting flags list. An operator can revoke them per-rule via deny_flags like any other flag.

Capability requests

Denied commands return a structured error pointing the agent at wraptool_request_capability. The agent submits a rationale; an operator reviews and approves out-of-band. This makes the policy evolvable without giving the agent a path to bypass it.

Known limitations

  • The MCP token is capability-equivalent to the harness. Any process in the workspace that can read it can invoke all tools exposed on that listener.
  • CWD containment is optional. Client-supplied ?cwd= is unrestricted when mcp.allowed_cwd_roots is empty. Containment checks also cannot eliminate all filesystem race conditions.
  • Positionals are count-only. With no arg_constraints, they are unlimited; there is no positional path, URL, or regular-expression constraint.
  • Help flags carry a value. -h/--help are seeded into every allow rule with no constraint entry, and an unconstrained flag’s value is emitted into argv as-is — so {"help":"somefile"} smuggles one extra token past positional_max. Flag values may not start with -, so this is a bounded smuggle, not flag injection.
  • Schema validation is not an enforcement boundary. The server does not enable MCP input-schema validation. Current boolean and empty-string handling has known argument-shape ambiguities; keep policy narrow and avoid assuming declared JSON types alone reject malformed calls.
  • Scrubbing is heuristic. It can miss unknown, encoded, split, short, or structured secrets. Proxied MCP non-text content is not scrubbed.
  • Introspection is trusted-host activity. Help/version probes currently use the server environment and their raw output is cached.
  • The Origin and Host checks bound the browser only. An sse listener is authenticated by default and refuses cross-origin and rebound requests, but only a browser is obliged to send those headers honestly; any other local process can set them freely, so the token file’s permissions remain the barrier there. HTTP body and server timeout hardening is limited; bind narrowly and use another network boundary when exposed.
  • Upstream MCP filtering is name-only. Allowed tool arguments pass through unchanged, and mcp_servers.*.working_dir is not currently applied.
  • Shared harness HOME persists across projects. One project can alter state observed by later projects using the same pool.

These are current implementation limits, not planned guarantees. Track changes through releases and repository history.

Deployment patterns

The recommended default is wraptool up: from any git repo it brings up a throwaway container per project — a native Guix container built from your manifest.scm when Guix is available, otherwise a Dev Container — wired to the wraptool server on the host over an auth_token. The agent, your build scripts, and their dependencies all run inside with no host credentials; privileged and remote actions (git push, gcloud, kubectl) go back through wraptool’s subcommands the policy allows on the host. Per the scope above, the container still has network and the source tree, so this bounds credentials and policy, not confidentiality or egress.

More generally, run the agent in a container or VM without credentials, with wraptool on the host or a separate container that holds the secrets. See isolated environments for Guix, Docker, Podman, and systemd recipes.

Reporting

The project does not currently publish a private security-reporting channel. Do not place credentials, exploit details, or other sensitive material in a public issue. This is an unresolved project gap, tracked alongside other work in the issue tracker.

Back to top