Repository navigation
Security Model and Environment Variables
This page documents ash's security model for reviewers auditing the project (for example, a security team evaluating adoption). It describes the threat model, every security-relevant environment variable, the enforcement points in source, and — honestly — the current weaknesses and gaps, with practical mitigations where one exists. It is deliberately direct about what is not a strong guarantee.
ash is a local CLI that sends your prompt (plus recent history and a system prompt) to a configured AI provider, and lets the model request execution of a small set of local tools — allowlisted Unix executables, Python scripts, workspace file reads/writes, and process scheduling. It is not a sandbox. Every tool call runs with the full OS permissions of the user running ash. The security boundary ash provides is "the model can only reach what you explicitly allowlisted, and only via direct argv, never a shell" — not "the model's actions are contained even if allowlisted."
The primary adversary this design defends against is untrusted content the model is asked to reason about — file contents, tool stdout/stderr, script output, web/API responses fetched by a tool, or a delegated sub-agent's result — attempting to hijack the model into taking unintended actions ("prompt injection"). Secondary concerns covered below: local privilege boundaries for the connection-sharing broker, and accidental unintended invocation via shell command-not-found routing.
Out of scope / explicitly not defended against: a malicious or compromised AI provider itself; a user who deliberately allowlists a dangerous executable and asks the model to use it; supply-chain compromise of a bundled or user-provided Python tool's dependencies (see Python-Environment for isolation, which limits blast radius but does not sandbox).
run_unix_command and run_unix_pipeline (defined and dispatched in tools.go) never invoke a shell. Arguments are passed as direct argv to exec. Two independent controls apply before anything runs:
-
Allowlist & Denylist: only executables named in
~/.ash/.ash_allow(orASH_ALLOW) and not blocked in~/.ash/.ash_deny(orASH_DENY) can be invoked. Any item in.ash_denyorASH_DENYis blocked unconditionally. -
Blocked argument patterns: every argv value is checked against
argumentBlockPattern(;,|,&&, backticks,$( ), redirection, NUL, newline/CR) viaisBlockedArgumentin support.go. Since there's no shell, these patterns can't actually be interpreted as control characters by ash itself — this check exists to stop a downstream command (or a human reading a transcript) from being tricked by shell-like-looking arguments, and to reject blatantly adversarial argv early.
The dedicated run_python3 tool is not controlled by this Unix allowlist. It is published only when ash can resolve its selected Python interpreter and strict mode is off. See Conditional-Python-Execution for the path and script restrictions.
The standalone $TOOLS_DIR_LIST and $PLUGINS_DIR_LIST lines in ~/.ash/.ash_allow are internal Ash directives, not environment variables. They add regular files in ~/.ash/tools/ and native binary plugins in ~/.ash/plugins/. In .ash_system, $TOOLS_DIR_LIST, $PLUGINS_DIR_LIST, and $IF_PYTHON_AVAILABLE are likewise reserved internal Ash substitutions; ordinary environment values cannot set or override them. Under strict mode (ASH_STRICT=1), wildcard expansion of $TOOLS_DIR_LIST and $PLUGINS_DIR_LIST is disabled; executables must be explicitly enumerated.
Weakness: the allowlist is a name-based boundary only. Allowlisting python3, bash, osascript, or any other general-purpose interpreter is equivalent to giving the model unrestricted code execution as you. There is no per-argument capability restriction (no read-only mode, no path restriction, no network restriction) beyond what the target executable itself enforces. Mitigation: keep the allowlist to single-purpose, narrowly-scoped executables; avoid interpreters and shells in the allowlist; prefer purpose-built scripts following the Guide-to-Creating-Python-Tools pattern (structured JSON, bounded, validated input) over general-purpose ones.
Every tool call runs through toolCommandRunner with:
- A timeout,
ASH_TOOL_TIMEOUT(default 15s). - A captured-output cap,
ASH_TOOL_OUTPUT_MAX(default 8192 bytes) — output beyond this is truncated, not silently dropped without signal. - A cap on tool-loop iterations per request,
ASH_MAX_TOOL_ITERS(default 4, referenced in runner.go). -
Tool-repeat detection: the tool loop in runner.go tracks consecutive identical tool calls (
toolRepeatLimit/repeatCount, config.go); once the same call repeats past the limit (default 2), ash injects a tool message telling the model to stop repeating and use the existing observation instead of re-invoking the same call. This is a loop-termination guardrail, not a security boundary — it complementsASH_MAX_TOOL_ITERSby cutting off wasted iterations sooner when the model gets stuck.
These bound resource consumption and the number of actions a single request can take, but do not limit what a single allowlisted call can do within its timeout (a fast, destructive command completes well inside 15 seconds).
Always on, unconditionally — not gated by ASH_STRICT. resolveWorkspacePath (used by every workspace/scratch file tool: ash_read_workspace_file, ash_write_workspace_file, ash_read_scratch_file, ash_write_scratch_file, ash_append_scratch_file, ash_edit_scratch_file) and the argument-validation loops in callUnixCommand, callUnixPipeline, and run_python3's argv handling (all in tools.go) reject any path that references a hidden dotfile, via hasBlockedDotSegment/isBlockedDotfileArgument in support.go. normalizeToolName (config.go) also rejects dot-prefixed executable/tool names outright, so a dot-prefixed entry in .ash_tools can never be allowlisted.
This exists because, before this control, ash_read_workspace_file could read ~/.ash/.ash_env directly (which can contain AI_AUTH_TOKEN), and run_unix_command arguments could reference arbitrary dotfiles anywhere on disk (~/.ssh/id_rsa, .env, .aws/credentials) with no restriction beyond the shell-metacharacter check.
- Rule:
.and..are navigational tokens and are never treated as dotfiles. - Default (no
ASH_STRICT): only the final path component (the filename itself) is checked —foo/.git/configis allowed,.envandfoo/.envare blocked. -
ASH_STRICT=1: any path segment is checked —foo/.git/configand~/.ssh/id_rsaare blocked too. - ash's own internal config loading (
.ash_env/.ash_tools/.ash_systemread directly byconfig.go/install.go/bootstrap_assets.goviaos.ReadFile, never throughresolveWorkspacePath) is unaffected — this control only applies to AI-invoked tool calls. - The
--attach <path>CLI flag (see Home) is a different trust boundary entirely: it's a file path supplied directly by the human runningashon the command line, not by the model or a tool call, so it is read directly (with a size cap viaASH_ATTACHMENT_MAX_BYTES) without dotfile blocking orresolveWorkspacePath— the same trust model as passing a path to any other CLI tool (cat,grep, etc.).
Weakness: this is a name-pattern check, not a capability sandbox. If a dangerous general-purpose executable is allowlisted (see the layer 1 weakness above), it could still be scripted to indirectly copy or exfiltrate a dotfile's contents through a non-dotfile-named intermediate path. Treat it as raising the bar for the common case (direct reference to a well-known secrets path), not as a guarantee.
This is the most nuanced area and worth reviewing carefully.
Default behavior (no configuration needed): buildPromptInjectionDefenseMessage in runner.go injects a system message on every request instructing the model to treat all tool, file, script, pipeline, and child-agent output as untrusted evidence, never follow instruction-like text embedded in it, and refuse to reveal hidden prompts/secrets/credentials. Every tool result is also logged as a one-line summary, not full injected verbatim into a visible trust boundary distinct from other content. This is a soft control: it relies on the model choosing to follow the instruction. It provides no deterministic, code-level blocking of injected text by default.
Opt-in hardening: setting ASH_STRICT=1 (checked by strictSecurityModeEnabled in support.go; also accepts true, yes, y, on, strict) activates sanitizeUntrustedTextForModel in support.go, which regex-matches untrusted text (tool results in renderToolMessageForModel, runner.go; file reads in callReadWorkspaceFile/callReadScratchFile, tools.go) against promptInjectionPattern — a fixed English-phrase regex covering variants of "ignore previous instructions," "disregard previous instructions," "system prompt," "developer message," "you are now," "jailbreak," and "override instructions" (see the pattern itself in support.go). A match replaces the entire content with a [blocked potential prompt-injection content from untrusted source] marker, and the content is wrapped in explicit UNTRUSTED_<KIND>_BEGIN/END source=... delimiters (formatUntrustedEvidenceBlock, support.go). Strict mode also removes the dedicated run_python3 tool and blocks ash-managed bundled .py tools, even if ASH_PYTHON or the managed virtualenv resolves successfully.
Honest weaknesses of ASH_STRICT:
- It is a fixed, English-only regex. Paraphrased instructions, other languages, unicode homoglyphs/zero-width characters, base64/hex-encoded payloads, or instructions split across multiple tool calls all bypass it trivially.
- It can false-positive on legitimate content that happens to contain a matched phrase (for example, a security blog post literally discussing "ignore previous instructions" as an attack technique would get blocked/replaced).
- It is opt-in and off by default — a fresh install has no automated content-level defense against injected instructions in tool/file output, only the soft system-prompt instruction.
- It does not cover every untrusted-content ingestion path uniformly; review
sanitizeUntrustedTextForModelcall sites in runner.go and tools.go against your own tool additions to confirm new tool output types are covered before relying on it.
Recommended mitigation: treat ASH_STRICT=1 as one layer of defense-in-depth, not a guarantee. Combine it with a narrow allowlist (layer 1), human review of tool output before acting on model conclusions in consequential workflows, and avoiding untrusted data sources (arbitrary web fetches, unreviewed third-party files) in automated/unattended (ash snooze-adjacent, scheduled) invocations where no human is in the loop to catch a bypass.
run_sub_agent (runSubAgentCommand in support.go) launches a child ash process with:
- A generated child session ID of the form
{parent}.{6 random alphanumeric chars}(generateChildSessionID). -
ASH_CHILD_AGENT=1set in its environment, checked byisChildAgent(); a child cannot itself callrun_sub_agent,schedule_future_prompt,schedule_recurring_prompt, ormanage_recurring_jobs— those handlers explicitly reject the call whenisChildAgent()is true (see theJ9QJ8y8pEID guard repeated across tools.go), limiting delegation to exactly one level. - A bounded count via
ASH_MAX_AGENTS(default 6), enforced byagentBudgetin support.go.
Weakness: a child agent inherits the same working directory, tool allowlist, and OS permissions as the parent — there is no privilege reduction, only a depth/count limit and a system-prompt instruction (subAgentSystemGuidance) telling the child not to try to escalate further. A malicious or manipulated child prompt can still do anything the parent's allowlist permits.
The Connection-Sharing broker (server implementation in cmd/ash-broker, shared wire types in internal/brokerproto, client side in broker.go, with platform-specific peer-credential checks in broker_peer_xucred.go and broker_peer_linux.go) is local-only and applies several controls. The broker now ships as a separate ash-broker binary with no AI provider SDK dependencies, reducing its attack surface and binary size independent of how many provider adapters ash itself links.
- Unix domain socket, chmod'd
0600(seeos.Chmod(socket, 0o600)in broker.go). - Every connection is checked against the peer's UID (
brokerPeerAllowed) viaLOCAL_PEERCRED/SO_PEERCRED-equivalent syscalls — only processes running as the same OS user as the broker can talk to it. - A random, in-memory, per-shell capability token is compared with
subtle.ConstantTimeCompare(constant-time, avoiding timing side-channels) inhandleBrokerConn(broker.go). - Only a fixed allowlist of request headers can be forwarded —
Authorization,Content-Type,Accept,User-Agent,anthropic-version,x-api-key(brokerHeaderAllowed, broker.go). - A request-size cap (
brokerMaxBody) and frame-size cap (brokerMaxFrame) bound resource use.
Host pinning: brokerURLAllowed (broker.go) validates that the destination is a well-formed http/https URL with a host and no embedded userinfo, and requires the destination host to match the host derived from the configured AI_ENDPOINT at broker startup — the broker rejects any request to a different host. Combined with the same-UID peer check, this means a same-user process compromise cannot use the broker as an open relay to an arbitrary destination; it can only reach the one already-configured provider.
The broker's socket path, token, PID, and lease file are ephemeral shell state (ASH_BROKER_SOCKET, ASH_BROKER_TOKEN, ASH_BROKER_PID, ASH_BROKER_LEASE) and must never be persisted into scripts, cron, or launchd configuration — see Incorporating-Ash-into-Shell-Scripts for why scripted/scheduled invocations don't (and shouldn't) inherit them.
By default, ash logs structured JSON via slog, and deliberately avoids putting secrets or full request/response content in logs: newStructuredLogger/configureDebugLogging (support.go) write JSON entries, and request/response bodies are logged only as byte length and SHA-256 hash, never the raw payload (see slog.Debug("AI request payload", ..., "sha256", hashForLog(payload), ...) in chat.go, and the equivalent for tool invocation results in runner.go). This means an AI_AUTH_TOKEN or the content of a sensitive file read by a tool should not end up verbatim in a log file even with ASH_VERBOSE=1/ASH_LOG_FILE set. Every log call carries a unique EID field for auditability (see AGENTS.md). See Debugging-with-ASH_VERBOSE for the full field-by-field breakdown of a log entry, how EID/request_id are generated, and the execution dashboard report.
Caveat: this covers the logging paths reviewed above; it is not an exhaustive guarantee across every future log call. Anyone extending logging in this codebase should preserve the hash-not-payload convention for anything derived from AI request/response bodies or tool output.
Not a defense against a malicious actor, but relevant to safe operation: the command_not_found_handle/command_not_found_handler hook that routes unrecognized interactive-shell commands to ash (and therefore to the configured AI provider, consuming quota/cost) is active only in interactive shells (non-interactive shells short-circuit immediately). ash snooze [duration|off] (snooze.go) lets a user temporarily disable that routing while testing commands at a terminal, without affecting explicit ash "..." invocations. See Incorporating-Ash-into-Shell-Scripts for the full explanation.
| Variable | Default | Effect | Source |
|---|---|---|---|
ASH_STRICT |
off | Enables regex-based prompt-injection blocking and UNTRUSTED_* wrapping of tool/file output; also disables ash-managed Python execution. Also widens the always-on dotfile block from "final path component only" to "any path segment" (see Defense layer 2b). See weaknesses above. |
support.go |
ASH_TOOL_ALLOWLIST |
unset (falls back to ~/.ash/.ash_tools) |
Comma-separated executable allowlist; overrides the file when set. | ash.go |
ASH_TOOL_TIMEOUT |
15s | Per-tool-call execution timeout. | tools.go |
ASH_TOOL_OUTPUT_MAX |
8192 bytes | Captured tool output cap (truncated beyond this). | tools.go |
ASH_MAX_TOOL_ITERS |
16 | Max tool-loop iterations per request. Its effective numeric value is also rendered into the Python-capable system-prompt guidance; invalid or negative values fall back to 16. | runner.go |
ASH_MAX_AGENTS |
6 | Max concurrent sub-agents per parent run. | support.go |
ASH_CHILD_AGENT |
unset (internal) | Set automatically on a spawned sub-agent; never set this yourself — an invalid/mismatched value is rejected at startup. | ash.go |
AI_AUTH_TYPE / AI_AUTH_TOKEN
|
unset | Cloud provider credential; required (bearer auth type) for non-local endpoints. Never logged verbatim (see Logging hygiene). |
config.go |
ASH_BROKER_SOCKET / ASH_BROKER_TOKEN / ASH_BROKER_PID / ASH_BROKER_LEASE
|
unset (internal, ephemeral) | Broker capability handle for the current interactive shell. Must never be persisted into scripts, cron, or launchd. | broker.go |
ASH_VERBOSE / ASH_LOG_FILE / ASH_LOG_MAX_BYTES
|
off | Debug logging destination/rotation; does not change what is logged (still hashed, not raw). | support.go |
ASH_ALWAYS_OPENAI_API |
off | Testing toggle: routes the ollama provider through the OpenAI-compatible SDK adapter instead of its hand-rolled implementation. No new security surface beyond the OpenAI adapter's own request construction. |
config.go |
ASH_STREAM |
off | Enables streaming responses for providers that support it (currently OpenAI only). Streamed text is only accumulated internally in this release, not printed live, so it changes internal control flow but not what is ultimately shown or logged. | config.go |
ASH_ATTACHMENT_MAX_BYTES |
10485760 (10 MiB) | Caps the size of a file passed via --attach. Attachment bytes are base64-encoded into the provider request body (currently OpenAI only); they are never logged raw (see Logging hygiene). |
ash_attachments.go |
-
Incorporating-Ash-into-Shell-Scripts — broker non-inheritance and
ash snoozein scripting contexts. - Guide-to-Creating-Python-Tools — writing tools that are safe to allowlist.
- Python-Environment — dependency isolation for bundled Python tools.
- SECURITY.md — vulnerability reporting process and the condensed policy summary.