Skip to content

Environment variable policy

Control which environment variables reach a tool — with two distinct protection levels depending on whether bubblewrap is installed.

By default a tool invoked through ixt inherits your full environment: AWS_SECRET_KEY, GH_TOKEN, every API key in your shell. That is fine for tools you trust. For tools installed from an unfamiliar repo, a random npm package, or a cloned project, runtime env policy lets you decide which variables the command receives when you run it through the ixt shim.

ixt tool config <target> env lets you lock that down per tool, using three orthogonal layers.

Runtime only — not install-time

Env policy applies when the installed command is launched through its ixt shim. It does not sandbox package installation (uv pip install, bun add, npm lifecycle scripts such as postinstall) and it does not sandbox ixt lifecycle hooks. If install-time code is untrusted, install in a disposable environment.

Two protection levels — read before relying on this

With bubblewrap installed (effective protection) — the shim runs the tool inside a bwrap sandbox with --unshare-pid --as-pid-1 and a private /proc. The filtered env reaches the tool, and /proc/$PPID/environ, /proc/1/environ, and the namespace-wide /proc/[0-9]*/environ scan all return the filtered env (or no data) — not the host secrets.

Without bubblewrap (hygiene only) — the shim falls back to execve(tool, filtered_env) direct. The filtered env reaches the tool, but a malicious tool can still read /proc/$PPID/environ to get the unfiltered host env from the shell that launched it, or read secret files on disk (~/.aws, ~/.netrc, ~/.ssh, …). Useful for cleaning logs/traces/naive telemetry; not a real exfiltration barrier.

The shim emits a one-shot warning per shell session in degraded mode. Silence with IXT_POLICY_QUIET=1. The CLI prints the same warning unconditionally when you call ixt tool config <target> env … and bwrap is missing — so you find out at config time, not later.


How it works

ixt normally exposes a tool as a symlink in $IXT_HOME/installed/bin/. When you set an env policy, ixt replaces that symlink with a small Python wrapper that:

  1. Filters os.environ according to your policy
  2. If bwrap is on PATH — runs the tool inside a bubblewrap sandbox with PID isolation, a private /proc, and the host filesystem mounted read-write (--bind / /). Uses --clearenv --setenv K V to hand only the filtered env to the tool. /proc/*/environ reads from inside the sandbox cannot reach host secrets.
  3. Otherwise — calls os.execve(tool, filtered_env) directly. Filtered env reaches the tool; /proc reads remain a leak surface (degraded / hygiene mode).

No daemon. The wrapper is a plain text file you can read and audit at any time; effective isolation is delegated to bubblewrap when available.

When you reset the policy (ixt tool config <target> env reset, or env base all with no allow or deny rules), ixt restores the direct symlink. Zero overhead.


The three layers

Policy resolution order — later layers narrow down the result:

Layer Subcommand Effect
base env base VALUE Starting set: everything (all), nothing (none), or a named preset (os-common)
allow env allow PATTERN Add env vars matching a glob on top of the base
deny env deny PATTERN Remove env vars matching a glob — always wins, regardless of base or allow

Effective env = (base ∪ allow) ∖ deny


Quick start

A typical workflow: start from a restrictive base, add what the tool needs, block anything sensitive.

# 1. Switch to a minimal base — only standard OS vars pass through
ixt tool config ruff env base os-common

# 2. Add the patterns the tool legitimately needs
ixt tool config ruff env allow '*RUFF*'

# 3. Hard-block a specific var even if a glob would match it
ixt tool config ruff env deny RUFF_SECRET_TOKEN

Check the result:

ixt tool config ruff env list
env policy for ruff:
  base : os-common  (HOME PATH XDG_* NO_COLOR FORCE_COLOR TERM COLORTERM LANG LC_* USER LOGNAME TMPDIR SHELL TZ)
  allow: *RUFF*
  deny : RUFF_SECRET_TOKEN

env base — the starting set

Sets the initial pool of environment variables before allow/deny filtering.

Value Starting pool Use case
all Your full os.environ (default) Trusted tool — current behaviour, no wrapper
none Empty — nothing passes unless explicitly allowed Unfamiliar tool, minimum runtime environment
os-common Standard OS and terminal variables (see below) Most formatters, linters, CLI tools

os-common expands to:

HOME  PATH  XDG_*
NO_COLOR  FORCE_COLOR  TERM  COLORTERM
LANG  LC_*
USER  LOGNAME  TMPDIR  SHELL  TZ

Start with os-common

os-common covers what ~90 % of formatters and linters actually need. Start there and add specific patterns with env allow if the tool complains.


env allow — the allow list

Adds glob patterns on top of the base. Patterns are evaluated at runtime against the current environment, so vars that don't exist yet (set later in your shell) are handled correctly.

ixt tool config ruff env allow '*RUFF*'              # any var containing RUFF
ixt tool config ruff env allow 'GIT_*' 'GH_TOKEN'   # multiple patterns at once
ixt tool config ruff env allow 'ANTHROPIC_API_KEY'  # exact name — no glob needed

Patterns follow Python's fnmatch rules: * matches any sequence, ? matches one character, [seq] matches any character in seq.

Multiple calls accumulate — each call appends to the existing allow list.


env deny — the deny list

Adds globs to the deny list. Deny always wins, regardless of base or allow.

ixt tool config ruff env deny 'RUFF_SECRET_TOKEN'        # exact name
ixt tool config ruff env deny '*SECRET*'                 # anything with SECRET
ixt tool config ruff env deny '*TOKEN*' '*KEY*'          # multiple in one call
ixt tool config ruff env deny '*TOKEN*' --except GH_TOKEN # keep one allowed token

Deny wins unconditionally

If a var matches both an allow pattern and a deny pattern, it is blocked. This is intentional — deny is your last line of defence.

Use --except when a broad deny pattern is right but one variable still has to pass. Exceptions are attached to that deny pattern and are persisted in ixt.json / ixt.toml.


Viewing the current policy

ixt tool config ruff env list

Output when a policy is active:

env policy for ruff:
  base : os-common  (HOME PATH XDG_* NO_COLOR FORCE_COLOR TERM COLORTERM LANG LC_* USER LOGNAME TMPDIR SHELL TZ)
  allow: *RUFF*  GH_TOKEN
  deny : RUFF_SECRET_TOKEN

Output when no policy is set (default):

env policy for ruff:
  base : all
  allow: (none)
  deny : (none)
  no policy — tool runs with full environment (symlink)

Degraded mode and opt-outs

The shim reads two environment variables before any policy filtering so they remain usable even with the strictest env_base="none" configuration:

Variable Effect
IXT_POLICY_QUIET=1 Silence the one-shot per-session warning printed in degraded mode. The protection level is unchanged — only the warning is hidden.
IXT_DISABLE_BWRAP=1 Force degraded mode for env-only policies even when bwrap is installed. Useful for debugging, or for tools that misbehave inside a PID namespace. fs policy still requires bwrap and errors out if combined with this var.

The runtime warning uses a marker file at $XDG_STATE_HOME/ixt/policy-warned-<sid> (where <sid> = os.getsid(0)) so it appears at most once per shell session. Delete the file to re-trigger it.


Debugging a policy — IXT_SHIM_DEBUG

Set IXT_SHIM_DEBUG=1 (or IXT_SHIM_VERBOSE=1) to make the wrapper print what it is passing and blocking before exec:

IXT_SHIM_DEBUG=1 ruff check .
[ixt shim: ruff] base='os-common' allow=['*RUFF*'] deny=['RUFF_SECRET_TOKEN']
[ixt shim: ruff] env passed (47 vars): COLORTERM HOME LANG LC_ALL PATH RUFF_CACHE_DIR TERM XDG_CONFIG_HOME …
[ixt shim: ruff] env blocked (12 vars): AWS_ACCESS_KEY_ID AWS_SECRET_KEY GH_TOKEN RUFF_SECRET_TOKEN …

Output goes to stderr so it does not interfere with stdout pipelines.


Persisting policy in ixt.toml

Declare the policy next to the tool entry so ixt tool apply replays it on a new machine:

[tools.ruff]
env_base  = "os-common"
env_allow = ["*RUFF*"]

[tools.ruff.env_deny]
"RUFF_SECRET_TOKEN" = {}
"*TOKEN*" = { except = ["GH_TOKEN"] }

Reference

ixt tool config <target> env <action> [args]
Action Arguments Description
list (default) Show the current policy
base all\|none\|os-common Set the base policy (removes wrapper when reset to all with no other rules)
allow PATTERN ... Add one or more glob patterns to the allow list
deny PATTERN ... [--except PATTERN ...] Add one or more glob patterns to the deny list (always wins over allow)
reset Clear all env policy rules and restore direct execution when no fs policy remains

Limitations

  • Linux only (for now). The Python wrapper uses os.execve, which is unavailable on Windows. On Windows, ixt keeps the direct copy (no symlink, no wrapper); ixt tool config env saves the policy to ixt.json but does not enforce it at runtime.
  • Hygiene without bubblewrap, effective with it. Without bwrap, the filtered env reaches the tool but /proc/$PPID/environ and friends remain readable — the tool can recover the host env from its parent shell. Install bwrap for an actual exfiltration barrier (PID namespace + private /proc). The shim warns once per session in degraded mode.
  • Filesystem isolation is a separate feature. This page covers environment variables only. See Filesystem policy for ixt tool config <target> fs — both policies compose inside the same shim.
  • No network isolation. Env policy does not block outbound connections. A tool can still send out any data it can read.
  • No inheritance from parent tools. Each tool's env policy is independent; injected packages share the same policy as the parent tool.