
Cli Bridge
- 1.7k installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
cli-bridge is a credential and daemon management system that lets a user's local starchild CLI securely authenticate to their agent in Fly, with optional local command execution (agent-shell) and file transfer capabiliti
About
cli-bridge mints short-code credentials (sc_xxxxxxxx) that pair a user's local starchild CLI with their agent running in Fly. It replaces raw AKM secrets with opaque bundles, reducing exposure of routing metadata and secrets. Developers use this when building CLI integrations, agent-shell daemons for local command execution, or file-transfer workflows between a user's machine and the agent workspace. Key workflows: cli_login.py mints bundles (with optional shell/file capabilities), cli_list.py shows active codes, cli_revoke.py kills bundles or underlying AKM keys. Shell and file transfer are independent, opt-in capabilities gated by local policy files (exec-policy.toml, file-policy.toml) and AKM capabilities.
- Mint short opaque codes (sc_xxxxxxxx) instead of exposing AKM secrets or Fly machine IDs
- Optional shell capability via agent-shell daemon; respects ~/.config/starchild/exec-policy.toml with built-in deny list
- Optional file transfer (request_upload, write_local_file, read_local_file) with layered path policy and Ed25519 signatur
- Revoke short codes without killing the underlying AKM; reverse-proxy WebSocket from CLI laptop through sc-chatroom to us
- Stateful session: persists cwd across commands, truncates output to 200 lines, heartbeats every 45s to keep WebSocket al
Cli Bridge by the numbers
- 1,722 all-time installs (skills.sh)
- +60 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #728 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
cli-bridge capabilities & compatibility
None (self-hosted, uses existing clawd and AKM infrastructure)
- Capabilities
- shell execution · file read · file write · policy enforcement · revocation · heartbeat keep alive
- Use cases
- orchestration · code review
- Platforms
- macOS · Linux
- Runs
- Local or remote
- Pricing
- Free
npx skills add https://github.com/starchild-ai-agent/official-skills --skill cli-bridgeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Authorize a local CLI to authenticate and communicate with a user's agent, optionally enabling remote shell execution and file transfer.
Who is it for?
Integrating agents with local development workflows, remote command execution from chat, file analysis/generation bridging agent workspace and user filesystem, building CLI tools paired to agents.
Skip if: Chatroom membership credentials, public shared access to agents, unattended privilege escalation without local policy, Windows agent-shell (macOS/Linux only).
When should I use this skill?
User requests a CLI key, wants to run shell commands on their machine from the agent, or needs to transfer files between agent workspace and laptop.
What you get
User receives a short opaque CLI code that pairs with their agent; agent gains shell/file tools only if explicitly enabled; all actions gated by local policy and revocable via the agent.
- starchild login bundle (one-liner)
- CLI short code (sc_xxxxxxxx)
- agent-shell daemon with WebSocket connection
By the numbers
- Default CLI bundle TTL: 90 days (max 365 days)
- Output truncation cap: 200 lines per command
- File transfer per-call cap: 100 MiB, streamed in chunks
Files
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 |
Prerequisites
Same as chatroom:
- AKM is installed in this clawd (
POST /api/keysworks on loopback) - AKM accepts
scope="chat:bridge:cli"and the/chat/streammiddleware
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(orCONTAINER_ID) env is setCHATROOM_PUBLIC_URLenv points at the sc-chatroom gateway (defaults
to https://workroom.iamstarchild.com)
CHATROOM_SERVER_URLenv points at the Fly-internal sc-chatroom
(defaults to http://sc-chatroom.internal:8080)
Commands
cli-login — mint a new bundle
python3 skills/cli-bridge/scripts/cli_login.py --label "my laptop"
python3 skills/cli-bridge/scripts/cli_login.py --label "codex-vm" --ttl-days 14Default 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
python3 skills/cli-bridge/scripts/cli_list.py
python3 skills/cli-bridge/scripts/cli_list.py --include-revokedLists every CLI short code minted by this user on sc-chatroom. Columns: code, issued, expires, uses, label.
cli-revoke — kill a bundle
python3 skills/cli-bridge/scripts/cli_revoke.py sc_xxxxxxxx
python3 skills/cli-bridge/scripts/cli_revoke.py --akm sk_yyyyyyDefault: 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:
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 daemonagent-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-denywhen no allow rules exist, else
allowlist), the user's allowed rules, explicit denied_extra rules, and the always-on builtin_denied list.
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:
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
allowrule 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.
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-filesfiles 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:
starchild agent-shell --yolo # allow ANY path (built-in deny still applies) # ~/.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 runningagent-shell(on a--enable-shellbundle)
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 thepolicy 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
# 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.
# 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 ) | starchildGotcha: 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:
## 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."""Shared helpers for the cli-bridge skill scripts.
Mirror the conventions of skills/workroom/scripts/_common.py but slimmer —
cli-bridge needs loopback access to clawd's /api/keys plus authed access
to sc-chatroom's /cli-keys for the short-code exchange.
"""
from __future__ import annotations
import os
import sys
from typing import Optional
import httpx
# ---------------------------------------------------------------------------
# Env
# ---------------------------------------------------------------------------
USER_ID = os.environ.get("USER_ID", "").strip()
CLAWD_BASE_URL = os.environ.get("CLAWD_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
# Public URL of the sc-chatroom gateway. The bundle hands this to the CLI
# as the dial target — it MUST be reachable from the user's laptop, not
# the .internal one.
CHATROOM_PUBLIC_URL = os.environ.get(
"CHATROOM_PUBLIC_URL", "https://workroom.iamstarchild.com",
).rstrip("/")
# Internal URL the skill uses for its own server-side calls (POST /cli-keys).
# Defaults to .internal so the call stays inside Fly; falls back to public.
CHATROOM_SERVER_URL = os.environ.get(
"CHATROOM_SERVER_URL", "http://sc-chatroom.internal:8080",
).rstrip("/")
# Fly machine id for this clawd — replayed by sc-chatroom as the
# `fly-force-instance-id` header so the proxy lands on this exact machine.
CONTAINER_ID = (
os.environ.get("CONTAINER_ID")
or os.environ.get("FLY_MACHINE_ID")
or ""
).strip()
CLI_BRIDGE_SCOPE = "chat:bridge:cli"
def require_env() -> None:
if not USER_ID:
die("USER_ID env var is not set")
if not CONTAINER_ID:
die(
"no CONTAINER_ID/FLY_MACHINE_ID — sc-chatroom needs the Fly machine "
"id so /agent/chat/stream can route to this exact clawd. Set "
"FLY_MACHINE_ID (Fly auto-injects this on every machine) or CONTAINER_ID."
)
def get_user_jwt() -> str:
"""Return the JWT clawd uses to identify itself to other Starchild
internal services. Same logic as the chatroom skill: prefer USER_JWT
env override, fall back to CONTAINER_JWT (the long-lived token Fly
injects into every clawd container)."""
override = os.environ.get("USER_JWT", "").strip()
if override:
return override
container = os.environ.get("CONTAINER_JWT", "").strip()
if container:
return container
die(
"no identity JWT available — expected CONTAINER_JWT env (production) "
"or USER_JWT env (dev)."
)
return "" # unreachable
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
def die(msg: str, code: int = 1) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def info(msg: str) -> None:
print(msg)
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
def clawd_call(method: str, path: str, **kwargs) -> httpx.Response:
"""Loopback to this agent's own clawd — middleware recognizes 127.0.0.1
and treats it as `auth_type=internal`, so no bearer required."""
url = CLAWD_BASE_URL + path
with httpx.Client(timeout=10.0) as c:
return c.request(method, url, **kwargs)
def chatroom_call(method: str, path: str, *, user_jwt: Optional[str] = None,
**kwargs) -> httpx.Response:
"""Authed call to sc-chatroom server (Fly-internal). Bearer is the
container JWT unless overridden — sc-chatroom's auth.py treats it as
a starchild user identity."""
headers = dict(kwargs.pop("headers", {}))
if "authorization" not in {k.lower() for k in headers}:
jwt = user_jwt or get_user_jwt()
headers["Authorization"] = f"Bearer {jwt}"
url = CHATROOM_SERVER_URL + path
with httpx.Client(timeout=15.0) as c:
return c.request(method, url, headers=headers, **kwargs)
#!/usr/bin/env python3
"""cli-list [--include-revoked]
List the CLI bundles this user has minted on sc-chatroom (sc_… short
codes). Each row: code, label, created, expires, last_used, use_count.
The underlying AKM secrets are NOT shown — sc-chatroom holds them server-
side and never returns them. To see the AKM rows themselves, use the
chatroom skill's `list_room_keys` style introspection on clawd directly.
"""
from __future__ import annotations
import argparse
import datetime
import sys
import _common as C
def _ts(v) -> str:
if not v:
return "-"
return datetime.datetime.fromtimestamp(v).isoformat(timespec="minutes")
def _parse(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="cli-list",
description="Show CLI short-code bundles minted on sc-chatroom.",
)
p.add_argument(
"--include-revoked", action="store_true",
help="Also show codes the user has already revoked.",
)
return p.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = _parse(argv)
C.require_env()
qs = "?include_revoked=true" if args.include_revoked else ""
r = C.chatroom_call("GET", f"/cli-keys{qs}")
if r.status_code != 200:
C.die(f"sc-chatroom GET /cli-keys returned {r.status_code}: {r.text}")
keys = r.json().get("keys", [])
if not keys:
C.info("no CLI bundles on sc-chatroom for this user")
if not args.include_revoked:
C.info(" (re-run with --include-revoked to see revoked ones)")
return 0
hdr = f"{'CODE':<14} {'ISSUED':<18} {'EXPIRES':<18} {'USES':<6} LABEL"
C.info(hdr)
C.info("-" * len(hdr))
for k in keys:
flags = " ✗revoked" if k.get("revoked") else ""
C.info(
f"{k.get('code', '?'):<14} "
f"{_ts(k.get('created_at')):<18} "
f"{_ts(k.get('expires_at')):<18} "
f"{str(k.get('use_count') or 0):<6} "
f"{k.get('label') or ''}{flags}"
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
#!/usr/bin/env python3
"""cli-login --label "<name>" [--ttl-days N]
Mint a fresh AKM key with `scope=chat:bridge:cli` on the local clawd, then
register it with sc-chatroom in exchange for a short opaque code
(``sc_<8>``). The bundle handed to the user contains only the short code
— never the AKM secret, never the Fly machine id.
Bundle layout (matches tools/starchild/internal/identity/identity.go):
{
"d": <sc-chatroom public URL>,
"c": "" ← deprecated; resolved server-side
"k": "sc_xxxxxxxx", ← short code, server-resolves to AKM
"s": "chat:bridge:cli",
"exp": <unix expiry>,
"l": <user-supplied label>
}
Revoking the bundle is now: ``cli-revoke <sc_…>`` (kills the short code,
AKM stays alive for direct use); or ``cli-revoke --akm <prefix>`` to also
nuke the underlying AKM on clawd.
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
import _common as C
DEFAULT_TTL_DAYS = 90
MAX_TTL_DAYS = 365
DEFAULT_RATE_LIMIT = {"per_minute": 30}
def _parse(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="cli-login",
description="Mint a CLI bundle for the starchild binary.",
)
p.add_argument(
"--label", required=True,
help='Human reminder for this key (e.g. "my laptop", "codex-vm").',
)
p.add_argument(
"--ttl-days", type=int, default=DEFAULT_TTL_DAYS,
help=f"Days until the AKM key expires (default {DEFAULT_TTL_DAYS}, "
f"max {MAX_TTL_DAYS}).",
)
p.add_argument(
"--enable-shell", action="store_true",
help="Grant the `shell` capability so `starchild agent-shell` can run "
"commands on the user's machine. OFF by default: a plain bundle is "
"a chat bridge only, never local RCE. Only pass this when the user "
"explicitly asks for local shell access.",
)
p.add_argument(
"--enable-files", action="store_true",
help="Grant the `files` capability so the agent can read/write files on "
"the user's machine via agent-shell. OFF by default. Independent of "
"--enable-shell. Transfers are path-gated on the laptop (dedicated "
"dir + policy); only pass this when the user wants file transfer.",
)
return p.parse_args(argv[1:])
def _encode_bundle(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
return "starchild_" + base64.urlsafe_b64encode(raw).decode().rstrip("=")
def main(argv: list[str]) -> int:
args = _parse(argv)
label = args.label.strip()
if not label:
C.die("--label cannot be empty")
ttl_days = args.ttl_days
if ttl_days < 1:
C.die("--ttl-days must be >= 1")
if ttl_days > MAX_TTL_DAYS:
C.die(f"--ttl-days exceeds max ({MAX_TTL_DAYS}); use a shorter TTL")
C.require_env()
ttl_seconds = ttl_days * 86400
enable_shell = args.enable_shell
enable_files = args.enable_files
# 1. Mint the AKM key on local clawd. Capabilities are granted ONLY when
# the matching --enable-* flag is passed — the AKM is the authoritative
# capability source (clawd reads it on the /ws/cli-shell handshake and
# refuses exec/file frames for connections lacking the capability, #264).
# `shell` (run commands) and `files` (read/write files) are independent.
# A default bundle is a chat bridge only: even if it leaks, it cannot drive
# local_shell or file transfer.
capabilities = []
if enable_shell:
capabilities.append("shell")
if enable_files:
capabilities.append("files")
akm_body = {
"scope": C.CLI_BRIDGE_SCOPE,
"ttl_seconds": ttl_seconds,
"label": label,
"rate_limit": DEFAULT_RATE_LIMIT,
"capabilities": capabilities,
}
r = C.clawd_call("POST", "/api/keys", json=akm_body)
if r.status_code != 201:
C.die(f"clawd POST /api/keys returned {r.status_code}: {r.text}")
akm_resp = r.json()
akm_secret = akm_resp["secret"]
akm_prefix = akm_resp["key"]["prefix"]
# clawd stores expires_at as REAL (services/akm.py); the Go CLI's
# Bundle.ExpiresAt is int64 and rejects floats during JSON decode.
expires_at = int(akm_resp["key"]["expires_at"])
# 2. Register the AKM + container_id with sc-chatroom in exchange for
# a short sc_… code. After this, the AKM secret never leaves the
# Fly internal network in plaintext — bundle carries only the code.
register_body = {
"akm_key": akm_secret,
"container_id": C.CONTAINER_ID,
"label": label,
"ttl_seconds": ttl_seconds,
}
rr = C.chatroom_call("POST", "/cli-keys", json=register_body)
if rr.status_code != 201:
# Roll back the AKM — don't leave a live secret nobody references.
try:
C.clawd_call("DELETE", f"/api/keys/{akm_prefix}")
except Exception:
pass
C.die(
f"sc-chatroom POST /cli-keys returned {rr.status_code}: {rr.text}"
" (AKM rolled back)"
)
code = rr.json()["code"]
# 3. Pack the bundle. No secret inside — only the short code, the
# gateway URL, expiry, label, and the advertised capabilities (`x`).
# `x` is informational for the laptop (the AKM in clawd is authoritative);
# the agent-shell daemon reads it to decide whether to even offer shell.
# Omitted entirely for a plain bridge bundle.
payload = {
"d": C.CHATROOM_PUBLIC_URL,
"c": "", # routing target now resolved by the code
"k": code, # bearer the CLI will send
"s": C.CLI_BRIDGE_SCOPE,
"exp": expires_at,
"l": label,
}
if capabilities:
payload["x"] = capabilities # only present when shell was granted
bundle = _encode_bundle(payload)
C.info(f" ✓ minted CLI key (akm_prefix {akm_prefix}, code {code}, "
f"expires {expires_at})")
if enable_shell:
C.info(" ⚠ shell ENABLED — agent-shell on this bundle can run commands "
"on the user's machine (gated by their local exec-policy).")
if enable_files:
C.info(" ⚠ files ENABLED — the agent can read/write files on the user's "
"machine (gated by their local file-policy; dedicated transfer dir).")
if not capabilities:
C.info(" • chat bridge only (no shell, no files). Re-run with "
"--enable-shell and/or --enable-files to allow local access.")
C.info("")
C.info("First time on this device? Grab the starchild binary "
"(auto-detects your OS):")
C.info(f" {C.CHATROOM_PUBLIC_URL.rstrip('/')}/starchild")
C.info("")
C.info("Then pair the CLI by pasting this into your terminal:")
C.info("")
C.info(f" starchild login {bundle}")
C.info("")
C.info(
"The bundle no longer contains your AKM secret — sc-chatroom resolves "
f"the short code ({code}) on each call. Revoke immediately if it leaks:"
)
C.info(f" python3 skills/cli-bridge/scripts/cli_revoke.py {code}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
#!/usr/bin/env python3
"""cli-revoke <code|prefix>
Revoke a CLI bundle. Two flavors:
cli-revoke sc_xxxxxxxx # default — kills the short code in
# sc-chatroom; underlying AKM stays alive
# (other bundles tied to the same AKM
# are unaffected).
cli-revoke --akm sk_xxxxxxxx # also kills the AKM on local clawd —
# ALL bundles backed by it stop working.
Use the short-code form unless you specifically want to nuke the AKM too.
"""
from __future__ import annotations
import argparse
import sys
import _common as C
def _parse(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(prog="cli-revoke")
p.add_argument(
"target",
help="sc_… short code (default) or sk_… AKM prefix when used with --akm",
)
p.add_argument(
"--akm", action="store_true",
help="treat target as an AKM prefix and revoke the AKM directly on "
"clawd (kills every bundle backed by it).",
)
return p.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = _parse(argv)
target = args.target.strip()
if not target:
C.die("target is empty")
C.require_env()
if args.akm:
# AKM-level revoke: only kill if scope matches the cli-bridge scope,
# so a fat-fingered chatroom AKM prefix can't accidentally take out
# someone's room key.
lookup = C.clawd_call("GET", "/api/keys?include_expired=true")
if lookup.status_code == 200:
match = next(
(k for k in lookup.json().get("keys", []) if k.get("prefix") == target),
None,
)
if match is None:
C.die(f"no AKM key with prefix {target!r} on this clawd")
if match.get("scope") != C.CLI_BRIDGE_SCOPE:
C.die(
f"refusing to revoke: key {target} has scope "
f"{match.get('scope')!r}, not {C.CLI_BRIDGE_SCOPE!r}. "
"Use the chatroom skill's revoke commands for room keys."
)
r = C.clawd_call("DELETE", f"/api/keys/{target}")
if r.status_code in (200, 204):
C.info(f" ✓ revoked AKM {target} (all bundles using it now dead)")
return 0
if r.status_code == 404:
C.die(f"AKM {target} not found")
C.die(f"clawd DELETE returned {r.status_code}: {r.text}")
# Short-code revoke: this is the default path.
if not target.startswith("sc_"):
C.die(
f"expected sc_… short code (got {target!r}); use --akm if you "
"intended to revoke an AKM prefix"
)
r = C.chatroom_call("DELETE", f"/cli-keys/{target}")
if r.status_code in (200, 204):
C.info(f" ✓ revoked CLI bundle {target} on sc-chatroom")
C.info(" (underlying AKM left alive; use --akm to revoke that too)")
return 0
if r.status_code == 404:
C.die(f"code {target} not found, already revoked, or not yours")
C.die(f"sc-chatroom DELETE /cli-keys/{target} returned {r.status_code}: {r.text}")
return 1 # unreachable
if __name__ == "__main__":
sys.exit(main(sys.argv))
Related skills
How it compares
Pick this when starchild CLI local execution needs managed short-code authorization rather than ad-hoc shell access.
FAQ
Why a short code instead of the raw AKM secret?
Short codes (sc_xxxxxxxx) hide routing metadata and Fly machine IDs; the AKM stays inside Fly's internal network. Revoke just the code without killing the underlying AKM. Earlier versions leaked secrets and metadata to user laptops.
Is shell access enabled by default?
No. cli_login.py does not grant shell unless --enable-shell is passed. The AKM is authoritative: clawd refuses exec for connections without the shell capability. A plain bundle is a chat bridge only.
How do I approve which commands the agent can run locally?
Edit ~/.config/starchild/exec-policy.toml with allow:/deny: rules (substring or regex). Built-in denies (vim, ssh, sudo, rm -rf, etc.) always apply. Default is deny-all unless matched by an allow rule.