CLI Module
August 31, 2026 · View on GitHub
Overview
The CLI module (kiro_crew/cli.py) provides the kirocrew command using stdlib argparse.
Import Weight Contract
cli.py is the shared dispatcher for every subcommand — including the
long-lived MCP stdio servers (kirocrew mcp-core / mcp-cron /
mcp-computer), which hold its module-scope imports resident for their whole
lifetime. Its module scope therefore stays light: cli_commands,
cli_server (which pulls slack.gateway) and dashboard.state (which pulls
vector_memory → numpy) are imported inside the one main() dispatch
branch that uses each name, never at module scope. Deferring them cuts a
fresh import kiro_crew.cli from ~1.3 s / ~112 MB to ~0.5 s / ~54 MB, paid
per CLI invocation and per MCP backend process.
test/test_cli_lazy_imports.py ratchets the contract: after
import kiro_crew.cli in a fresh interpreter, none of those modules may be
present in sys.modules, and every deferred dispatch import must resolve.
The entry point itself is not negotiable: all invocation forms
(kirocrew <sub>, python -m kiro_crew <sub>, and the frozen desktop
binary) land in cli.main(), whose prelude runs boot_platform()
(fail-closed for non-standalone profiles), sandbox env hygiene, and
KIROCREW_PROJECT_DIR resolution before any dispatch.
Source Checkout Launcher
The POSIX wrapper at bin/kirocrew resolves symlinks to find the real checkout,
sets KIROCREW_PROJECT_DIR to that checkout unless the caller already supplied
one, and delegates every argument to .venv/bin/kirocrew. The virtualenv entry
point comes from the editable install created by the setup scripts, so it makes
src/kiro_crew importable without adding the source tree to PYTHONPATH. Any
caller-provided PYTHONPATH is inherited unchanged.
If .venv/bin/kirocrew is unavailable, the wrapper exits with source-install
guidance instead of falling through to a different Python environment.
Standalone Wheel Installer Trust Contract
cli.sh installs channel or pinned-version wheels only from an authenticated
manifest. This distribution trust boundary is independent of the runtime CLI
and of macOS signing/notarization.
- Schema:
kirocrew-cli-artifact-manifest-v1. - Algorithm:
RSASSA_PKCS1_V1_5_SHA_256. - Key identity:
sha256:plus the lowercase SHA-256 digest of the public SubjectPublicKeyInfo DER bytes. - Signed fields:
algorithm,channel,key_id,pub_date,python_requires,schema,sha256,version, andwheel_url. - Optional signed field:
min_version— the forced-update floor for a breaking release, declared inpackaging/MIN_VERSIONat publish time. Must be a bare release (0.6.0, no prerelease suffix) and must not exceed the manifest's ownversion; both are enforced bypackaging/signing/cli-manifest.pybefore signing. The installer verifies its format but does not act on it (it always installs the signed version); running gateways read it from the channel feed and mark the update REQUIRED when their own version sits below the floor — but only afterplatform/feed_trust.pyverifies the manifest signature against the same pinned key, because the floor coerces the dashboard UI and an unverified one must degrade to the ordinary dismissible prompt. Absent means no floor, and the canonical payload omits the key entirely so no-floor manifests stay byte-identical to the pre-floor format. - Signature field: base64 RSA signature over sorted, compact UTF-8 JSON of all
signed fields;
signatureitself is excluded. - Channel source:
feed/<channel>/latest-cli.json. Pinned-version source:cli/<channel>/<version>/cli-manifest.json; pinned installs do not resolve through the mutable channel feed.
The installer embeds the public key and expected key id. Before any network
request, it requires OpenSSL, rejects an unconfigured pin, materializes the key,
and verifies its DER fingerprint. It then applies bounded input/object sizes,
duplicate-key and exact-field-set rejection, printable-ASCII/string checks,
canonical URL and digest validation, requested channel/version matching, and
pinned-key signature verification. Artifact fields are not consumed and wheel
bytes are not fetched until the signature succeeds. The downloaded wheel must
then match the authenticated SHA-256 digest before pipx install runs.
Any unavailable trust root, malformed or unsigned legacy feed, unknown field,
wrong schema/algorithm/key id, signature failure, metadata mismatch, network
failure, or wheel digest mismatch terminates installation. There is no unsigned,
SHA256SUMS, or trust-on-first-use fallback. Until the operational public key is
pinned, the repository's explicit UNCONFIGURED state therefore makes stock
cli.sh non-installing by design. Provisioning and rollout are specified in
packaging/signing/README.md.
Project Directory Detection
At startup, main() auto-detects the project root and sets KIROCREW_PROJECT_DIR:
- If
KIROCREW_PROJECT_DIRenv var is already set, use it - Walk up from CWD looking for a directory with both
skills/andsrc/kiro_crew/(_PROJECT_MARKERS). The project-levelagents/dir was removed when agent config was consolidated intosrc/kiro_crew/config/(commit bbbc1f6e), so the marker no longer references it — a staleagents/requirement left detection (and the dashboard changelog) silently broken. - Read saved path from
~/.kiro/crew/project_dir(written bykirocrew setup); the saved path is re-validated against the same markers
This allows kirocrew to find project-level agent config and skills from any directory.
Commands
Top-level help
kirocrew --help (and a bare kirocrew, which prints the banner first) does NOT
use argparse's own subcommand block. With ~40 commands that block is one flat
list in registration order, so the three commands a new install needs — gateway,
service, doctor — land in the middle of it, and the {chat,doctor,gateway,…}
choice blob makes the usage line unreadable.
cli_help.py owns the taxonomy instead:
COMMAND_GROUPSis an ordered list of sections, each an ordered list of(command, one-line summary). It is the single source of truth for what the top-level help lists and in what order;Start hereis first and holds exactlygateway,service,doctor.- Its notes answer the two questions the flat list never did: how
gateway(foreground, dies with the terminal) differs fromservice install(systemd unit / launchd agent, detached, restarts on crash, starts at boot, only one at a time), and that the dashboard on loopback5476is the only port opened — messaging channels connect outbound. cli.pysetshelp=argparse.SUPPRESSon the subparsers action to hide argparse's listing, passescli_help.TOP_USAGEas the top-levelusage=(the suppressed action would otherwise drop the placeholder), and pinsprog="kirocrew"on the action so each subcommand's own usage line isusage: kirocrew <cmd> …rather than the whole top-level usage string.- Every user-facing command is registered with
cli_help.add_command(sub, name), which raisesKeyErrorfor a name that is in no section — a new command cannot be added without appearing in the help. The section summary becomes the subparser'sdescription, which is whatkirocrew <cmd> --helpprints, so the sentence is not duplicated. A caller may pass its own longerdescription(benchdoes). - Internal
mcp-*servers callsub.add_parser(name)with nohelp, which keeps them out of both listings. They are also kept out of argparse'sinvalid choice: 'x' (choose from …)message:cli_help.hide_internal_commandsswaps the subparsers action'schoicesfor a live Mapping view over the same parser map that ITERATES only user-facing commands, in the help's section order. Membership is unfiltered and_name_parser_mapis untouched, sokirocrew mcp-corestill dispatches — the filter changes what argparse prints, never what it accepts. It must be installed after the lastadd_parserand beforeparse_args. test/test_cli_help.pypins offered-vs-listed parity by reading that same message, and pins that a hidden command still resolves.
| Command | Description |
|---|---|
kirocrew chat -m "msg" | Send a single message, print streaming response |
kirocrew chat | Interactive chat mode (readline, exit with Ctrl+D) |
kirocrew chat --model X | Override model for this session |
kirocrew gateway | Start the Kiro Crew server (dashboard + messaging channels) |
kirocrew gateway --slack-only | Start without dashboard or SSH tunnel instructions |
kirocrew gateway --no-crons | Start without cron scheduler (use when another instance handles crons) |
kirocrew gateway --no-tunnel | Never publish a tunnel: refuses to start or provision one for the life of the process, whatever tunnel.enabled says. SCOPED TO TUNNELS — it does not change where the dashboard binds, so a config that widens dashboard.url off loopback still does, with token auth as the control there; do not read publish_disabled() as "no published surface of any kind". Reach the instance on the loopback port it binds (ssh -L from another host). A Dev Fleet pod boots with this whenever its own checkout declares the flag — the pod's argv is built by the control plane but executed by the target worktree's gateway, so pod.runtime.target_supports_flag probes that checkout first and DROPS the flag when it is absent (passing it would make argparse exit 2, which the unit's Restart=on-failure/RestartSec=5 turns into a 5s restart loop). Such a checkout keeps the tunnel behaviour it had before this flag existed and is not given the guarantee — see security.md for why no config-side substitute is applied. |
kirocrew setup | Install agent config, save project dir, configure credentials |
kirocrew setup --agent-only | Only install agent config (skip credentials) |
kirocrew setup --slack | Run the guided Slack credential + slash-command setup (opt-in) |
kirocrew setup --whatsapp | Run the guided WhatsApp opt-in: report the optional whatsapp extra and the pairing state, then enable the channel (opt-in) |
kirocrew doctor | Verify kiro-cli is installed and config is valid |
kirocrew cron add/list/remove | Manage cron jobs |
kirocrew spawn run/list | Manage background subagents |
kirocrew app install/list/enable/disable/uninstall | Manage App Kit apps. Uninstall preserves apps/<name>/data/ by default. |
kirocrew app uninstall NAME --purge-data | Explicitly uninstall an app and permanently delete its app data. |
kirocrew app dev <name> [--off] | Toggle an installed app into/out of dev mode (no-store UI serving + live reload on file change). See App Dev Mode. |
kirocrew learn add/list/remove | Manage learned corrections |
kirocrew run TASK.md | Run an autonomous task from a spec file |
kirocrew token | Print a dashboard access URL with auth token |
kirocrew logout | Revoke all active dashboard sessions, refresh chains included |
kirocrew manifest | Generate Slack manifest with user alias auto-populated |
kirocrew update | Update to latest version (git fetch + hard reset to upstream + rebuild; a diverged checkout is refused — --force discards its local commits) |
kirocrew status | Show runtime stats from running gateway |
kirocrew stop | Stop a running gateway (service-aware: stops the systemd/launchd service if active, otherwise terminates the gateway found by a cross-platform port lookup — lsof on POSIX, netstat on Windows). Pass --port N to bypass the service short-circuit and target a specific gateway. |
kirocrew restart | Restart a running gateway (service-aware: restarts the systemd/launchd service if active, otherwise terminates the foreground gateway and respawns it detached). Pass --port N to bypass the service short-circuit and target a specific gateway. |
kirocrew service install | Install gateway as a system-level systemd service (Linux, requires sudo for tee + systemctl only) or launchd LaunchAgent (macOS, no sudo). Auto-restarts on crash, auto-starts on boot. |
kirocrew service uninstall | Stop and remove the systemd unit / launchd plist. |
kirocrew service status | Show service status (systemctl status or launchctl list). No sudo required. |
kirocrew logs | Tail gateway logs from the systemd journal, launchd stdout file, or ~/.kiro/crew/gateway.log. |
kirocrew logs -f | Follow logs live (long-running tail). |
kirocrew cloud launch/list/status/connect/stop/start/destroy/iam-policy/doctor | Provision, connect to, and manage a KiroCrew EC2 instance in the user's AWS account. |
kirocrew security events | Show recent SEL audit events (-n N for count) |
kirocrew security verify | Verify SEL HMAC chain integrity |
kirocrew snapshot | Create a .tar.gz snapshot of all KiroCrew state |
kirocrew snapshot --keep N | Auto-prune to N most recent snapshots (default 7) |
kirocrew snapshot --list | List existing snapshots |
kirocrew restore <file> | Restore from a snapshot (auto-detects replace vs merge) |
kirocrew restore <file> --mode replace|merge | Force restore mode; merge skips malformed incoming or local cron JSON with a file-specific warning |
kirocrew restore <file> --components X,Y | Selective component restore |
kirocrew restore <file> --dry-run | Preview restore without writing |
kirocrew restore --list-components | Show available component names |
kirocrew snapshot --allow-unpinned-staging | Stage by path name where a directory cannot be pinned by descriptor |
kirocrew restore <file> --allow-unpinned-staging | Same, for the restore side |
Staging is descriptor-pinned, and refuses rather than degrading silently
Snapshot and restore stage through kiro_crew.pinned_fs: the parent chain is resolved
once, pinned component by component with openat + O_NOFOLLOW, and everything
downstream is addressed through the descriptor already held. A validated path and the
inode later opened are otherwise not the same thing, and anything running as the user
— which in this product includes an agent — can plant the swap between the two.
os.supports_dir_fd is empty and O_NOFOLLOW does not exist on Windows, so pinning
is unavailable there. The decision, recorded here rather than only in the pull request
that made it: staging is refused on such a platform unless
--allow-unpinned-staging is passed, and when it is, the archive's MANIFEST.json
carries "staging": "unpinned" and kirocrew restore --dry-run prints that the
archive was staged by name. The refusal is the default because a by-name walk is not a
slightly weaker version of a pinned one; it is the mechanism whose failure closed two
earlier attempts at this change. The flag is a permission for a platform that cannot
pin, not a switch that turns pinning off where it works.
MANIFEST.json also carries "skipped": any file omitted during staging (a hardlink
alias, a symlink, an entry that vanished mid-walk) with its reason, so an incomplete
archive says so in its own record instead of only in the console output of whoever ran
the command.
SQLite databases are out of scope for the pinned staging described here: they keep the
sqlite3.backup() path they already had, which reopens the live name. Capturing a live
database without reopening its name is a genuine conflict of requirements — SQLite accepts
only a path and cannot be pointed at a held descriptor — so it is tracked separately rather
than solved alongside the tree walk. The exposure is unchanged from before this staging
work, not introduced by it.
A refusal to stage is a permission decision and is written to the SEL audit log —
snapshot_rejected or state_restore_rejected, both with reason=unpinnable_staging.
The dashboard's import path (portability.apply_import_zip) is the exception, and
deliberately: it has no flag and no consent surface, so refusing there would not mean
"ask the user", it would mean deleting import on that platform. It therefore proceeds with
a by-name traversal where pinning is unavailable and records "staging": "unpinned" in its
returned summary, with a logged warning. Snapshot and restore keep refusing, because
--allow-unpinned-staging lets them ask. The per-entry screens apply on both paths — the
copy opens O_NOFOLLOW and the walk rejects links and reparse points — so what the import
path gives up is ancestor-swap resistance, not link resistance.
| kirocrew config get [key] | Print full config or a dot-path value |
| kirocrew config set <key> <val> | Set a config value (auto type detection) |
| kirocrew config set --file <path> | Replace config from a JSON file |
| kirocrew config edit | Open config in $EDITOR |
| kirocrew memory list/search/stats/audit | Inspect vector memory (entries, semantic search, counts, suspicious-content scan) |
| kirocrew memory show [preferences\|projects\|history] | Read the markdown memory layer (all three when no target given); --format md\|json, --since YYYY-MM-DD for history |
| kirocrew memory export/import/migrate | Export memory to JSON (--include-markdown adds the markdown layer), import it back, or migrate legacy markdown memory into the vector store |
| kirocrew policy show/validate/explain/profile | Inspect the effective enterprise security policy, load-check it and all profiles, explain one tool/scope decision for a surface, or print a profile. show also summarizes the built-in denied-command catalog as grouped counts (--ids lists each category's rule ids), on every install regardless of whether an enterprise policy is active — the one place an agent can learn a class of work is hard-denied before planning around it. |
| kirocrew pod up/down/ls/status/token/url/logs/exec/install/provision | Isolated worktree test gateways (Linux systemd --user only — every systemd-touching verb refuses with a one-line message on macOS/Windows). See src/kiro_crew/pod/README.md. |
| kirocrew knowledge dedup [--apply] | Collapse cross-source duplicate knowledge documents (dry-run unless --apply) |
| kirocrew cron preview <script> | Run a script cron locally with real MCP tools; notifications are captured and printed instead of delivered |
| kirocrew workspace create/update --dir <name> | --dir is a directory NAME that must resolve to a strict descendant of the data home (~ is expanded first); anything landing outside — and the home root itself, in any spelling — is refused with a SEL denied audit event. Containment, not an absolute-path ban: an absolute path under the home resolves where the relative form would and is accepted. The strict-descendant test is what closes the root case for tilde paths, since the per-call-site root-equality checks compare un-expanded config_dir() / ws_dir. Deliberately stricter than the dashboard's POST /api/workspaces, which accepts an absolute dir anywhere, screened by is_sensitive_path. |
| kirocrew computer doctor [--json] | Report computer-use availability: platform support, the keystone primary-enable state, and the advisory macOS Accessibility / Screen Recording probe with a responsible_hint. See Computer Use Commands. |
| kirocrew computer apps | List on-screen applications the accessibility layer can address (human-facing twin of the computer_list_apps MCP tool). Gated by the same chokepoint as call — refused while the feature is off or the session is unattended. |
| kirocrew computer call <tool> [k=v ...] | Run ONE computer-use tool through the same gated chokepoint the agent uses, and print its reply (debug / reproduction) |
| kirocrew computer call --calls '[…]' | Run a JSON array of tool calls in a SINGLE process, so element_index values from an earlier computer_get_state are still resolvable |
| kirocrew mcp-cron | MCP server for cron tools (spawned by kiro-cli) |
| kirocrew mcp-core | MCP server for spawn, learn, task tools (spawned by kiro-cli) |
| kirocrew mcp-computer | MCP server for computer-use tools (spawned by kiro-cli; hidden — registered with no help, so it is in neither listing). A thin shim — it forwards to the gateway over loopback and does no accessibility work itself. |
| kirocrew --version | Print version |
Token Command Output Streams
kirocrew token has a machine-readable stdout contract: stdout carries only
the dashboard URL(s), and every failure reason (invalid TTL, gateway not running,
gateway unreachable, empty token) goes to stderr.
The contract exists because stdout is parsed, not just read by a human. The
remote-mint path (kiro_crew.instances.token_mint.mint_remote_token) runs
kirocrew token on a remote host over SSH and regex-extracts the JWT from its
stdout. Error prose on stdout would both break the Unix convention and hide the
reason from a caller that captures stderr.
Legacy remote handling. Older remotes predate this split and still print
their failure reasons to stdout, which made a stderr-only error message degrade
to a bare <no stderr>. mint_remote_token therefore also carries a bounded,
redacted stdout tail in TokenMintError — appended only when stdout was
non-empty, so a current remote keeps the single-stream message shape. Because
stdout is the one stream that legitimately carries a token, the tail is
token-scrubbed (URL-borne and bare forms) before the generic credential and
exfiltration redactors run.
Setup Command
kirocrew setup performs:
- Saves
KIROCREW_PROJECT_DIRto~/.kiro/crew/project_dir - Installs agent config to
~/.kiro/agents/kirocrew.json - Prompts for Slack credentials and the slash-command name only when
--slackis passed; the default wizard configures no messaging channels and prints a pointer to connect them later - Offers to set up custom domain
kirocrew.localhost(macOS/Linux)
The saved project dir enables running kirocrew from any directory.
First-run Kiro CLI prerequisite onboarding
KiroCrew exposes the same two-step readiness contract on every supported
platform: an executable candidate must answer kiro-cli --version, then
kiro-cli whoami must confirm authentication. Candidate discovery includes
supported fixed locations in addition to inherited PATH; unusable candidates
are reported for repair. Setup probes the same first executable candidate ACP
will launch, so a stale earlier candidate cannot produce a false-ready result
from a different later installation.
- Missing CLI: the setup page offers an explicit install action on macOS,
Linux, and Windows. macOS/Linux download the fixed
https://cli.kiro.dev/install; Windows downloads the fixedhttps://cli.kiro.dev/install.ps1. Every redirect and the final response must remain on the exactcli.kiro.dev:443endpoint and expected path, with no userinfo, query, or fragment. Redirect destinations are resolved and validated before any request is sent, and the chain is limited to three redirects. Responses are size-bounded and must match a release-pinned SHA-256 digest plus the platform-specific official installer marker. A changed upstream script therefore fails closed until a KiroCrew release updates the pin; the manual official guide remains available. The exact validated bytes stay in memory and run through the fixed system interpreter's standard input. The installer receives a system-onlyPATHplus explicit HTTP(S) proxy variables, never user-writable executable directories or ambient application credentials. The official installer additionally verifies its downloaded package manifest and artifact checksum. - Unusable CLI candidates: the same page identifies that Kiro CLI needs repair
instead of treating a spawn failure as a signed-out session. If the upstream
POSIX installer would require an interactive
/dev/ttyreplacement prompt (an existing macOS app bundle or Linux~/.local/bin/kiro-cli), automatic repair is disabled and the user is directed to the official guide. A candidate that already runs is directly usable for sign-in regardless of install source; the post-installer attestation file is now write-only bookkeeping and does not gate credential access. - Installed but signed out: the setup page names the commands the USER runs and
runs nothing itself —
kiro-cli loginfor a personal account (Builder ID, Google, or GitHub), orkiro-cli login --use-device-flow --license profor organization SSO, which prompts for the organization's start URL and region. Both are backend code constants rendered verbatim in a<code>, never catalog values, because a translated command cannot be typed. Both tiers are named because the browser portal the bare command opens presents a free Builder ID as a peer of organization SSO; Kiro Crew does not detect which tier applies, so the gate describes the choice and the user makes it. Sign-in completion is observed only through the read-onlykiro-cli whoamiprobe. - Browser dashboard: the authenticated SPA gate operates on the gateway host, not the browser host. This covers native Windows source installs, Linux gateways, and browsers connected to another machine.
- Desktop shell: the shell starts or reuses the gateway first, then displays the same gateway-served setup gate as a browser. Remote gateways are therefore checked on the remote host rather than the desktop host.
- Offline test harness: the explicit
gateway --test-modebundle injects a ready prerequisite state so deterministic fake-ACP smoke and Playwright suites do not depend on a developer machine's Kiro installation, identity, or Linux sandbox capabilities. Ordinary gateway invocations always use the real probe/install/login service.
The setup client cannot supply a command, URL, argument, or output path. The web
API exposes only fixed install/login mutations to the configured owner (or the
signed local-app / local-startup identities before an owner exists).
Authenticated non-owner dashboard users receive only a redacted readiness bit:
they enter the dashboard once ready but cannot see host state/output or operate
setup. App tokens remain denied. Electron has no separate installer/login IPC
or subprocess implementation. Filesystem discovery runs off the event loop.
Version probes use a minimal noninteractive environment with no proxy
credentials or desktop-session IPC. They use the strict OS sandbox and
additionally hide the configured data home, ~/.kiro/crew, ~/.kirocrew, and
every known Kiro identity store. Any candidate that runs --version is eligible
for whoami and device login — trust is "it runs, and it has a valid login",
not install source, owner, or fixed path (KiroCrew is not the authority on where
Kiro CLI is installed, and its self-updater rewrites its own bytes as the user).
Auth calls execute the user's installed binary IN PLACE, never a private copy of
its bytes — a multi-call Kiro CLI resolves its sibling subcommand executable
relative to its own path, so a copy strands it (see security.md).
Sign-in itself is delegated to Kiro CLI: login --use-device-flow runs in the
standard sandbox against the user's real home, with only the Kiro Crew data
homes hidden, and the CLI writes its own credential store exactly as it does
from a terminal. KiroCrew stages nothing and publishes nothing, so no staged
state has to be reconciled after a failure, timeout, or cancellation. The
credential-minimal temporary home populated only with Kiro identity JSON and
SQLite files survives as an opt-in read-only mode — one that also hides
unrelated AWS, SSH, GitHub, and Kubernetes state — and its temporary directory
is removed on every exit path. Any allowlisted live identity artifact that is a
symlink, non-regular, oversized, unreadable, or disappears while being captured
aborts that mode before the command runs. Every
probe emits a critical invoked SEL event before spawn
and a best-effort terminal event without argv, candidate paths, output, or
environment values. Installer and login timeouts cover process exit and
output-pipe draining. On POSIX, a private supervisor remains the process-group
leader after the real command exits and keeps the group safely addressable until
all descendants close or are terminated. Windows cleanup opens an exact
primary-process handle before yielding after spawn and completes an initial
descendant snapshot even when the launcher exits immediately. It then retains
exact child handles and continues discovery from every live child, so late
helpers remain supervised and identifier reuse cannot target an unrelated
process. Numeric parent edges are accepted only when exact-handle creation and
exit times prove that the child was created during the parent's lifetime, and
the check is repeated across both tree snapshots. The primary root and each
retained child root receive one final snapshot after becoming inactive, so a
child spawned immediately before its parent exits is not lost between polls.
Failure to anchor or validate the primary process, create a Toolhelp snapshot,
or complete any process enumeration fails the operation closed. Ordinary
pipe/task errors follow the same terminate, reap, cancel, and cleanup path
before another action can start.
An auto-created config.json alone does not mark first-run setup complete; a
successful authenticated probe writes the setup marker, while existing
session/history state preserves established-install migration behavior. Fresh
installs receive the full-screen flow. Established dashboards remain navigable
and fully usable when signed out — no controls are paused. Readiness is probed
once at gateway boot and thereafter only on explicit user action, so no path runs
a subprocess probe on the message hot path; the authoritative logout signal is
the ACP attempt's AcpAuthRequired. The SPA refreshes ready status every 30 seconds,
retains cached readiness across transient refetch errors, and invalidates
prerequisite state after access-cookie refresh.
POSIX group membership ignores zombie records, which cannot retain pipes or
perform work, so an unreaping PID 1 cannot hold the supervisor forever.
The supervisor source is captured eagerly at import for replacement resistance;
if it is missing or unreadable, gateway import still succeeds and each affected
POSIX setup operation fails cleanly before spawning a command.
Sandbox launcher/profile preparation and cleanup are worker-thread operations
and do not stall the asyncio gateway loop.
Setup and ACP launch share the side-effect-free kiro_cli resolver on every OS.
Status requests never publish a discovered path by mutating KIROCREW_KIRO_BIN.
Both setup discovery and ACP launch enumerate the same candidates — inherited
PATH, the interpreter Scripts directory, package-manager dirs (incl. the
Windows Program Files Kiro-Cli tree and winget/scoop/user installs on PATH),
and an operator override — and accept a runnable candidate wherever it lives,
since trust is "the CLI runs". ACP launch runs the resolved candidate in place on
every platform — never a copy of its bytes. Setup discovery and ACP resolution therefore agree on
Windows, so a winget/scoop install is never sent to a redundant reinstall.
When a previously completed setup is no longer ready, the dashboard remains
fully navigable and fully usable — nothing is paused and no sign-in chrome is
shown. A signed-out CLI is reported by the turn itself as an actionable
kiro-cli login error card (see modules/learn-cron-dashboard.md § "The
dashboard does not guide the user to sign in"). Only the endpoints that act
BEFORE a turn still return 503: the poll-driven kiro-cli spawn sites
(/api/models, /api/sessions/usage) and the destructive reruns (regenerate,
edit-resend, rewind), which rewrite persisted history up front.
Custom Domain
After credentials, kirocrew setup offers to add 127.0.0.1 kirocrew.localhost to the system hosts file so the dashboard is accessible at http://kirocrew.localhost:5476:
- macOS/Linux: Uses
sudo tee -a /etc/hostsfor safe append
Skipped if kirocrew.localhost is already present or user declines.
Cloud Command
kirocrew cloud is a human installer/control-plane surface for running
KiroCrew on the user's own AWS EC2 instance. Provisioning and teardown are not
LLM-facing tools. AWS credentials are resolved by the AWS CLI; KiroCrew stores
only profile, region, and the most recent instance tag in cloud.json.
kirocrew cloud launch runs a six-step wizard: check AWS reachability, explain
permissions, choose whether to keep an existing deployment or create a new one,
choose an instance size when creating a new stack, deploy or resume the
CloudFormation stack, sign in the remote kiro-cli, and open the dashboard
through SSM port forwarding. Launch is resume-safe by default: if cloud.json
contains a last_tag whose stack still exists in the same saved profile/region,
rerunning interactive launch offers to keep/resume that stack or create a new
installation. If cloud.json is missing or stale, launch discovers existing
kirocrew-* CloudFormation stacks with cloudformation:ListStacks and offers a
choice to resume one or create a new installation. kirocrew cloud launch --new
is the explicit escape hatch for creating a separate new stack. --yes keeps a
single or saved existing stack; if multiple unsaved stacks exist it fails closed
instead of choosing one arbitrarily. For a new launch, the generated tag is
written to cloud.json before the long CloudFormation deploy starts, so an
interrupted provisioning run can be found on the next launch attempt.
Launch and connect require the local AWS Session Manager plugin for
AWS-StartPortForwardingSession. If session-manager-plugin is missing,
cloud launch prompts to install AWS's official package for the current local
platform (macOS .pkg, Debian/Ubuntu .deb, or RPM Linux .rpm) before the
wizard reaches sign-in/dashboard tunneling. --yes accepts this installer
prompt. cloud connect performs the same check and installer prompt before
opening the dashboard tunnel. If installation is declined or fails, the command
exits non-zero and tells the user to retry after fixing the local prerequisite.
The instance-size picker supports arrow keys in an interactive terminal
(↑/↓, j/k, digit shortcuts, Enter to select) and falls back to the
numbered prompt for non-TTY input. Ctrl-C must interrupt prompts and long AWS
subprocesses; unhandled cloud-command interrupts return exit code 130.
Remote Kiro sign-in prefers the device-code flow over SSM. The launcher starts
kiro-cli login --use-device-flow as a background process on the instance,
captures the URL/code from its log, and leaves that same process alive while the
wizard polls for completion. It must not kill that process after scraping the
prompt or start a second hidden device-code flow. If device-code startup does
not produce an actionable URL, launch falls back to the Google/GitHub callback
flow automatically: it starts kiro-cli login on the instance with FIFO-backed
stdin, captures the printed loopback callback port, opens an
AWS-StartPortForwardingSession from the same local port to the remote port,
sends the Enter continuation back to the remote CLI, then opens or prints the
local browser URL. The temporary callback tunnel is closed after the sign-in
poll completes. In headless local terminals, browser auto-open is skipped and
the URL is printed for manual opening.
kirocrew cloud connect mints a dashboard token over SSM, opens an
AWS-StartPortForwardingSession, waits for the local tunnel port to accept TCP
connections, and opens or prints the local dashboard URL. If the tunnel port
does not become reachable, the command reports failure, does not present the
dashboard URL as usable, and does not keep a dead tunnel process open. If final
dashboard opening fails during cloud launch, the instance remains running but
launch returns non-zero and tells the user to rerun kirocrew cloud connect
after fixing the local SSM tunnel issue.
Config Command
kirocrew config manages ~/.kiro/crew/config.json:
- get — prints full effective config (with defaults resolved) or a single dot-path value
- set key value — sets a value with auto type detection (bool/int/float/JSON/string). Rejects unknown leaf keys.
- set --file path — replaces entire config from a JSON file. File read routed through
hooks.safe_read_file()(blocks sensitive paths). - edit — opens config in
$EDITOR(supports args likecode --waitviashlex.split). Creates default config if missing.
All write paths emit SEL audit events (config_get, config_set, config_set_file, config_edit).
Gateway Auto-Create
kirocrew gateway creates ~/.kiro/crew/config.json with defaults if the file doesn't exist. Does nothing if it already exists.
Verbosity
| Flag | Level | What you see |
|---|---|---|
| (none) | WARNING | Errors only |
-v | INFO | Session lifecycle, context %, compaction |
-vv | DEBUG | ACP events, message updates, full traces |
Interactive Mode
- Prompt:
you> - Exit:
exit,quit,/exit,/quit,:q, Ctrl+D - Streaming output printed as chunks arrive
Tool permission requests
A backend that routes tool decisions over ACP holds the turn open until it gets an answer, so the stream consumer must respond — an ignored request is not a missed prompt, it is a turn that never ends.
Answering one is an authorization decision, so the CLI is a security surface, not just a prompt. Every request runs the same ladder, in this order:
permission_request
→ HookManager.on_tool_call (sensitive paths, denied commands, ceiling ∩ profile)
deny → SEL "denied" → reject_tool → stderr notice [not overridable]
→ may this invocation ask? (command mode AND both streams a TTY)
no → SEL "denied" → reject_tool → stderr notice
→ prompt the human
exactly "a" → SEL "allowed" → approve_tool(always=False)
anything else → SEL "denied" → reject_tool
The gate is fed the event's non-model-authored fields (tool_kind,
raw_tool_params, shell_command, is_shell, mcp_server_name, tool_name),
not just title: for a shell tool the title may be an LLM-authored
description, so a dangerous command behind a benign label is exactly what
keying on the title alone lets through. The CLI identifies itself as
session_key="cli_chat" (SEL source cli) with the resolved agent, which is
what lets the gate resolve ceiling ∩ profile rather than the ceiling alone.
No second copy of the sensitive-path or denied-command rules lives in the CLI.
The hook result is used as a deny ceiling only: TOOL_DENY rejects, and
both TOOL_ALLOW and TOOL_AUTO_APPROVE still ask the human. This consumer
answers permission requests; it does not carry the dashboard's trust and
auto-approval semantics, and honouring TOOL_AUTO_APPROVE here would add a
second execution path with no human confirmation. Asking more often than the
dashboard is the safe direction.
| Mode | stdin+stdout TTY | Behaviour |
|---|---|---|
| interactive REPL | yes | Prompt. Exactly a allows once; anything else denies. |
| interactive REPL | no | Deny automatically, notice on stderr, stdin untouched. |
-m single message | either | Deny automatically, notice on stderr, stdin untouched. |
Both conditions are required, and neither implies the other. -m is documented
as Single message (non-interactive), so a terminal does not license a prompt
there — a script wrapped in a pty would otherwise block on a question nobody is
watching for. And a prompt nobody can see is a hang, not consent, so the REPL
still needs a real terminal on both ends. The mode is passed explicitly rather
than inferred from a TTY check, because only the caller knows which mode runs.
A shell call shows the command it is asking about:
Permission required: Run a helpful script
Command: git status --short
[a] allow once [d] deny (default):
title is LLM-authored prose, so approving on it alone is consent to a
description rather than to what runs — the same reason the gate keys on
shell_command. The command is redacted with the two security helpers,
collapsed to one line, and capped with an explicit ... [truncated]. Local
paths are deliberately not redacted here: this is the operator's own
terminal, and seeing the real path is part of the consent.
A call that names a file discloses that file. A trusted tool identity says
WHICH tool runs, not what it runs against, so fs_write under a benign title is
consent to a verb:
Permission required: Tidy up the notes
Tool: fs_write
Path: /home/tester/thesis.md
[a] allow once [d] deny (default):
The path is read from the request's own raw_tool_params, under the same
path / file_path / filePath spellings hooks._SEARCH_DENY_ARG_KEYS
accepts. Sharing the spellings is the point: the gate already denies a
sensitive path or a write-protected config path read from those keys, so what
is left for a human to judge is exactly the ordinary valuable file no rule
speaks for — and a prompt reading a different field than the gate inspects would
let the two disagree about what the target is. Rendered like the command
(sanitised, one line, capped), and shown for any call that carries a path rather
than only an edit-kind one, because the kind is agent-influenced and
disclosure can only inform the decision.
Absence of a path is not a refusal. Most builtin calls legitimately act on no file — a memory write, a tag creation — so denying whenever a path cannot be found would refuse them all to close a gap that exists only for tools which name one. A non-string value is treated as absent rather than raised on: raising would leave the request unanswered, which is the hang this path exists to end.
Beyond the command and the path, the whole tool input is still not shown — this is the question, not a detail panel.
Terminal controls are neutralised on this surface. Every untrusted string
the permission UI prints — the title, the command, and a gate reason — goes
through _for_consent, which redacts as above and then replaces ESC, the C0 set
(U+0000–U+001F), DEL (U+007F), and the C1 set (U+0080–U+009F) with
spaces before collapsing whitespace, so the result is always a single line. Lone
UTF-16 surrogate code points (U+D800–U+DFFF) are removed by the same boundary:
they are not Unicode scalar values, so even a strict UTF-8 stream cannot encode
them. The result is then round-tripped through the destination stream's codec
with backslashreplace. UTF-8 terminals retain ordinary Unicode; a strict cp1252
or other legacy Windows stream sees inert ASCII escapes for characters it cannot
represent. This is an authorization surface: OSC 52 writes the clipboard, and
CSI can move the cursor and erase what is drawn, so a model-authored title could
otherwise repaint the question a human is answering and hide what is being approved.
Scope is the permission prompt only — ordinary streamed model output is printed
raw by _send_and_print and is unchanged, which is a surface-wide rendering
question rather than part of answering a permission request.
An authorization-boundary failure is itself a refusal. If the shared gate
raises, the CLI records gate_failed best-effort and rejects the request. If TTY
detection, prompt rendering, or prompt reading raises, it records prompt_failed
best-effort and rejects. The transport response is sent before any explanatory
notice, so a closed or unencodable output stream cannot leave the backend waiting.
This does not change cancellation: Ctrl-C still aborts the session and provider
teardown owns the pending request, because waiting on a possibly wedged rejection
transport would swallow the cancellation.
The audit write never runs on the event loop. sel() opens the audit log
and replays it to recover the running HMAC chain, so the first permission of a
fresh chat pays a filesystem cost inside the call — an unbounded one on slow or
corrupt storage. The decision coroutine shares its loop with the ACP reader and
stderr-drain tasks, exactly as the gate call does, so the audit is awaited
through asyncio.to_thread for the same reason: a blocking write here would
stop draining the backend and freeze the turn the audit is about. The one
exception is the cancellation teardown, which keeps the synchronous call because
awaiting anything there would swallow the CancelledError being delivered.
There is no "always allow". A persistent approval asks the backend to stop sending permission requests for matching calls, and a request that is never sent is a call this ladder never runs and never audits. The dashboard offers it because its tool pipeline re-gates every call; this consumer does not.
The answer is matched exactly, not by first letter: a prefix match reads
abort as an allow. Blank line, EOF, and any unrecognised word all deny.
Denial is not an error: the request is answered, the turn runs to completion,
and the exit code is unchanged. Only the existing transport failures
(AcpTimeoutError, AcpError) exit non-zero.
Every decision is written to the SEL log before the matching
approve_tool/reject_tool, so a transport failure cannot erase a decision
already made:
| decision | outcome | error |
|---|---|---|
| gate denied | denied | hook_deny |
| gate raised before a verdict | denied | gate_failed |
| execute-kind request has no verified command | denied | unverified_shell |
| nobody to ask | denied | noninteractive |
| prompt availability/render/read failed | denied | prompt_failed |
| user allowed | allowed | — |
| user allowed but the critical audit failed | denied | audit_unwritable |
| user denied / blank / EOF / unrecognised | denied | user_denied |
| session cancelled with the question open | denied | session_aborted |
error is a stable machine code, never the gate's reason: a reason names the
path or command that triggered it, and an audit record must not restate the
thing it protects (log_tool_invocation does not redact for its callers). The
audited tool_name is the canonical _meta.kiro identity when the backend
supplies one, falling back to a redacted title — an audit trail keyed on prose
the model wrote can be steered by the model being audited.
Responses go through provider.approve_tool() / provider.reject_tool(). The
CLI never reads the advertised options: those ids are backend-specific and the
ACP layer owns the mapping.
stdin ownership
The prompt is read on an owned daemon thread through a private duplicate of the
stdin descriptor, not on the event loop and not through sys.stdin. The turn is
parked inside an active stream — the ACP runtime holds a reader task on the
backend's stdout and a drain task on its stderr — so a read on the loop thread
would stop draining those pipes until the human answers.
A cancelled prompt ends the session. A blocking terminal read cannot be
retracted: cancelling the await frees the coroutine, not the thread, and that
reader stays parked and takes the next line the user types. So cancellation
marks stdin poisoned and propagates; _require_usable_stdin then refuses at
every later entry point — a second permission prompt and the REPL's own you>
alike — rather than racing the abandoned reader for keystrokes. There is no
input broker and no recovery path: the abandoned reader can only outlive the
session, never compete with a live prompt.
Teardown must not await the backend. The request in flight is left
unanswered and audited as session_aborted, because answering it means awaiting
a transport: CancelledError has already been raised once and nothing
re-delivers it, so a wedged reject_tool would leave the Ctrl-C that asked for
the teardown unable to land. The provider is shut down with the session, so the
unanswered request dies with it. StdinPoisonedError carries the same rule.
That last sentence is a guarantee, not an expectation: provider.start() hands
back a live backend process, so _chat runs the whole message/REPL lifecycle
under try and calls provider.shutdown() from finally. A normal return, an
exception, and a cancellation raised through a permission prompt all tear the
backend down; without it a Ctrl-C at the prompt would exit leaving the backend
running with nothing owning it. Cleanup never swallows the cancellation — a
failing shutdown() is logged rather than raised, because replacing the
exception already propagating would discard the very CancelledError the
teardown exists to clean up after — and gc.collect() is nested inside its own
finally so a raising shutdown cannot skip it.
Context Tracking
After each message, checks provider.context_usage_pct():
>= autocompact_pct(default 70%): compact → shutdown → restart provider, reset counter>= autocompact_pct - CONTEXT_WARN_MARGIN_PCT: warning printed to stderr. Relative, not absolute: the compact arm is tested first, so an absolute warn level at or above the threshold would be unreachable
CLI compaction is blocking (single-user, acceptable).
Entry Point
console_scripts in setup.cfg maps kirocrew → kiro_crew._bootstrap:main.
Gateway asyncio child watcher
_install_child_watcher() runs once on the gateway command path only (not
chat, doctor, or any other subcommand) and must be called before
asyncio.run, on the main thread. It replaces CPython's default
thread-per-child ThreadedChildWatcher — whose os.waitpid reaper threads can
starve the event loop when many kiro-cli/MCP children die at once — with a
single-descriptor alternative:
| Runtime | Installed watcher |
|---|---|
Linux, os.pidfd_open probe succeeds (kernel ≥ 5.3) | PidfdChildWatcher |
Linux, probe raises OSError/AttributeError | SafeChildWatcher (SIGCHLD) |
| macOS / other non-Linux Unix | SafeChildWatcher (SIGCHLD) |
| Python ≥ 3.14 (child-watcher API removed) | none — no-op |
SafeChildWatcher unavailable (e.g. Windows) | none — default retained |
Python 3.14+ is a deliberate no-op. CPython 3.14 removed
set_child_watcher, PidfdChildWatcher, SafeChildWatcher, and
ThreadedChildWatcher; the Unix event loop reaps children itself with a single
non-thread reaper, so the loop-starvation wedge this installer exists to prevent
cannot occur. The function short-circuits on hasattr(asyncio, "set_child_watcher") — probed by capability, not sys.version_info, so a
runtime that still ships the API keeps the mitigation. Without that guard the
Linux pidfd branch raised AttributeError and kirocrew gateway died before
binding its port, while every other subcommand kept working.
Linux gateway heap reclamation
The event-loop heartbeat offers a self-gating maintenance object a tick every
five seconds. At most once every ten minutes on Linux, it reads current RSS
directly from procfs. When RSS is at least 1.5 GiB, it asks glibc
malloc_trim(0) to return wholly-free heap pages to the OS and logs reductions
of at least 16 MiB. The probe and allocator call run in a worker thread, after
the dashboard socket has bound, so maintenance cannot delay readiness or block
the event loop. Healthy gateways remain below the threshold and do no
allocator-wide work.
The heartbeat waits at most two seconds for a pass. A timed-out worker keeps the
single in-flight slot until it exits, preventing repeated submissions or a
watchdog-triggering wait when the executor is saturated. Missing ctypes,
non-glibc libc, failed current-RSS probes, and rejected trim calls are
best-effort no-ops: reclamation must never stop the liveness heartbeat or make
the gateway unavailable. macOS and Windows are unchanged.
Live-target bootstrap
On the gateway command path only, immediately after _JAILED_COMMANDS
attestation and before the --seed handler:
if args.command == "gateway":
from kiro_crew.service.live_target import maybe_reexec
maybe_reexec(sys.argv[1:])
maybe_reexec reads the live-target pointer (config_dir() / "live_target.json")
and, when it names a different checkout, os.execves into that checkout's own
kirocrew binary. This runs before anything is written to $KIROCREW_HOME,
before the gateway lock is acquired, and before any socket is bound — so exec'ing
away leaves nothing half-done. It is fail-safe: an absent, unreadable,
malformed, or stale pointer (missing binary, same image already running, or
KIROCREW_LIVE_EXECED marker already in env) causes the function to return, and
the currently-installed build boots normally. A bad pointer can never leave the
host with no gateway.
Gateway only — a plain CLI invocation (kirocrew doctor, kirocrew chat, etc.)
keeps running the install the user typed, not a worktree someone made live.
Environment Variables
| Variable | Purpose |
|---|---|
KIROCREW_HOME | Override config/data directory (default ~/.kiro/crew) |
KIROCREW_PORT | Override dashboard port (default 5476, validated as int at CLI startup) |
KIROCREW_PROJECT_DIR | Override agent config/skills directory |
KIROCREW_WORKSPACE | Override workspace root directory |
For local dev:
- macOS/Linux:
bin/kirocrew(POSIX shell wrapper);source setup.shaddsbin/to PATH
The wrapper sets KIROCREW_PROJECT_DIR and routes to the right runtime based on install type:
- One-liner install (
install.shclones the repo into~/.kirocrew-app/): if a sibling.venv/bin/kirocrewexists, the wrapper execs it directly. - pip editable install (
pip install -e .): the console_scripts entry point resolves directly.
Setup Scripts (First-Time Bootstrap)
setup.sh (macOS/Linux) auto-installs all dependencies from scratch using public tooling only.
Note: Windows is not supported.
Install order:
- Node.js (via
ensure-node.sh) - Optional tools (git-lfs, ffmpeg for voice)
- kiro-cli (
npm i -g) - kiro-cli login (guided authentication)
- Frontend build (
npm install && npm run build) - Backend build (
pip install -e .) - PATH setup + shell profile persistence
kirocrew setup --agent-only(install kiro-cli agent config)- Optional Slack credential configuration (
kirocrew setup --slack)
Each step checks if the tool is already installed and skips if present.
Doctor Checks
kiro-clibinary in PATH- Project directory and git repo
- Agent config installed
- Config values (provider, model, approval mode, dashboard port)
- MCP tools:
@kirocrew-cronand@kirocrew-coreintools,allowedTools, andmcpServers— auto-fixes missing entries - Global mcp.json: kirocrew MCP servers present with valid binary paths — auto-fixes stale paths
- Python environment: checks Python 3.9+ availability and dependency installation
- Vector memory (in-process embeddings): vendored llama-cpp-python runtime importable, embedding model file present (downloads in background on gateway start; when absent, a light HTTPS-reachability probe of the resolved model URL runs); embeddings are always-on (
embeddings: ✅ always-on). On platforms with no vendored native libs (_platform_libs_dirname()returns None, e.g. darwin/x86_64 — Intel Macs or a Rosetta interpreter), the runtime line reports⏹ unsupported platform … — memory uses keyword searchand is NOT counted as an issue (designed degradation perembeddings.py); only a load failure on a supported platform flagsembedding runtime. When that failure is an INCOMPLETE shipped payload, doctor additionally names the absent files (Missing native libs for <platform>: …, fromembeddings.verify_vendored_libs()) and says it is a packaging defect rather than an unsupported platform — the two are indistinguishable in ctypes' ownShared library with base name 'llama' not found, which reads as an architecture problem and misdirects diagnosis. WhenLLAMA_CPP_LIB_PATHis set, doctor reports THAT directory as the thing to check instead (mirroring the loader's exemption): the libs load from there, so blaming the bundled tree would send the operator to reinstall a package they are deliberately not loading from. Afaiss:line reports whether the optional FAISS accelerator is importable — never an issue on any platform (episodic recall falls back to the stdlib cosine scan); when absent it suggestspip install faiss-cpu - Speech-to-Text (optional): recognizer, selected model and audio-decoder presence when STT is enabled. Supported desktop releases bundle and build-gate the recognizer plus the pinned
imageio-ffmpegexecutable; a missing decoder there is a corrupt payload whose remedy is reinstalling Kiro Crew, never Homebrew/Winget/Apt. Source installs usekirocrew[voice]for the recognizer and a fixed system FFmpeg path for compressed audio. Windows preserves its non-fatal⚠️marker for an optional-extra gap so enabled-by-default STT cannot block gateway startup; on macOS/Linux a missing active runtime still flags an issue. - Slack credentials (optional)
- Discord (optional): the channel's enabled flag, whether a bot token is present (never any part of its value), the three allow-lists, the privileged Message Content intent, the live connection, and the install URL. Blocking issues are enabled-without-a-token, an empty
discord.allowed_user_ids(the transport fails closed, so every message is denied while it is empty), a thread or channel allow-list with Message Content OFF, and a reachable gateway whose Discord connection recorded aconnect_error. The intent state comes fromdiscord/intent_probe.py: one read-onlyGET /oauth2/applications/@methat decodes Discord's application-flags bitfield as a tri-state per intent PAIR (enabled/limited/disabled, since a limited grant still delivers the data) and degrades tounknownon any failure rather than aborting the report. Granted-but-unused Server Members / Presence intents are hardening notes, never issues. The install URL comes fromdiscord/install_url.py, the OAuth-authorize analogue of Slack's app manifest: named permission bits OR'd to309237711936for a thread-capable install (the numberdiscord-integration.mdpublishes), and none at all for the recommended DM-only install - WhatsApp (optional): printed whether or not the channel is enabled, because a channel that is invisible in the preflight is the failure this section exists to catch. When enabled it reports the optional
neonizeextra, checked withfind_specand never imported (importing it loads a ~19 MB ctypes CDLL plus protobuf descriptors, and a health check must not initialize the subsystem it inspects, nor construct a client), and whether the linked-device session store exists at<data home>/whatsapp/session.db, resolved from the same expression the channel opens it with so the two can never describe different files. A missing extra IS an issue: the channel is enabled, cannot start, and the fix is one offlinepip install. An absent store is a⚠️note and never an issue, because pairing is a QR scan served BY the running gateway, so failing here would break the documentedkirocrew doctor && kirocrew gatewaychain at the one moment the operator has to start the gateway to make progress. Group membership is not knowable offline, so the section reports the configured count and the gateway logs the unmatched JIDs on connect - kiro-cli connectivity
- Gateway running status
- Cron job health: names cron jobs that auto-paused after repeated failures (
Fix: kirocrew cron resume <id>) and jobs whose last run errored while still scheduled (Fix: kirocrew cron trigger <id>), with an aggregate count each and the named list capped at 5 plus a+N moretail. Read-only — doctor never resumes or triggers a job, because an auto-pause after_AUTO_PAUSE_THRESHOLDconsecutive failures is usually load-bearing and lifting it silently would hide the problem the run is meant to find. The scan iscron.unhealthy_jobs_from_disk(), which readscrons.jsondirectly rather than via the gateway API: the dashboard's per-joberrbadge and the gateway's hourly failure re-alert both run inside the gateway, so neither can report a wedged one, and that out-of-process property is the point of this check. A job the user paused explicitly is never reported (onlyauto_pausedis a health signal, and both flags can be set at once since pausing an auto-paused job preservesauto_paused). Silent on a healthy store and on a fresh install with nocrons.json. A store that EXISTS but yields no readable job list — unparseable, non-UTF-8, wrong shape, or holding no usable record — is reported instead, because the scheduler can load nothing from it and every job has stopped; reporting that as a clean bill of health would reproduce the silence this check exists to break. No corruption aborts the run
Update Command
kirocrew update pulls the latest source and rebuilds:
git fetch+git reset --hard origin/<branch>fromKIROCREW_PROJECT_DIR. The reset only runs for a FAST-FORWARDABLE checkout — behind its upstream and not ahead of it (git rev-list --count --left-right HEAD...origin/<branch>shows behind > 0, ahead = 0) — mirroring the dashboard check's verdict, because the hard reset discards committed local work and the uncommitted-changes prompt does not cover it. A DIVERGED checkout (both sides non-zero) is refused with a non-zero exit and a rebase-or-merge instruction;--forceis the explicit opt-in that lets the reset discard the local commits. An ahead-only checkout has nothing to pull and is reported as up to date without resetting (even under--force— the flag lets a real update discard diverged work, it does not delete commits when there is nothing to update to). An unreadable comparison refuses (fail closed). Uncommitted tracked changes still prompt before being discarded — and because that prompt makes the gap to the reset unbounded, the divergence count is re-taken immediately before the reset and refuses commits that appeared while the update was waiting (committing the listed edits in another terminal to rescue them is the natural response to the prompt, and is exactly what would otherwise be reset away). OnlyHEADcan move in that window, so the re-check needs no second fetch.- Rebuilds the dashboard via
build_frontend_sync()(npm; non-fatal on failure). Non-fatal also means non-destructive:npm cideletesnode_modulesbefore it installs, so the tree is moved aside and restored unless the install succeeds (node_modules_txn.NodeModulesBackup). A failed install therefore costs the rebuild, not the dependency tree -- which matters because the registry needed to rebuild one is usually what was unavailable. The gateway's unattended auto-apply takes the async sibling and is protected the same way. - Reinstalls backend via
pip install -e .
Client Port Resolution
kirocrew token / status / logout / stop / restart must find the port
the gateway is actually bound to. port_resolution.resolve_client_port()
(re-exported by cli_server) resolves it
in this order, first hit wins. The MCP stdio servers (mcp_core /
mcp_computer) resolve their gateway API base through the same helper —
lazily, on the first gateway call, and cached for the process lifetime — so a
loopback callback and a client CLI command always agree on which gateway they
are talking to:
- An explicit
--port Nflag (0counts — the check isis not None). KIROCREW_PORT, when it parses as an int. Deliberately above the bound export: an explicitly-setKIROCREW_PORTis how a caller retargets a child at a DIFFERENT gateway —pod execbuilds a client env withKIROCREW_PORT=<pod-port>while the inheritedKIROCREW_BOUND_PORTstill names the spawning live gateway, and the bound value outranking it would walk podtoken/status/logoutinto the live plane. (build_pod_envadditionally scrubsKIROCREW_BOUND_PORToutright.)KIROCREW_BOUND_PORT, when it parses as an int — the port the parent gateway actually bound, exported once its TCP site is listening (dashboard.server._export_bound_port). Never persisted —service_environment()deliberately does not capture it.- A port explicitly written in
dashboard.url. A portless URL (http://my.host) is not a port choice:parse_dashboard_url()substitutes5476for the server's benefit, so the client re-splits the URL and only accepts the port when it was actually named. - The sole gateway-owned run-marker. A running gateway records
<data-home>/run/gateway-<port>.bin(seekiro_crew.instances.run_marker, written for the SSH token-mint), so its filename already advertises the port. A client with nothing configured reads the marker names — never the file contents — and uses that port. Two guards keep it from being a guess:-
Ownership, not reachability —
clear_marker()only runs on graceful shutdown, so a crash leaves a stale marker behind and an unrelated process may since have bound that port. Because_token/_logoutsendX-Local-Secretto whatever answers, a bare "is something listening" probe would walk the local secret into that process. A command-line check is not enough either — argv is attacker-chosen, so a listener started as/tmp/kirocrew gatewaywould pass it._gateway_owns_port()therefore requires three things, none sufficient alone:- the pid recorded in
run/gateway-<port>.pid(written0600inside the0700run/dir, which is on theis_sensitive_pathfloor, so neither another local user nor an agent file tool can write it); - that pid must be among the pids listening on the port
(
platform_compat.find_listening_pids), which is what makes a stale recorded pid harmless; - that pid must be owned by the calling uid
(
platform_compat.process_owner_uid) and look like a KiroCrew process. The uid check is what closes pid recycling into a foreign user's process; argv is retained only as defense in depth.
It fails closed at every step: no sidecar, an unparseable pid, a pid that does not hold the port, an unresolvable uid, a missing
lsof/netstat, or a throwing lookup all deny, and discovery is skipped. A same-user attacker is out of scope by construction — they can already read.local_secretunder their own uid.On non-POSIX platforms the step denies outright.
process_owner_uidcannot report an owner on Windows, and aKIROCREW_HOMEwritable by another user would let them replace both the marker and the sidecar with a forged listener — the file-permission argument that carries requirement 1 stops holding there. So discovery is skipped rather than approximated: Windows users keep--port/KIROCREW_PORT, exactly where they were before this fallback existed, so nothing regresses. - the pid recorded in
-
Ambiguity — with several gateways up there is no basis to pick one, so the step refuses, prints the candidate ports and the
--port/KIROCREW_PORThint to stderr, and falls through.
-
_DEFAULT_PORT(5476).
Steps 3 and 5 are what make a single gateway started on a non-default port
(kirocrew gateway --port 6776) reachable from a bare kirocrew token with
zero configuration; before it existed, the client hit a dead 5476 while the
marker naming the live gateway sat unread. Config-load, URL-parse (including a
non-string dashboard.url, which raises TypeError rather than ValueError),
and discovery failures all degrade to the next step — a client command never
dies on a bad config or an unreadable data home.
Because restart resolves a port and then polls it for readiness, it passes the
resolved port to the detached replacement (_spawn_detached_gateway(port)). The
child re-resolves independently, so without that the replacement could bind 5476
while the parent waited on the discovered port.
The marker is written for every dashboard-serving gateway, including a
source-tree python -m kiro_crew launch with no console script beside
sys.executable: in that case the .bin file is written empty, which is inert
for the token mint (its shell clause requires a non-empty executable path) but
still advertises the port for discovery. The pid always goes to the separate
.pid sidecar — never into the marker, whose contents mint cats and execs. A
--slack-only gateway serves no dashboard, so it writes no marker — there is no
client port to discover.
Writing a marker also prunes markers naming other ports. A gateway is a
singleton per data home (gateway.lock), so any other port's marker is residue
from a run that crashed before clear_marker() could fire. Unpruned, they
accumulate one per port ever used and each costs every client command an extra
listener lookup, making discovery slower the longer a dev box churns ports. The
live gateway is the only writer and knows which port is current, so it is the
right place to reap them; pruning is best-effort, and the ownership check still
rejects anything it misses.
CLI→gateway requests are built against the literal 127.0.0.1, never the name
localhost. On a dual-stack host localhost can resolve to ::1 first, and the
listener verification is address-agnostic (lsof -ti TCP:<port> cannot tell an
IPv6 squatter from the real IPv4 gateway), so a name-based URL could deliver
X-Local-Secret to a socket other than the one that was verified. The URL
printed for the browser still uses resolve_dashboard_host() (localhost) —
that must not change, because the SPA's per-origin localStorage is keyed on it.
Stop Command
kirocrew stop [--port PORT] stops a running gateway:
- If a systemd/launchd service is active and the caller did not pass
--portexplicitly (see Service Management), stop it via the service manager and return — without this branch, SIGTERM-by-port would be racing the manager's auto-restart. - Otherwise (no service active, or
--portwas passed explicitly to target a non-default dev gateway):platform_compat.find_listening_pids(port)to find PIDs —lsof -ti TCP:{port} -sTCP:LISTENon POSIX,netstat -anoparsing on Windows (there is nolsofthere; this previously madekirocrew stopa no-op on Windows). Both binaries are resolved throughplatform_compat.trusted_system_bin()— the fixed system directories, neverPATH, which on a gateway can lead with same-uid-writable dirs — and a name that does not resolve there counts as absent rather than falling back.listening_pid_tool_available()performs the same pinned resolution, so it distinguishes "no listener" from "lookup tool missing" without disagreeing with the lookup it describes. The pinned set is the FHS directories plus/run/current-system/sw/bin, which is root-owned and rewritten only by a system rebuild. A tool installed anywhere else — a Homebrew or conda prefix — still reads as absent, and deliberately so: those prefixes are writable by the invoking user, which is the exposure the pin exists to close.trusted_system_bin()logs a warning once per name when the tool is onPATHbut not resolvable under the pin, andtool_outside_trusted_dirs()letsstopname where the tool actually is rather than tell an operator who already has it to install it. That case carries SELreason=<tool>_outside_trusted_dirs, distinct from<tool>_not_found, so the two are separable in the audit log. platform_compat.process_command_line(pid)to verify it's a KiroCrew process —/proc/<pid>/cmdline(Linux),ps -o command=(macOS),Win32_Process.CommandLinevia WMI (Windows). The Windows venvkirocrew.exere-execspython.exe, so the match is on the command line (-m kiro_crew gateway/\Scripts\kirocrew.exe gateway), not the image name.- Terminate each verified PID:
os.kill(SIGTERM)on POSIX;taskkill /T /F(viaplatform_compat.kill_process_tree) on Windows so the gateway's detached children are reaped too. Liveness is probed withplatform_compat.pid_exists(a rawos.kill(pid, 0)would terminate the process on Windows). - Waits up to 1s for exit.
- SEL audit event logged.
Restart Command
kirocrew restart [--port PORT] restarts a running gateway. Mirrors
stop's service-aware structure:
- If a systemd/launchd service is active and the caller did not
pass
--portexplicitly, ask the platform to restart it. On Linux:sudo systemctl restart kirocrew.service(single atomic operation, smaller down-window than stop+start, and the supervisor stays in charge of the lifecycle the whole time). On macOS:launchctl unload <plist>+launchctl load <plist>(no-w, so persistent enable state is unchanged). The deprecatedlaunchctl restartis avoided because underKeepAliveit behaves likestop(SIGTERM + immediate respawn) and never re-reads the plist. - Otherwise (foreground gateway, no service, or
--portpassed explicitly to target a non-default dev gateway):platform_compat.find_listening_pids(port)(lsof on POSIX, netstat on Windows) to detect a running gateway. If found — OR if the lookup tool is absent (not listening_pid_tool_available(), so a missing tool is not mistaken for a dead gateway) — run the existing_stopkill-by-port path. If not (e.g. the user runsrestartafter a crash), skip the stop step rather than erroring — the user expects to end up with a running gateway either way. The_stopcall is wrapped in atry / except SystemExitso a TOCTOU race (gateway exits between the listener check and_stop's own lookup →_stopcallssys.exit(1)) does not abort the restart before the spawn.- Spawn a detached
kirocrew gatewayviasubprocess.Popen, stdin set tosubprocess.DEVNULL, and stdout + stderr redirected to~/.kiro/crew/gateway.log(the same file thekirocrew logscommand tails for foreground gateways). Detach is per-platform: POSIX usesstart_new_session=True; Windows usescreationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP(there is no setsid) — both viaplatform_compat. The shell returns immediately and the user can follow logs viakirocrew logs -f.
- SEL audit event logged with
via=serviceorvia=fork pid=<n>so the audit trail distinguishes the two paths.
Service Management
kirocrew service {install,uninstall,status} registers the gateway
with the OS service manager so it survives SSH disconnects, restarts
on crash, and starts on boot. Implemented in src/kiro_crew/service/.
- Linux (
current_platform() == SYSTEMD):- Unit file:
/etc/systemd/system/kirocrew.service(root-owned). - Install:
sudo installwrites the unit, thensudo systemctl daemon-reload && sudo systemctl enable --now kirocrew.service. Privilege is resolved per call: already-root (euid 0) skipssudoentirely — required on minimal container /root-login images that ship nosudobinary — and a non-root caller with nosudofails with a clearServiceInstallErrorrather than an uncaughtFileNotFoundError. - The gateway runs as
User=$USER Group=$(id -gn)— kirocrew code never runs under sudo. Onlyinstallandsystemctlinvocations are elevated. - Environment: values are captured from the installer's environment
into the unit's
Environment=lines at install time (service_environment()inservice/common.py) — this is howKIROCREW_PORT=5477 kirocrew service installbinds a non-default port. The unit also readsEnvironmentFile=-/etc/kirocrew/kirocrew.env, an operator-editable file the installer seeds create-if-absent (a reinstall never clobbers edits). systemd applies the file AFTER — and overriding — the bakedEnvironment=lines, so editing it and runningsudo systemctl restart kirocrewchanges a value (e.g. the port) without reinstalling. Uninstall removes the file and its/etc/kirocrewdirectory. - Credentials are deliberately NOT captured. Both baked locations are
world-readable — the unit lives in root-owned
/etc/systemd/systemand the override file is installed0644— so a model credential placed there would be readable by every local user on the host.service_environment()therefore carries no credential — its only installer-derived values arePATH,KIROCREW_KIRO_BINandKIROCREW_PORT(it also returnsHOME,LANGandLC_ALL) — and a test pins the absence so a future "just propagate it" change fails. Consequence: aKIRO_API_KEYexported in the installing shell does not reach the service, the readiness probe (which forwards that variable from the gateway's own environment) sees no credential, and unless akiro-cli logincredential store under the bakedHOMEsupplies one instead, the dashboard reports a signed-out state on a host wherekiro-cliitself is authenticated.install_service()prints a warning naming the variable and the remedy when it detects that case, and~/.kiro/crew/.envis the supported home —load_credentials()reads every key from that file into the gateway environment at boot and forces0600on it first. The warning is diagnostic only: it is non-fatal by construction, since the unit is already written and started by the time it runs.kirocrew doctorreports the same condition next to itskiro loginline — the one output where the contradiction is visible, since that line runswhoamiwith the inherited environment and reports signed in. Doctor's report is gated on a service definition existing (installed_unit_path()): without one the gateway runs in the foreground and inherits the invoking shell, so the credential does reach it and a warning would be a false positive. It is advisory only — never appended to doctor'sissues, which is the exit-code channel — since that gate establishes a definition on disk, not that the serving gateway lacks a credential; a fall-back login store, or a stopped unit beside a foregroundkirocrew gateway, both leave the host healthy while the check fires. - Boot survival via
WantedBy=multi-user.target(no linger needed — that's a user-service concept; this is system-level). - Crash-loop safety:
StartLimitBurst=3 StartLimitIntervalSec=300. - Logs are read from the journal:
sudo journalctl -u kirocrew -f, or unprivileged if the user is insystemd-journal/adm.
- Unit file:
- macOS (
current_platform() == LAUNCHD):- Plist:
~/Library/LaunchAgents/dev.kirocrew.gateway.plist - Install:
launchctl load -w <plist>.RunAtLoad=trueandKeepAliveensure auto-start and crash recovery. - Stdout and stderr are written to
~/Library/Logs/KiroCrew/gateway.{log,err}.
- Plist:
- Other platforms: install/uninstall return exit code 2 with a message pointing to manual setup.
kirocrew stop is service-aware: if the service is active it calls
the platform's stop instead of SIGTERM, so the manager does not
immediately restart the gateway under us.
Logs Command
kirocrew logs [-n LINES] [-f] tails the gateway log from whichever
source is most appropriate:
- systemd journal if the system service is installed on Linux. Tries
unprivileged
journalctlfirst; falls back tosudo journalctlonly if the unprivileged probe returns no rows. - launchd stdout file if a plist exists on macOS and that file is non-empty. Both conditions matter: the platform probe reports launchd on any macOS host, and an install that never started the agent leaves a 0-byte log behind, so either check alone would capture the command and tail nothing.
~/.kiro/crew/gateway.logfor foreground gateways
Uses os.execvp so signals (Ctrl+C) propagate naturally to the
underlying journalctl/tail process.
Dashboard Self-Update
On gateway startup and every 12 hours, a background task runs git fetch and
reports an update when EITHER the checkout can be FAST-FORWARDED
(git rev-list --count --left-right HEAD...@{u} shows commits behind and none
ahead) OR the pull already landed and only a restart is missing (HEAD equals
its upstream while the on-disk __version__ outranks the one this process
imported).
Commit distance is the primary signal because __version__ is bumped only at a
release: comparing version strings alone reported "you're on the latest version"
to a checkout hundreds of commits behind main, for as long as the next bump
took.
Both conditions are narrower than "is it behind", because available is also
read by an unattended apply. GatewayOrchestrator._auto_apply_update applies
git fetch + git reset --hard origin/<branch> with no prompt, so:
- "Behind" alone is true both for a checkout that is purely behind and for a DIVERGED one carrying its own commits, and the second would have those commits reset away. Only a fast-forwardable checkout is offered an update.
- The version signal is required to come with
HEAD == @{u}. A checkout that pulled a version bump and then committed on top is ahead, so its upstream still reads newer than the imported version; without that requirement the same reset would drop those commits.
The unattended apply is triggered by the version, not by commit distance.
The check reports version_newer beside available, and the git branch of the
auto_update path requires both. available is what the dashboard shows, and
on commit distance alone that would mean any upstream commit — resetting a
source checkout within 12 hours of one, where the version-only verdict only did
so at a release. Requiring both keeps that path firing no more often than
before. Commit distance without a version bump lights the dashboard badge, and
POST /api/update (git pull, dirty tree refused with 409) is the
non-destructive way to apply it.
- Topbar shows
📦 v0.1.3badge — click to check and view changelog - If newer version found: badge turns into "📦 Update Available"
- Clicking opens a dismissible changelog modal with rendered markdown
- "Update Now" button:
git pull→ rebuild →os.execv()restart - Health indicator shows "Updating…" during the process
- SSE auto-reconnects when the new process starts
Status Command
kirocrew status queries the running gateway's /api/status endpoint
and prints uptime, sessions, messages, tool calls, subagents, crons, lessons.
App Dev Mode
kirocrew app dev <name> [--off] toggles an installed App Kit app into (or,
with --off, out of) dev mode, which speeds the app-UI edit loop by serving
UI files uncached and live-reloading the dashboard on file change. The command
writes the flag out-of-process; the running gateway's watcher picks it up within
one poll interval, so no gateway restart is needed. Full App Kit developer docs
live in docs/app-kit/api-reference.md; the durable contract surfaces this
feature introduces are:
- Persisted schema —
installed.jsondev: bool(defaultfalse): a per-app flag in each app's~/.kiro/crew/apps/<name>/installed.json. Tolerant on read (absent ⇒false), reversible, no migration. This field is the sole authoritative source of truth for an app's dev-mode state. Builtin apps cannot enter dev mode. - Endpoint —
POST /api/apps/{name}/dev, body{"enabled": <bool>}, returns{"name": <name>, "dev": <bool>}. Behind standard gateway auth; emits anapp_dev_modeSEL audit event.400for a non-boolean body, a builtin app, or an unsafe app name;404when the app is not installed. Equivalent to the CLI toggle for in-dashboard control. - WebSocket event —
app_reload, payload{"app": <name>, "ts": <float>}, broadcast when a dev-mode app'sui/tree changes; the dashboard reloads that app so edits appear immediately. - Serving behavior: while an app is in dev mode the gateway serves its UI
with
Cache-Control: no-store; otherwise the standard revalidation header applies.
An internal, unstable sentinel cache under ~/.kiro/crew/apps/ mirrors the set of
dev-mode apps so the zero-dev-apps steady state costs one stat() per second.
It is a derived cache reconciled from installed.json at watcher init (under a
cross-process lock, atomic with concurrent toggles), not part of the App Kit
contract — its path and format are internal and may change without notice.
Computer Use Commands
kirocrew computer {doctor [--json] | apps | call} — hand-rolled dispatch
mirroring browser/cli.py (see computer-use.md).
doctor reports, in order: whether the platform is supported (macOS today;
Windows and Linux report a typed refusal), whether the keystone primary enable at
~/.kiro/crew/computer_use.json is on, and the macOS TCC probe
(AXIsProcessTrusted() + CGPreflightScreenCaptureAccess()). The probe is
advisory and never a gate: macOS attributes a grant to the responsible
parent of the process tree, so both rows can read missing while a
full-fidelity capture succeeds — observed live. doctor therefore prints a
responsible_hint naming the process a user should actually grant (the packaged
app, or the terminal that launched a dev gateway) and says outright that "not
detected" does not always mean unavailable. It never calls
CGRequestScreenCaptureAccess, which would pop a system dialog from a background
process.
--json is the machine form the gateway shells out to for the Settings
permission rows. That indirection is deliberate: a short-lived subprocess keeps
native ctypes out of the gateway, so a native fault cannot take down the gateway
and with it cron, Slack and the dashboard WebSocket.
apps lists on-screen applications resolved from
CGWindowListCopyWindowInfo (layer-0 windows only, never pgrep — a pgrep -n
lookup returns short-lived helper pids whose accessibility tree is empty). It runs
computer_list_apps through the SAME gated dispatcher as call, so it is refused
while the feature is disabled, in an unattended session, or under a policy that bans
computer use — the agent can run this command with bash, so an ungated version was
an unauthorized read of every window title.
call runs one tool — call computer_get_state app=Finder — or a whole
sequence in ONE process: call --calls '[{"tool":"computer_get_state","args": {"app":"Finder"}},{"tool":"computer_click","args":{"app":"Finder", "element_index":12}}]'. The batch form exists because element_index values only
resolve against the per-process snapshot cache that produced them, so two separate
invocations cannot share them. key=value arguments are JSON-decoded when they can
be (element_index=3 → int, screenshot=false → bool) and kept as text otherwise
(app=Finder). --json emits [{tool, text}, …]; the exit code is non-zero if any
reply carries the Error: prefix, and a batch runs to completion rather than
aborting at the first refusal.
call goes through computer_use.tools.dispatch_tool, the same chokepoint an
agent call traverses, so the primary enable, the target policy and the secure-field
floors all apply — it is a reproduction tool, not a bypass. Its session key is the
attended cli_chat surface, which is what the SEL audit records. There is no
separate diagnostics opt-in and no identity proof: the unattended-surface refusal
that made one necessary was removed along with the rest of the computer-use
governance model.
All three are human-facing. apps has an MCP twin (computer_list_apps) per
the MCP-first rule; doctor is a permission diagnostic rather than a capability,
so the rule does not bind it; and call adds no capability at all — it is a
harness over the eleven existing MCP tools, and deliberately has no MCP twin,
because a tool that runs other tools would let a model launder one per-call gate
decision into many. There is deliberately no kirocrew computer state <app> —
that would be a second, CLI-shaped spelling of an LLM-facing capability and would
have to be an MCP tool instead (it is: computer_get_state).
Gateway Test Harness
Four composable flags let an integration test or eval harness boot a gateway deterministically, with no model and no developer-machine state:
kirocrew gateway --test-mode # bundle: ephemeral port + json-ready + reads approval
kirocrew gateway --port auto # OS-assigned port, avoiding a collision with a real gateway
kirocrew gateway --json-ready # print KIROCREW_READY:{port,token,pid,home} once listening
kirocrew gateway --approval reads # auto-approve read-only tools
kirocrew gateway --approval yolo # auto-approve ALL tools
--json-ready is what makes the harness race-free: the caller waits for the
KIROCREW_READY line instead of polling a port, and reads the token from it rather
than minting one.
--approval yolo refuses to start unless KIROCREW_HOME is explicitly set to a
non-default path. The flag disables every per-call approval, so pointing it at the
real data home would let a test drive an operator's live sessions and credentials.
The guard is a startup refusal rather than a warning because a warning in CI output
is not read.