Skip to content

Incorporating Ash into Shell Scripts

Jon edited this page Aug 30, 2026 · 2 revisions

Purpose

ash is a normal CLI binary, so it composes with shell scripts, Makefile recipes, and other tools' subprocess calls the same way any other command-line program does: pipe a prompt into it, or pass it as arguments, capture stdout, check the exit code. This page walks through two real examples from this repository — release notes generation in Makefile and scripts/release/repair_release_notes.py — and explains the behavior you need to understand to script against ash safely.

Example 1: make release-notes (piped stdin)

The release-notes target builds a prompt containing instructions plus the raw git log output, pipes it into the built ash binary, and captures stdout to a file:

{ \
  printf '%s\n\n' 'You are preparing release notes for ASH $(RELEASE_VERSION).'; \
  printf '%s\n' 'Return only concise, friendly, user-facing Markdown. ...'; \
  printf '%s\n' "$$git_log"; \
} | NO_COLOR=1 "$(RELEASE_OUTPUT_DIR)/$(APP_NAME)" > "$$tmp_path"

Points worth noting:

  • NO_COLOR=1 disables ANSI styling in the output (checked directly via os.Getenv("NO_COLOR") in support.go), so the captured Markdown is clean for writing to $$tmp_path and later dist/release/release-notes.md.
  • The instructions explicitly tell the AI to treat the commit history as untrusted reference data, not instructions — the same defensive framing ash itself uses for tool output (see Guide-to-Creating-Python-Tools and the "Usage guidance" pattern), applied here at the prompt-authoring level since a hostile commit message could otherwise try to inject instructions into the generated notes.
  • The recipe retries up to RELEASE_NOTES_ATTEMPTS times, validating with grep that the required ## Features / ## Fixes & Improvements sections are present in the output before accepting it, and fails the build with diagnostic output if the AI never produces a compliant result.

Example 2: repair_release_notes.py (subprocess with captured stdin/stdout)

scripts/release/repair_release_notes.py calls ash the same way from Python, via subprocess.run with input= (not a shell string), so there is no shell interpolation risk on either side:

def run_command(args, input_text=None):
    result = subprocess.run(args, check=True, input=input_text, capture_output=True, text=True)
    return result.stdout

generate_release_notes builds the same kind of prompt (release_note_prompt) and pipes it into whatever command --ash-command/ASH_RELEASE_NOTES_COMMAND resolves to (default: ash), then validates the same required headings before trusting the output. This is a good template for any script that wants to call ash non-interactively: build the full prompt as one string, pass it via stdin, capture and validate stdout, and treat a non-zero exit code or missing expected content as failure.

How ash tells piped input apart from argument input

ash's entrypoint (run in ash.go) decides where the prompt comes from like this:

  1. If you pass arguments (ash summarize this repo), they're joined with spaces and used as the prompt directly — stdin is ignored entirely.
  2. If you pass no arguments, ash checks whether stdin is an interactive terminal (isInteractiveStdin in support.go, which stats os.Stdin for ModeCharDevice):
    • If stdin is a terminal and there are no args, ash prints usage and exits 1 — it never blocks waiting for interactive typing with no prompt.
    • If stdin is not a terminal (a pipe, as in both examples above), ash reads all of stdin (readAllPromptFromStdin) and uses that content as the prompt.

This means echo "hi" | ash and ash hi both work, but ash alone in an interactive shell just shows usage — there's no ambiguity between "no input yet" and "empty prompt."

Security provisions relevant to scripting

  • No shell is ever invoked for tool calls. run_unix_command and run_unix_pipeline (in tools.go) execute argv directly; there's no shell interpolation for the AI's own tool use, and arguments containing shell-control characters (;, |, &&, backticks, $( ), redirection, newlines — isBlockedArgument in support.go) are rejected outright.
  • Allowlisting. Only executables listed in ~/.ash/.ash_tools (or ASH_TOOL_ALLOWLIST) can be run as tools by the AI — see Guide-to-Creating-Python-Tools for adding your own scripts to it.
  • Bounded execution. Every tool call has a timeout (ASH_TOOL_TIMEOUT, default 15s) and a captured-output cap (ASH_TOOL_OUTPUT_MAX, default 8192 bytes), so a runaway or verbose tool can't hang or flood a script's log.
  • When you invoke ash itself from a script, none of the above changes how you call it — you're just running a CLI program with stdin/argv/exit-code semantics like any other. The allowlist and sandboxing rules govern what the AI can do inside that invocation (its tool calls), not how your script talks to ash.
  • When ash calls itself. run_sub_agent launches a child ash process (runSubAgentCommand in support.go) with a restricted environment and one level of delegation depth; this is unrelated to a script invoking the top-level ash binary, but is worth knowing if your prompt asks ash to delegate work.

Scratch Python scripts

When run_python3 is published, ash can author one Python script inside its current session scratch directory and run it. The model writes it with ash_write_scratch_file, then passes the returned absolute_path verbatim as script_path to run_python3. Ash accepts only an existing .py regular file contained in that session's scratch root; it rejects constructed paths, traversal, and files outside the session.

The session ID is managed by ash. Do not construct ~/.ash/scratch/... paths yourself. Scratch data is temporary and eligible for cleanup after 48 hours with no access in the preceding 24 hours. Python execution is unavailable when no selected interpreter resolves or when ASH_STRICT=1; see Conditional-Python-Execution.

Error logging when scripting

ash always logs structured JSON via slog (see newStructuredLogger/configureDebugLogging in support.go), and every log call carries a unique EID field (see AGENTS.md — "the EID is a unique ID for every logging call, use it to find the source of the log message"). For scripting purposes:

  • By default, logs go to stderr, so a script that only captures stdout (as both examples above do) won't see them mixed into the generated content — check the exit code and stderr separately if you need to diagnose a failure.
  • Set ASH_VERBOSE=1 to raise the log level to debug and get more detail.
  • Set ASH_LOG_FILE=/path/to/file (and optionally ASH_LOG_MAX_BYTES, default 1 MiB) to write logs to a rotating file instead of stderr — useful for scripts run unattended (cron, CI) where you still want to inspect what happened after the fact.
  • If ash exits non-zero, grep the log for the EID value in the message to jump straight to the slog.Error/slog.Warn call site that produced it.

When the connection-sharing broker is involved

The Connection-Sharing broker is only started and used by the interactive shell wrappers (~/.ash/.ash_bashrc, .ash_zshrc, .ash_fish.fish) via _ash_ensure_broker, which exports ASH_BROKER_SOCKET/ASH_BROKER_TOKEN/ASH_BROKER_PID/ASH_BROKER_LEASE into that shell's environment. A plain script or Makefile recipe invoking the ash binary directly (as both examples above do) does not inherit those variables unless it's a child process of a live interactive shell that already set them up — so scripted/CI/cron invocations of ash almost always make a direct HTTPS request rather than reusing a shared connection. This is intentional: the broker's socket and token are ephemeral shell state and must never be persisted into scripts, cron, or launchd configuration.

Avoiding accidental invocation during interactive script development: ash snooze

command_not_found_handle/command_not_found_handler (the hook that routes an unrecognized command to ash) is installed only for interactive shells — the wrapper scripts short-circuit immediately in non-interactive shells (case "$-" in *i*) ;; *) return ;; esac in ash_bootstrap/.ash_bashrc), so a script executed with bash myscript.sh never triggers accidental AI routing from a typo.

Where this matters is when you're interactively iterating on a script at the terminal — sourcing pieces of it, retyping commands, or testing fragments by hand — and a typo'd or not-yet-installed command name would otherwise silently get sent to the AI as a natural-language prompt via command_not_found_handle. Run:

ash snooze          # pause routing for 5 minutes (default)
ash snooze 30m       # pause for a custom duration
ash snooze off       # resume immediately

This is implemented in snooze.go: runSnooze writes an expiry timestamp to ~/.ash/.ash_snooze_until, and each shell wrapper's _ash_prompt_processing_enabled check (for example in ash_bootstrap/.ash_bashrc) reads that file before routing an unrecognized command, refusing to invoke ash while the snooze is active. It only affects the command_not_found auto-routing path — explicit ash "..." calls (including from scripts) are never affected by snooze.

Related reading

Clone this wiki locally