Adds an opt-in ability for the model to run programs on the user's Mac,
using the Foundation Models Tool API, so aii can do what the user asks
instead of only showing a command in a code block.
SPEC.md remains the source of truth for the CLI surface, modes, and error
codes. This document owns everything about command execution. If the two
disagree about exec behaviour, this document wins. Section 14 lists the
edits SPEC.md needs.
- Opt-in. Without
-x, behaviour is identical to v0.8: no tool, no extra instructions. - Safe by default. Read-only commands run silently; everything else needs a human decision.
- Minimal. One tool, one allowlist, one config file.
- One approval model in every mode: interactive, one-shot, piped stdin,
--file.
- No auto-approve-everything flag (no
--yes, in any spelling). - No sandboxing (
sandbox-exec, containers, chroot). - No shell: no pipes, redirects, globbing,
~expansion,&&, subshells. - No per-directory or per-project allowlists.
- No
XDG_CONFIG_HOMEsupport; the config path is fixed. - No raw single-keypress input; answers are read as a line.
- No file-write, network, or other tools.
run_commandis the only tool.
Two things can cause a harmful command:
- Prompt injection. Content the user pipes in, attaches with
--file, or pastes into an interactive session may contain instructions aimed at the model. A small on-device model resists this poorly. - Model mistakes. Hallucinated or over-eager destructive commands.
Not in scope: a malicious user, or an already-compromised local account.
This is a guardrail, not a sandbox. Design consequences:
- No shell, so approving a program cannot smuggle in a second command.
- Approvals are argv prefixes, not program names.
- The exact command is always shown, unabbreviated and escaped.
- A human must be reachable, or the command is denied.
- "Always" can only make narrow, non-dangerous prefixes permanent.
| Flag | Short | Description |
|---|---|---|
--exec |
-x |
Let the model run programs via the run_command tool |
--list-allowed |
Print the effective allowlist rules, then exit 0 | |
--reset-allowed |
Delete the config file, reverting to built-in defaults, then exit 0 |
-xworks with every mode: one-shot,--file, piped stdin,-i.--list-allowedand--reset-allowedare standalone, like--model-info. Combining either with a prompt,-i,-x,-f, or each other is an input error:conflicting_input, exit 2.- Both management flags are handled before the availability check, so they work on devices without Apple Intelligence.
- Use flags, not subcommands: a positional prompt would make
aii allowambiguous. --list-allowedprints the effective rule set, one rule per line, headed by its source:# source: <config path>if the config file exists, otherwise# source: built-in defaults (no config file).--reset-alloweddeletes the config file (no confirmation), printsaii: removed config; using built-in defaults(oraii: no config file; using built-in defaultsif there was none), exits 0. This discards all user rules and any edits made to the seeded defaults.
Name: run_command. (Deliberately not "bash": there is no shell, and the
name must not invite shell syntax.)
@Generable
struct Arguments {
@Guide(description: "Program name or absolute path, e.g. ls or /usr/bin/git")
var executable: String
@Guide(description: "Arguments, one array element each. No quoting, wildcards or ~.")
var arguments: [String]
}The wire contract is the two fields executable and arguments. The Swift
shape is an implementation detail: if the @Generable / @Guide macros are
unavailable with the Swiftly toolchain, build an equivalent schema with
DynamicGenerationSchema and take GeneratedContent as the arguments type.
Tool description (keep short — it costs context tokens):
Run one program on the user's Mac and return its output. There is no shell: pipes, redirects, wildcards and ~ do not work. Paths are relative to the current directory.
call(arguments:) never throws for expected failures. A throw aborts
the whole response in Foundation Models, so every failure is returned as a
string the model can react to. Strings are fixed (tune after first runs):
| Situation | Returned string |
|---|---|
| Ran | exit <status> + newline + output |
| Output over cap | output + \n[output truncated: <total> bytes, showing first 2048] |
| Timeout | Terminated: the command exceeded the 30-second time limit. + newline + partial output |
| User said no | Denied: the user declined to run this command. Do not retry it. |
| No terminal | Denied: this command is not on the allowlist and there is no terminal to ask the user for approval. Do not retry it. |
| Not found | Error: executable not found: <name> |
| Launch failed | Error: could not launch <name>: <reason> |
| Empty or NUL-containing executable | Error: invalid executable |
Output is stdout and stderr merged in arrival order.
For every tool call:
- Validate the executable (non-empty, no NUL).
- Resolve it (5.2).
- Match it against rules (5.3). Match → run without asking.
- No match → open
/dev/tty. If that fails → return the "No terminal" denial. - Prompt (5.4).
y→ run.a(only if eligible, 5.5) → add rule, persist, run. Anything else, including empty input and EOF → "User said no" denial.
Every command that runs, approved or not, is logged (Section 9).
- If
executablecontains/: treat it as a path (absolute, or relative to the current directory). Do not follow symlinks for the trusted-directory check. - Otherwise: search the inherited
PATH, skipping entries that are not absolute (empty,., relative). First executable regular file wins. - The program name is the last path component of the resolved path.
So
/bin/lsandlsboth have program namels.
A command matches a rule when all hold:
- Rule's first token equals the program name.
- The resolved path is inside a trusted directory:
/bin,/usr/bin,/sbin,/usr/sbin,/usr/local/bin,/opt/homebrew/bin. - The rest of the rule is a prefix of the command's arguments.
An executable outside trusted directories (e.g. ./script.sh, ~/bin/x)
never matches: it always prompts, and "always" is never offered.
There is one effective rule set: the config file if it exists, otherwise the built-in defaults (5.6). See Section 6.
Written to and read from /dev/tty, never stdin/stdout:
aii: run this command?
git status --short
[y] yes once [a] always allow "git status" [N] no
>
- The
[a]option is omitted when ineligible (5.5). - Default is no: Enter, unrecognised input, and EOF all mean no.
- The answer is read as a line; the first character, lowercased, decides.
- If the resolved path is outside trusted directories, show the full
resolved path as the executable and append
(not in a system directory).
Rendering. The command is displayed in full — never abbreviated — as the program followed by arguments, POSIX-shell-quoted where needed. Before display, escape anything that could spoof or hide content:
- control characters (U+0000–U+001F, U+007F, U+0080–U+009F) as
\xNN - Unicode bidi and other format characters (e.g. U+202E, U+200B) as
\u{XXXX} - newlines as
\n
The same rendering is used for the prompt, the "always allow" text, and log lines.
[a] is offered only if:
- the resolved path is in a trusted directory, and
- the program name is not in the never-always set (below).
The stored prefix is derived from the approved command:
| Command shape | Stored prefix |
|---|---|
| No arguments | [program] |
First argument does not start with - |
[program, args[0]] |
First argument starts with - |
the full argv ([program] + args) |
Examples: git status --short → git status; brew list → brew list;
git -C /x status → the full argv (a bare git is never stored for a
command with a subcommand).
The prompt shows exactly the prefix that will be stored. Same rule in
every mode; it does not depend on how input reached aii.
Never-always set (hardcoded; for these the prompt is y/n only, every
time). Initial list, to be tuned:
| Category | Programs |
|---|---|
| Shells | sh bash zsh dash ksh csh tcsh fish |
| Interpreters | python python3 perl ruby node osascript swift |
| Wrappers | env xargs sudo su doas nice nohup time |
| Can run or rewrite arbitrary things | find sed awk gawk tee dd make npm npx pip |
| Network | curl wget nc ssh scp rsync |
| Launchers | open launchctl |
| Destructive or system-changing | rm rmdir mv cp chmod chown kill killall defaults diskutil |
The fallback rule set, used only when no config file exists, and the template that seeds the config file when it is first created (Section 6). Each rule is the program alone, so it auto-approves with any arguments. Every entry must be safe with arbitrary arguments; review the list whenever it changes.
ls pwd cat head tail wc date whoami uname df du
stat file which ps uptime sw_vers
Reading files is not treated as a leak: the model runs on-device, and sending anything onward needs a further command that is not allowlisted.
Path: ~/.config/aii/allow
There is exactly one effective rule set at any time:
- No config file: the built-in defaults (5.6) are the effective rule
set. Nothing is written to disk just by running with
-x. - Config file exists: it is authoritative. The built-in defaults are not consulted, so an empty file means nothing is auto-approved, and a hand-created file replaces the defaults.
- First "always" (no file yet): create the file containing the full default list plus the new rule, so the defaults become visible and editable. The write is atomic (temp file, then rename); a crash must not leave a file that lacks the defaults.
- A seeded file starts with the header comment
# seeded from aii <version> built-in defaults. - A seeded file is not updated by later releases, even if the built-in
defaults change. This is a deliberate trade-off for user control. To pick
up new defaults, run
--reset-allowed(this also discards user rules).
# seeded from aii 0.9 built-in defaults
# one rule per line: an argv prefix
ls
pwd
cat
head
tail
wc
date
whoami
uname
df
du
stat
file
which
ps
uptime
sw_vers
git status
- Plain UTF-8 text. One rule per line: an argv prefix, tokens separated by whitespace, POSIX-style single quotes, double quotes, and backslash escapes. No variable or tilde expansion.
- Blank lines and lines starting with
#are ignored. - Malformed lines are skipped with a warning on stderr (not in
--jsonmode). - Loaded once at startup, only when
-xis set. An unreadable file → warn and use the built-in defaults. - On "always": add to the in-memory set immediately (so it applies for the
rest of an interactive session, and survives
/new), then persist: if the file exists, append one line; if not, seed it as described above. Duplicates are not re-added. - Create
~/.config/aii/with mode 0700 and the file with mode 0600. - If persisting fails, warn; the rule still applies for this process.
- Hand-edited lines are honoured, including for never-always programs: editing the file is the deliberate out-of-band channel. The trusted- directory requirement (5.3) still applies.
- The model has no tool that writes this file (see Section 12).
Same values in every mode. Defined once as constants (Exec.timeout,
Exec.outputCap) so they are easy to tune.
| Limit | Value |
|---|---|
| Timeout | 30 s wall clock |
| Output cap | 2048 bytes retained |
- Timeout: send SIGTERM; if still running after 2 s, SIGKILL. The result includes whatever output was captured.
- Output cap: keep draining the pipe past the cap (so the child never blocks on a full pipe), retain the first 2048 bytes, count the total. Decode leniently (invalid UTF-8 becomes replacement characters).
- Rationale: 2048 bytes is roughly 500–700 tokens of a 4,096-token
window that also holds instructions, tool schema, the prompt, and the
response. Not scaled with
contextSizein v0.9. - Child stdin is
/dev/null, so a command can never consume piped content or read the user's terminal. - Working directory and environment are inherited.
With -x, the session is created with the tool and these instructions:
You can run programs on the user's Mac with the run_command tool. When the user asks you to do something or find something out on their machine, call the tool instead of showing commands in a code block. There is no shell: give the program and its arguments separately; pipes, redirects, wildcards and ~ do not work. Run one program per call. Do not run destructive commands unless the user explicitly asked for them. If a command is denied, do not retry it.
- One-shot: these are the whole instructions.
- Interactive with a system prompt: the user's system prompt, a blank line, then the text above.
- Without
-x, nothing is added.
Signature change: ModelBridge.makeSession(systemPrompt:exec:).
The one-shot pre-flight token check is unchanged (it only catches
guaranteed failures); overflow caused by tool schema, instructions or tool
output is handled by the existing reactive context_exceeded path.
| Channel | Content |
|---|---|
| stdout | Model text only, as today |
/dev/tty |
Approval prompt and answer |
| stderr | aii: $ <command> log line for every command that runs; warnings |
- The log line uses the same rendering as the prompt. It makes silently auto-approved commands visible.
- In
--jsonmode, log lines and warnings are suppressed, so stderr stays pure JSONL. Prompts still go to/dev/tty. - Before writing to the terminal (prompt or log), flush any pending stdout buffer so model text and prompts do not interleave mid-line.
New files under Sources/aii/ (aim for under ~150 lines each; split
further rather than exceed, the list below is the minimum):
| File | Contents |
|---|---|
ExecTool.swift |
RunCommandTool, Arguments, launching, timeout, output cap, result strings |
Allowlist.swift |
rule type, built-in defaults, never-always set, trusted dirs, resolution, matching, prefix derivation, config parse/persist |
Approval.swift |
/dev/tty prompt, command rendering and escaping, tty abstraction |
Shared state (the rule set) lives in an actor; Tool conformance
requires Sendable, and call is async. Do not use nonisolated(unsafe).
The stdout buffer needs a small hook or shared sink so the tool can flush it
(Section 9).
Modified: aii.swift (flags, dispatch), ModelBridge.swift (makeSession),
OneShot.swift and Interactive.swift (pass exec, own no logic beyond
that).
Allowlist and rendering logic is pure; unit-test it with Swift Testing. Inject the tty opener and the clock/timeout so the no-tty and timeout paths are testable without a real terminal.
Unit tests
- Matching: defaults match with any arguments; prefix rules match longer
argv; wrong program or wrong subcommand does not; non-trusted directory
never matches; relative
PATHentries are skipped. - Prefix derivation for the three command shapes.
- Never-always programs produce no
[a]option. - Config parsing: comments, blank lines, quotes, malformed lines skipped.
- Rule set selection: no file → built-in defaults; existing file (even empty) → only its rules; unreadable file → built-in defaults plus a warning.
- Seeding: the first "always" writes defaults plus the new rule in one atomic write; later "always" answers append.
- Rendering: newline, ESC, C1 controls, bidi override all escaped; quoting of spaces.
- Output cap: truncation at 2048 bytes, total count, invalid UTF-8.
- Timeout: a short injected timeout terminates a
sleep. - Denial and error strings match Section 4 exactly.
Manual checks (in addition to the SPEC.md verification list)
aii -x "how much free disk space do I have?"→ runsdf/du, no prompt, log line on stderr.aii -x "create an empty file named x.txt"→ prompt fortouch;n→ model reports it was denied; nothing created.- Approve
git statuswitha→~/.config/aii/allowis created containing the defaults plusgit status; the next run auto-approves it. cat notes.md | aii -x "…"→ prompt still appears (via/dev/tty);[a]behaves the same as without the pipe.aii -x "…" 2>/dev/null→ prompt still visible.- Run without a controlling terminal (e.g. from a launchd job) with a non-allowlisted request → "No terminal" denial, exit 0.
aii -x --json "…"→ stderr contains only JSONL.aii --list-allowed→ source line says built-in defaults. After theaapproval above it names the config path and lists the defaults plusgit status. Afteraii --reset-allowedit is back to built-in defaults.aii -x -i→/newkeeps previously approved rules.
- Not a sandbox. An injected but plausible-looking command can still reach a tired user; the exact-argv prompt is the mitigation.
- Some trusted programs execute code from their environment. For
example,
git statusorgit diffin an untrusted repository can run configured helpers (fsmonitor, pager). "Always allow git …" trusts the repositories you use it in. - An approved command that can write files could edit the allow file. The prompt shows the command, but there is no special protection.
- The inherited environment (which may contain secrets) is visible to approved commands.
- Grandchild processes may outlive a timeout kill.
- Small-model tool calling is inconsistent; expect retries and odd argv.
- No
~or glob expansion, so the model must use explicit paths. - Seeded defaults are frozen at the version that created the config file; later changes to the built-in list do not reach existing users unless they reset.
- Timeout (30 s) and output cap (2048 bytes); head-only vs head+tail
truncation; scaling the cap with
contextSize. - The default allowlist and the never-always set.
- The exact denial strings and instruction text; whether the model loops on retries after a denial (if so, add a per-turn denial limit).
- Whether same-mode prompts become noisy enough to justify a directory-scoped "always".
- Whether stale seeded defaults need a version check, or a refresh command that preserves user rules.
- Sync first:
SPEC.mdis v0.7 while the code is 0.8.0 (--model-info,contextSizedetection, pre-flight token check are missing). - Bump version to 0.9.
- CLI flags table: add
--exec/-x,--list-allowed,--reset-allowed. - Project structure: add
ExecTool.swift,Allowlist.swift,Approval.swift. - Non-goals: replace "No tool calling or MCP integration" with "No tools
other than
run_command(see SPEC-EXEC.md); no MCP integration". - Mode summary: note that
-xapplies to all modes. - Exit codes and error codes: unchanged.