Security model

Threat model

wraptool treats the whole workspace as untrusted. The adversary is not just the AI assistant (or its operator) but everything the assistant sets in motion: the build, test, and dependency code its tool calls trigger. Prompt injection can steer the assistant; a malicious postinstall hook or a compromised transitive dependency pulled in during a routine version bump runs drive-by, with the assistant’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 assistant 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 whitelisted 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 AI 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 AI 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 AI:

  • Bearer tokens (Authorization: Bearer ...)
  • API keys with common prefixes (sk-..., pk_..., AWS access key ID format)
  • AWS secret access keys
  • SSH key block markers (-----BEGIN ... PRIVATE KEY-----)
  • Any value listed in the tool’s resolved env

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

sse and unix transports support auth_token_file. Requests without a matching Authorization: Bearer ... header are rejected with 401 (no header) or 403 (bad token), using constant-time comparison.

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 AI at wraptool_request_capability. The AI submits a rationale; an operator reviews and approves out-of-band. This makes the policy evolvable without giving the AI 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.
  • 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.
  • Network auth is optional in validation. A TCP listener without auth_token_file is unauthenticated. 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 assistant, 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 whitelisted subcommands 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 AI assistant 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