Skip to content

Policy & Safe Execution

A ps-bash policy is a small JSON document that tells the shell which commands, arguments, and paths a script is allowed to touch. When a policy is loaded, every command — whether typed at the prompt, piped through -c, or spawned by a transpiled script — is checked before it runs. A violation refuses the command with a non-zero exit and a clear message; it never silently downgrades.

The intended use cases are environments where ps-bash runs untrusted or semi-trusted shell text:

  • npm run / package.json scripts that you want to keep from drifting into rm -rf, curl | sh, or arbitrary network calls.
  • LLM agent bash tools (Claude Code, Cursor, Copilot CLI, etc.) where the model produces the command line. You want it to be able to read, search, build, and test — not delete your home directory or run claude -p recursively.
  • CI runners and sandboxes where a job should only touch its own workspace.
  • Hosted Jupyter / notebook shells that expose !command to end users.

  1. Pick a built-in profile to start from. llm-agent is a good default for AI tools:

    Terminal window
    ps-bash --policy llm-agent -c "ls && grep -r TODO src"
  2. Write a project policy when you need to customize it. Save as .ps-bash.policy.json at the repo root:

    {
    "extends": "llm-agent",
    "allowCommands": ["pnpm", "vitest"],
    "denyArgs": [
    { "command": "git", "pattern": "^push\\s+--force" }
    ],
    "writePaths": ["${workspace}/**", "${TEMP}/**"]
    }
  3. Point ps-bash at it. Any of these work, in order of precedence:

    Terminal window
    ps-bash --policy ./.ps-bash.policy.json # explicit flag (wins)
    $env:PSBASH_POLICY = './.ps-bash.policy.json'
    # otherwise: auto-discovered as .ps-bash.policy.json walking up from $PWD
  4. Verify what was loaded and what would be allowed:

    Terminal window
    ps-bash --policy-show # prints the merged effective policy
    ps-bash --policy-check 'rm -rf /' # prints the verdict without executing

Profiles are starting points. Extend them with your own allowCommands / denyArgs / writePaths.

ProfileIntentRead pathsWrite pathsNetworkEnv vars
strictRead-only inspection. Nothing mutating, nothing remote.${workspace}/**(none)deniedallowlist: PATH, HOME, USER, LANG, LC_*, TEMP only
npm-scriptBuild & test in a project. Block destructive ops outside the workspace and any agent CLIs (claude, cursor-agent, aider, gh auth).${workspace}/**, system tool dirs${workspace}/**, ${TEMP}/**allowed for known package managers onlyallowlist: build/CI vars (PATH, NODE_*, npm_*, CI, GITHUB_ACTIONS, …) — deny *_TOKEN, *_SECRET, *_KEY, AWS_*
llm-agentCoding agents. Allows the read/search/edit/build set; denies rm -rf, force pushes, credential exfil patterns, and recursive agent calls.${workspace}/**, system tool dirs${workspace}/**, ${TEMP}/**allowed; agent CLIs deniedallowlist: build/runtime vars — deny all *_TOKEN/*_SECRET/*_KEY/*_PASSWORD, cloud creds, SSH_*; blockExpansion: true
offNo policy (default — same as not passing --policy).anyanyanypassthrough

Inspect any profile:

Terminal window
ps-bash --policy-show llm-agent

A policy is a JSON object. Every field is optional; unspecified fields inherit from extends (or from off if no parent).

{
// Start from a built-in profile or another file.
"extends": "llm-agent", // profile name OR relative/absolute path
// ── Command-level rules ────────────────────────────────────────────
"allowCommands": ["pnpm", "vitest", "git"], // if set, ONLY these are allowed
"denyCommands": ["claude", "cursor-agent"], // always refused
// allowCommands wins over an inherited denyCommands for the same name;
// denyCommands wins over an inherited allowCommands.
// ── Argument-level rules (regex against the full argv after the command) ──
"denyArgs": [
{ "command": "rm", "pattern": "(^|\\s)-[a-zA-Z]*r[a-zA-Z]*\\s+/(?!tmp/)" },
{ "command": "git", "pattern": "^push\\s+--force(?!-with-lease)" },
{ "command": "curl", "pattern": "\\|\\s*(sh|bash|pwsh)\\b" },
{ "command": "*", "pattern": "\\bAWS_SECRET_ACCESS_KEY=" }
],
"allowArgs": [
{ "command": "rm", "pattern": "^-rf?\\s+\\$\\{TEMP\\}/" } // carve-out
],
// ── Path rules (glob; checked for every file operand and redirect target) ──
"readPaths": ["${workspace}/**", "${HOME}/.config/**"],
"writePaths": ["${workspace}/**", "${TEMP}/**"],
"denyPaths": ["${HOME}/.ssh/**", "${HOME}/.aws/**", "**/.env*", "**/id_rsa*"],
// ── Environment variables ──────────────────────────────────────────
// Controls which env vars a spawned command can SEE. ps-bash filters
// the child process's environment block before exec — anything not
// allowed is simply not present (not "blanked", not "redacted" — gone).
"env": {
"mode": "allowlist", // "allowlist" | "denylist" | "passthrough"
"allow": [
"PATH", "HOME", "USER", "USERNAME", "TEMP", "TMP", "LANG", "LC_*",
"NODE_ENV", "CI", "${workspace}_*" // globs supported
],
"deny": [ // applied after allow (deny wins)
"*_TOKEN", "*_SECRET", "*_KEY", "*_PASSWORD",
"AWS_*", "GITHUB_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY",
"SSH_AUTH_SOCK", "SSH_AGENT_PID"
],
// Also block reads from inside the shell itself ($env:FOO, ${FOO}, printenv FOO).
// When true, an expansion of a denied var produces the empty string and logs a
// violation; the command still runs (matching bash's "unset variable" behavior
// unless `set -u` is active).
"blockExpansion": true
},
// ── Network ────────────────────────────────────────────────────────
"network": {
"mode": "allow", // "allow" | "deny" | "hosts"
"hosts": ["registry.npmjs.org", "github.com"] // used when mode = "hosts"
},
// ── Behavior on violation ──────────────────────────────────────────
"onViolation": {
"action": "refuse", // "refuse" | "prompt" | "log"
"exitCode": 126,
"message": "Blocked by policy: {rule}. See {policyPath}."
}
}

These tokens are expanded inside *Paths globs and *Args patterns:

TokenExpands to
${workspace}The directory containing the policy file (or $PWD if loaded via env var).
${HOME}User home directory.
${TEMP}OS temp directory ($env:TEMP on Windows, /tmp on Unix).
${env:NAME}Any environment variable.

Globs use the same syntax as ps-bash’s path expansion: *, **, ?, [abc], {a,b}.

When a policy extends a parent, lists are unioned and deny* always wins over allow* for the same key. The single-valued blocks (network, onViolation) are object-merged with the child overriding the parent field-by-field.

off ◄── built-in profile ◄── extended file(s) ◄── --policy file ◄── --policy-deny CLI overrides

CLI overrides for one-off tightening (these never loosen):

Terminal window
ps-bash --policy llm-agent --policy-deny-cmd 'gh,docker'

A policy is consulted before any command runs, including commands inside pipelines, command substitutions, xargs, find -exec, and external (non-mapped) executables.

SurfaceChecked?Notes
Transpiled bash commands (grep, head, …)Command name + argv.
External executables (anything not mapped)Resolved via Get-Command; symlinks followed.
$(…) and backtick command substitutionEach inner command.
xargs cmd… / find … -exec cmd \;Each cmd invocation.
Pipelines (a | b | c)Each stage.
Redirects (>, >>, <, heredocs)✅ (path)Target path checked against writePaths / readPaths.
File operands (e.g. cat ./foo)✅ (path)Resolved to absolute path before glob match.
source / .✅ (path)Sourced file must be in readPaths; its contents are also policy-checked.
Environment-variable assignments (FOO=bar cmd)Checked against env rules; assigning a denied name refuses the command.
Environment-variable reads ($env:FOO, ${FOO}, printenv)✅ (when env.blockExpansion)Denied vars expand to "" and log a violation.
Child process environment blockFiltered before exec — denied vars are absent, not blanked.
In-process PowerShell expressionsOut of scope; do not expose a raw Invoke-Expression surface to untrusted input.

npm script that builds & tests, nothing else

Section titled “npm script that builds & tests, nothing else”

.ps-bash.policy.json:

{
"extends": "npm-script",
"allowCommands": ["pnpm", "node", "vitest", "tsc", "eslint"],
"writePaths": ["${workspace}/dist/**", "${workspace}/coverage/**", "${TEMP}/**"]
}

package.json:

{
"scripts": {
"build": "ps-bash --policy ./.ps-bash.policy.json -c 'pnpm tsc && pnpm vitest run'"
}
}

LLM agent that can read, edit, build, but cannot exfiltrate or self-invoke

Section titled “LLM agent that can read, edit, build, but cannot exfiltrate or self-invoke”
{
"extends": "llm-agent",
"denyCommands": ["claude", "cursor-agent", "aider", "gh"],
"denyArgs": [
{ "command": "*", "pattern": "(api[_-]?key|secret|token)\\s*=" },
{ "command": "curl", "pattern": "-X\\s*(POST|PUT|DELETE)" },
{ "command": "git", "pattern": "^push\\b" }
],
"denyPaths": ["${HOME}/.ssh/**", "${HOME}/.aws/**", "**/.env*", "**/*.pem"]
}

Even an “allowed” command like node or printenv can dump the parent’s environment block — that’s how leaked CI secrets usually escape. The env policy filters the child’s env before exec, so secrets are simply not present in the child process.

{
"extends": "npm-script",
"env": {
"mode": "allowlist",
"allow": ["PATH", "HOME", "USERNAME", "TEMP", "TMP", "NODE_ENV", "CI", "npm_*"],
"deny": ["*_TOKEN", "*_SECRET", "*_KEY", "*_PASSWORD", "AWS_*"],
"blockExpansion": true
}
}

Result:

$ GITHUB_TOKEN=ghp_xxx ps-bash --policy ./.ps-bash.policy.json -c 'node -e "console.log(process.env.GITHUB_TOKEN)"'
undefined # the child never received it
$ ps-bash --policy ./.ps-bash.policy.json -c 'printenv | grep TOKEN'
# empty — printenv itself sees a filtered env
$ ps-bash --policy ./.ps-bash.policy.json -c 'echo "$GITHUB_TOKEN"'
# empty + a violation logged (blockExpansion)
Terminal window
ps-bash --policy strict -c "grep -rn TODO src && wc -l src/**/*.ts"

Carve a hole into an otherwise strict policy

Section titled “Carve a hole into an otherwise strict policy”
{
"extends": "strict",
"allowCommands": ["npm"],
"allowArgs": [
{ "command": "npm", "pattern": "^(install|ci|run\\s+build|run\\s+test)\\b" }
],
"writePaths": ["${workspace}/node_modules/**", "${workspace}/dist/**"]
}

Terminal window
# What policy is active right now?
ps-bash --policy-show
# Would this command be allowed? (prints the matched rule; no execution)
ps-bash --policy-check 'rm -rf /'
# → REFUSE denyArgs[rm /^(^|\s)-[a-zA-Z]*r…/] exit 126
# Log every check (allowed and denied) to a file
$env:PSBASH_POLICY_LOG = './policy.log'

The log line format is stable and grep-friendly:

2026-05-25T12:00:01Z DENY cmd=rm argv="-rf /" rule=denyArgs[rm#0] pid=1234
2026-05-25T12:00:02Z ALLOW cmd=grep argv="-rn TODO" rule=allowCommands pid=1235

$ ps-bash --policy llm-agent -c 'rm -rf /'
ps-bash: blocked by policy
command : rm
argv : -rf /
rule : denyArgs[rm#0] (^|\s)-[a-zA-Z]*r[a-zA-Z]*\s+/(?!tmp/)
policy : ./.ps-bash.policy.json (extends llm-agent)
exit : 126

Errors are written to stderr in the same Write-BashError format as other ps-bash diagnostics, so they thread through pipelines and redirects identically.


ApproachStops rm -rf /?Stops curl … | sh?Stops writing to ~/.ssh?Stops env-var exfil?Windows?
ps-bash policy
restricted shell (rbash)partial
Container / VM✅ (if you remember to scrub env)requires Docker
Reviewing the model’s outputdependsdependsdependsdepends

The policy layer is meant to compose with the others, not replace them. Use a container for hard isolation; use ps-bash policy to keep the shell itself from helping an attacker.