cli-bridge Β· diff

v0.3.0 to v0.3.3

60 added, 1 removed. Audit A to B.

---
name: cli-bridge
- version: 0.3.0
+ version: 0.3.3
description: |
Manage short-code bundles that authorize the local starchild CLI to talk to this agent, including the agent-shell local-exec channel.
Use when connecting or disconnecting the starchild CLI (e.g. mint a CLI bridge code, list my CLI bundles, revoke an old CLI session, or let the agent run shell commands on the user's own machine).
delivery: script
metadata:
starchild:
emoji: "πŸ”—"
skillKey: cli-bridge
requires:
bins: [python3]
user-invocable: false
author: starchild
tags: [cli, akm, bridge, sc-chatroom, short-code]
---
# cli-bridge β€” issue CLI bundles for the user's own `starchild` binary
This skill mints a fresh AKM key (`scope=chat:bridge:cli`) on the local
clawd, then registers it with sc-chatroom in exchange for a short opaque
code (``sc_xxxxxxxx``). The bundle handed to the user contains only that
short code β€” never the AKM secret, never the Fly machine id.
```
+----------------+ POST /agent/chat/stream +-----------------+
| starchild CLI | Bearer sc_xxxxxxxx | sc-chatroom |
| (user laptop) | --------------------------> | (gateway) |
+----------------+ +--------+--------+
|
resolves sc_… β†’ AKM + container_id
|
v
POST /chat/stream (Bearer sk_…
+ fly-force-instance-id)
+----------------------+
| user's own clawd |
| (Fly internal) |
+----------------------+
```
## Why a short code instead of the raw AKM?
Earlier versions baked the AKM secret + Fly machine id into the bundle
directly. That worked but had two downsides β€” the bundle leaked routing
metadata when decoded, and any party that ever held the bundle held a
permanent AKM secret. The short-code form fixes both:
- Bundle base64 decodes to ``{d, c:"", k:"sc_…", s, exp, l}`` β€” no
secret, no Fly machine id.
- ``cli-revoke <sc_…>`` kills just the short code; the underlying AKM
stays alive (use ``cli-revoke --akm <prefix>`` to nuke that too).
- sc-chatroom now holds the AKM secret in its DB. That's a deliberate
trust shift β€” the AKM stays inside Fly's internal network instead of
riding around on user laptops.
## Scope boundary β€” read this first
`cli-bridge` covers **exactly one path**: the user's local CLI talking 1:1
to that user's own clawd. It is **not** a chatroom membership credential.
| Use case | Right credential | Wrong |
|---|---|---|
| Personal CLI ↔ own clawd (this skill) | `chat:bridge:cli` AKM, fronted by `sc_…` code | β€” |
| Join an sc-chatroom room | `chat:thread:chatroom-{room_id}` AKM via `chatroom join` | `chat:bridge:cli` AKM |
| Browse a public room as a guest | no credential needed | any AKM |
+ ## Install the CLI
+
+ The rest of this skill assumes `starchild` is on the user's `$PATH` β€” install
+ it first if it isn't.
+
+ ### One-liner (auto-detects OS + arch)
+
+ ```bash
+ curl -fsSL https://workroom.iamstarchild.com/install/cli | bash
+ ```
+
+ Picks the right binary for darwin/linux Γ— arm64/amd64, drops it on
+ `$PATH` (Apple Silicon lands in `/opt/homebrew/bin`; Linux falls back to
+ `~/.local/bin`; `sudo` only when the dir isn't user-writable), patches the
+ user's shell rc if the install dir wasn't already on `$PATH`, and runs
+ `starchild --version` as a self-check. SHA256 etag means re-running is a
+ cheap "already current" no-op (HTTP 304, no download). Source for review:
+ [tools/install-cli.sh](https://workroom.iamstarchild.com/install/cli)
+ (`__SERVER_URL__` is rewritten at request time).
+
+ ### Homebrew
+
+ ```bash
+ brew tap starchild/tap https://github.com/Starchild-ai-agent/homebrew-tap
+ brew trust starchild/tap
+ brew install starchild
+ ```
+
+ The `starchild` formula ships binaries for **macOS (arm64 / amd64) and
+ Linux (arm64 / amd64)** β€” `brew install` picks the right one for the host.
+ The formula has no `bottle` block, so install runs a tiny Ruby script that
+ downloads the prebuilt binary from the server (`workroom.iamstarchild.com`)
+ and drops it on `$PATH` β€” there's no local compile step. To upgrade later:
+ `brew update && brew upgrade starchild`.
+
+ **Linux caveat:** Homebrew itself works on Linux, but expects a Ruby +
+ build toolchain (one-time `apt install build-essential ruby` / distro
+ equivalent). For a Linux host, the oneliner above skips that and is
+ functionally identical, so prefer it unless the user is already a brew
+ user. **`starchild-app` (the desktop workspace) is macOS-only** β€” that
+ formula builds from source (rust + node) and only the macOS build is
+ meaningful.
+
+ ### Verify
+
+ ```bash
+ starchild --version
+ ```
+
+ If you just ran the one-liner and your shell still says `command not found`,
+ open a new terminal β€” the PATH update is in your rc, not the current
+ session.
+
## Prerequisites
Same as `chatroom`:
- AKM is installed in this clawd (`POST /api/keys` works on loopback)
- AKM accepts `scope="chat:bridge:cli"` and the `/chat/stream` middleware
allows arbitrary `thread_id` for that scope (already shipped in clawd
branch `aladdin/feat/akm-chatroom`)
- sc-chatroom is on a build that includes `POST /cli-keys` (migration 007+)
- `FLY_MACHINE_ID` (or `CONTAINER_ID`) env is set
- `CHATROOM_PUBLIC_URL` env points at the sc-chatroom gateway (defaults
to `https://workroom.iamstarchild.com`)
- `CHATROOM_SERVER_URL` env points at the Fly-internal sc-chatroom
(defaults to `http://sc-chatroom.internal:8080`)
## Commands
### `cli-login` β€” mint a new bundle
```bash
python3 skills/cli-bridge/scripts/cli_login.py --label "my laptop"
python3 skills/cli-bridge/scripts/cli_login.py --label "codex-vm" --ttl-days 14
```
Default TTL is 90 days; max is 365 days. Output is a one-liner the user
copies into `starchild login`. The bundle is opaque β€” sc-chatroom
resolves it on each call.
### `cli-list` β€” show active bundles
```bash
python3 skills/cli-bridge/scripts/cli_list.py
python3 skills/cli-bridge/scripts/cli_list.py --include-revoked
```
Lists every CLI short code minted by this user on sc-chatroom. Columns:
code, issued, expires, uses, label.
### `cli-revoke` β€” kill a bundle
```bash
python3 skills/cli-bridge/scripts/cli_revoke.py sc_xxxxxxxx
python3 skills/cli-bridge/scripts/cli_revoke.py --akm sk_yyyyyy
```
Default: kills the short code in sc-chatroom; underlying AKM stays alive.
With `--akm`: also revokes the AKM on local clawd, taking out every
bundle backed by it.
## Local shell via `agent-shell` (CLI β‰₯ v0.2.0)
A `cli-login` bundle **minted with `--enable-shell`** also authorizes the
agent to run shell commands on the **user's own machine** β€” for "is nginx
running on my laptop", "organize ~/Downloads", and the like. A plain bundle
is a chat bridge only and grants no shell access (see "Shell is off by
default" below). The user starts a small daemon:
```bash
starchild agent-shell # daemonizes; holds a WS open to your clawd
starchild agent-shell --foreground # attach to the terminal for debugging
starchild agent-shell-stop # stop the daemon
```
`agent-shell` refuses to start if the logged-in bundle wasn't granted shell
β€” it tells the user to get a `--enable-shell` bundle rather than connecting
a channel clawd would reject.
The daemon is single-instance (pidfile + flock) and macOS/Linux only. It
self-updates at startup and periodically; downloaded binaries are verified
against an embedded Ed25519 release key before swapping, so a hostile or
MITM'd update server can't push arbitrary code to the user's machine.
How it works: the daemon dials `wss://<chatroom>/ws/cli-shell` with the
bundle's `sc_…` code. sc-chatroom resolves the code and **reverse-proxies**
the WebSocket to the user's clawd machine β€” it accepts the laptop's
upgrade, opens its own upstream WS to clawd pinned with
`fly-force-instance-id`, and pumps bytes between the two (this is *not*
`fly-replay`: chatroom and clawd are different Fly apps, and cross-app
replay is rejected with 403). The AKM is injected server-side on the
upstream hop β€” it never reaches the laptop. clawd holds the connection in
its `ShellHubService`; the `local_shell` tool is then exposed to the LLM
**only while a shell-capable laptop is connected**, and pushes commands
down the socket.
### Shell is off by default (capability gate)
`cli-login` does **not** grant shell unless `--enable-shell` is passed. The
AKM is the authoritative capability source: clawd reads it on the
`/ws/cli-shell` handshake and refuses every exec for a connection that
doesn't carry `shell` (#264). So a leaked plain bundle is a chat credential,
never local RCE.
- Grant shell: `cli_login.py --label … --enable-shell` β†’ AKM
`capabilities: ["shell"]`, bundle carries `x: ["shell"]`.
- Upgrade an existing no-shell bundle: you can't flip it in place β€” mint a
new `--enable-shell` bundle, `starchild login` it, and `cli-revoke` the
old one. Privilege escalation always goes through a fresh issuance.
### What the agent knows up front (capability manifest)
On connect, the daemon sends a `hello` frame advertising:
- **Platform** β€” `os` (darwin/linux), `arch` (arm64/amd64), and the active
`shell`. So the agent knows whether it's talking to BSD or GNU userland,
which package manager to assume, etc. β€” no more guessing `ps` flags or
hitting `ps: illegal option`.
- **Policy summary** β€” `mode` (`default-deny` when no allow rules exist, else
`allowlist`), the user's `allowed` rules, explicit `denied_extra` rules,
and the always-on `builtin_denied` list.
+ - **File-transfer policy** β€” the `transfer_dir` (always-allowed workspace),
+ `yolo` flag, and the `read_allow` / `write_allow` globs from
+ `~/.config/starchild/file-policy.toml`. Present only when the bundle
+ carries the `files` capability. See "File path policy" below for the
+ full rules; this bullet is just so the agent knows the laptop
+ advertised file transfer at all.
clawd renders this into the agent's system prompt (only while connected),
so the agent picks a permitted command β€” or tells the user plainly that the
local policy forbids it β€” instead of probing blindly.
### Session behavior
- **Connection-level cwd.** Each command's resulting working directory is
echoed back (via a trailing-`pwd` sentinel stripped from stdout) and
persisted for the next command, so `cd` has real meaning across calls
within a session β€” without the cost/fragility of a full PTY. An explicit
per-call cwd overrides it.
- **Output truncation.** stdout/stderr are each capped at 200 lines (plus a
byte cap) so a `find /` or log dump can't flood the LLM context. The full
pre-truncation line count is reported (`stdout_lines` / `stderr_lines`),
and `truncated: true` is set β€” the agent can say "showing first 200 of N
lines" rather than truncating silently.
- **Heartbeat.** The daemon pings every 45s to keep the idle WebSocket
alive (Fly's edge cuts idle sockets at ~2.5min). Exec runs in a goroutine
so a long command doesn't block heartbeats.
### Local execution policy (the only auto-run guard)
The daemon runs headless (no TTY to prompt on), so every command is
gated by `~/.config/starchild/exec-policy.toml` (parsed as a tiny
YAML `allow:`/`deny:` line format β€” no TOML dependency, despite the name).
Rules are **substring** matches by default; wrap a rule in `/ /` for a
regex:
```yaml
allow:
- "ls"
- "cat "
- "/^git (status|log|diff)/"
- "ps"
deny:
- "git push"
```
Decision order: **built-in deny (always wins) β†’ file `deny` β†’ file
`allow` β†’ default-deny.** Two hard rules apply regardless of the file:
- A built-in deny list of interactive/TTY-blocking and destructive
commands is **always** refused: `vim`/`vi`/`nano`/`emacs`,
`less`/`more`/`man`, `top`/`htop`/`btop`, `ssh`/`telnet`, `sudo`/`su`/
`doas`, `tmux`/`screen`, `reboot`/`shutdown`/`halt`, plus the shapes
`rm -rf`, `mkfs`, `dd if=`, `… | sh`, `… | bash`, `> /dev/sd*`.
- **Default-deny:** anything not matched by an `allow` rule is denied. So
with no policy file the policy `mode` is `default-deny` and nothing runs
until the user opts commands in.
### Limitations
- **Unattended policy only.** There is no interactive approval prompt; the
policy file is the sole guard. A future version adds a web-approval popup.
- **Synchronous commands only.** No background jobs / progress polling yet.
- **macOS/Linux only.** The daemon refuses to run on Windows.
- **Revocation:** `cli-revoke <sc_…>` kills the short code; the daemon's
next reconnect then fails auth and the channel closes.
## File transfer via `agent-shell` (CLI β‰₯ v0.3.0)
When the bundle is minted with `--enable-files`, the same `agent-shell`
daemon also serves **file transfer** between the user's machine and the
agent's workspace. Content streams disk→disk and never passes through the
chat, so **large/binary files (10MB+ PDFs, images, archives) work**.
Three agent-facing tools + one user command:
- `request_upload(laptop_path)` β€” agent pulls a file FROM the laptop into
`workspace/uploads/` ("take my ~/big.pdf and summarize it").
- `write_local_file(src, dst)` β€” agent sends a workspace file TO the laptop
("save workspace/output/report.pdf to my ~/Downloads"). `src` is a
workspace path, not inline content.
- `read_local_file(path)` β€” read a **small text** file for the agent to see
(config/log snippet). Large/binary files go through `request_upload`.
- `starchild push <file>` β€” user proactively uploads a local file into the
agent's `workspace/uploads/`; it's announced to the agent in its prompt.
```bash
python3 skills/cli-bridge/scripts/cli_login.py --label "laptop" --enable-files
# combine with shell if you want both:
python3 skills/cli-bridge/scripts/cli_login.py --label "laptop" --enable-shell --enable-files
```
`files` is an **independent capability** from `shell` β€” a bundle can have
either, both, or neither. Like shell, it's off by default and authoritative
on the AKM (clawd refuses transfer frames for a connection without it).
### File path policy (laptop-side, layered)
Transfers are gated on the laptop by a path policy, strictest-first:
1. **Built-in protected paths are ALWAYS refused** (even under `--yolo`):
`~/.ssh`, `~/.aws`, shell rc (`.zshrc`/`.bashrc`/…), `.config/starchild`,
launchd/systemd/cron, `.git/hooks`, browser cookie stores, `.env`, ssh
keys. Writing those would be persistent RCE; reading them leaks creds.
2. **Dedicated transfer dir** (`~/starchild-transfer`, auto-created) β€” always
allowed for read + write. The safe default workspace; prefer it.
3. **Outside that dir** β€” denied unless the path matches a `read_allow` /
`write_allow` glob in `~/.config/starchild/file-policy.toml`, **or** the
daemon was started with `--yolo`:
```bash
starchild agent-shell --yolo # allow ANY path (built-in deny still applies)
```
```yaml
# ~/.config/starchild/file-policy.toml (YAML allow-globs)
read_allow:
- "~/Documents/*.md"
write_allow:
- "~/exports/*.csv"
```
Other guarantees: written files get mode **0644** (never executable);
writes are atomic (temp file + rename, no half-written target); symlinks
that escape the transfer dir are refused; per-transfer cap is 100 MiB,
streamed in chunks so large files don't blow the WS frame limit.
> **Security note:** a running `agent-shell` (on a `--enable-shell` bundle)
> plus a permissive policy is effectively remote command execution on the
> user's machine, bounded by the AKM TTL, the `sc_…` code's validity, and the
> policy file. Defaults are conservative: shell is **off** unless explicitly
> granted, the policy is **deny-all** until commands are opted in, and the
> daemon's self-update verifies an **Ed25519 signature** before swapping
> binaries. Widen deliberately.
## End-to-end smoke test
```bash
# 1. Inside agent chat:
@agent give me a cli key for my laptop
# β†’ outputs `starchild login starchild_<base64>` (bundle has sc_… code)
# 2. On laptop:
starchild login starchild_xxx
starchild whoami
starchild "hello, who are you?"
# β†’ starchild sends Bearer sc_… to sc-chatroom; sc-chatroom resolves
# β†’ it to AKM + container_id and forwards to user's clawd
# 3. Revoke the short code from chat:
@agent revoke cli code sc_xxxxxxxx
# 4. Next CLI call should fail at the gateway:
starchild "hello?"
# β†’ "gateway rejected (401) β€” code may be revoked; ask your agent for a fresh CLI bundle"
```
## Pipe / shell composition (CLI β‰₯ v0.1.0)
Once paired, `starchild` is pipe-friendly. It reads stdin when no
positional prompt is given, writes the assistant reply to stdout, and
sends diagnostics to stderr β€” so it composes with any Unix tool.
```bash
# stdin β†’ reply
echo "explain monads in 3 lines" | starchild
# reply β†’ downstream
starchild "what is the OWASP top 10?" | pbcopy
# full three-stage pipe with streaming output
( echo "summarize this README:"; cat README.md ) | starchild --stream | tee summary.md
# code review pattern β€” concatenate context + question upstream
( echo "review this diff, flag risky changes:"; git diff ) | starchild
```
**Gotcha:** when you pass a positional prompt, stdin is **ignored**.
To send both context and an instruction, concatenate them upstream
with `( echo "<question>"; cat <file> )` rather than relying on
`cat <file> | starchild "<question>"` (which would silently drop the
file contents).
## SOUL.md hint (recommended)
Add to your agent's SOUL.md so the LLM picks the right tool when the
user asks for a CLI key:
```markdown
## Issuing CLI bundles for the user's own bots/scripts
When the user asks "give me a cli key" / "create a starchild bundle" /
"let me talk to you from my terminal", run:
python3 skills/cli-bridge/scripts/cli_login.py --label "<inferred>"
This is a chat bridge only β€” it does NOT let you run commands on their
machine or touch their files. Two independent opt-in capabilities, each
granting local access β€” only add them when the user explicitly asks:
- `--enable-shell` β†’ run commands ("run commands on my laptop", "use
agent-shell", "organize my Downloads"). Remote command execution.
- `--enable-files` β†’ read/write files ("save this to my laptop", "read my
~/notes.md"). Reads/writes files on their machine.
python3 skills/cli-bridge/scripts/cli_login.py --label "<inferred>" --enable-shell
python3 skills/cli-bridge/scripts/cli_login.py --label "<inferred>" --enable-files
Treat both as granting access to their machine β€” never add either by
default or "to be helpful". If they later want a capability, mint a new
bundle with the flag and have them revoke the old one.
Default the label to something like "untitled-YYYY-MM-DD" if the user
doesn't suggest one. Show them the resulting bundle and tell them how
to revoke: `cli-list` to find the code, then `cli-revoke sc_…`.
After pairing, mention they can also pipe into the CLI from their
shell β€” e.g. `echo "..." | starchild`, `starchild "..." | pbcopy`,
or `( echo "review:"; git diff ) | starchild`. Stdout is the reply
(pipe-safe), stderr is diagnostics. Note the gotcha: passing a
positional prompt makes stdin get ignored, so context + question
should be concatenated upstream.
```