# Coding Agents and OpenScope Claude Code, Codex CLI, OpenCode, Gemini CLI, Cursor, every serious coding agent executes shell commands, and every one of them eventually asks for the dangerous ones: `ssh root@prod`, `sudo systemctl restart`, `scp` a build onto a server. This guide shows how to put the OpenScope action broker between any of these agents and your privileged operations, so the agent keeps working at full speed while the raw primitives stay out of its reach. You do not need OpenClaw or NemoClaw to use OpenScope. Any agent that can run a CLI can use the broker. ## The Problem: Approval Fatigue Becomes Ambient Root Coding agents ask permission for each shell command. That model collapses under real use: - The prompts never stop, so users add blanket allowlist entries like `ssh *`, `scp *`, `rsync *`, and `sudo *`. From then on, every future session holds unaudited root on every host the user's SSH key reaches. - The agent runs with the user's credentials. The SSH key, the sudo grant, and the production host are all one generated command away, including from a prompt-injected or simply mistaken agent. - Nothing is recorded. When you wonder what an agent actually did on a server last Tuesday, the answer lives in scrollback, if anywhere. We audited two months of real coding-agent session history on one developer workstation: more than 1,000 raw SSH command invocations against a production host, most of them as `root`, plus over a hundred local `sudo` calls, and a global permission allowlist that had grown to include unscoped `ssh`, `scp`, and `rsync`. None of it was policy-checked, and none of it left an audit trail. ## What Changes With the Broker OpenScope removes the raw primitive and replaces it with named, parameter-scoped actions: | Instead of the agent running | It runs | |---|---| | `ssh root@prod 'systemctl restart api'` | `openscope ssh restart_service --agent claude-code --target prod-api --service api` | | `ssh root@prod 'journalctl -u api -n 200'` | `openscope ssh tail_logs --agent claude-code --target prod-api --service api --lines 200` | | `ssh root@prod 'cat /etc/nginx/nginx.conf'` | `openscope ssh read_file --agent claude-code --target prod-api --path /etc/nginx/nginx.conf` | | `sudo brew install jq` (or apt on Linux) | `openscope system manage_packages --agent claude-code --op install --manager brew --package jq` | | `sudo cp -R build/MyApp.app /Applications/` | `openscope system manage_apps --agent claude-code --op install --name MyApp --source /path/to/build/MyApp.app` | | `sudo lsof -i :8080` + `kill` | `openscope system check_port --agent claude-code --port 8080`, then `release_port` | What this buys you: - **The agent never holds the credential.** The SSH identity is configured on the broker's target definition; the agent process has no reason to read `~/.ssh` at all. The broker audits key protection on every call and attaches `key_warnings` to results when the key is agent-readable (e.g. living under `~/.ssh`, loose permissions). - **Default deny, parameter-level scope.** A target only exposes the services in its `allowed_services` list and the paths under `allowed_paths`/`allowed_path_prefixes`. Policy rules grant `agent + app + action` with optional exact-match parameter constraints, `--target prod-api --service api` allows exactly that, nothing else. Deny rules override allow rules. - **Agents cannot widen the action surface.** SSH targets, allowed services and paths, and system allow-lists live in root-owned admin config, and every command that changes them, or policy, requires root (`sudo openscope ...`). An agent running as your user can ask for more access; it cannot quietly configure more access. (Per-agent policy rules live in your user-owned `~/.openscope/policies.yaml`; the Claude Code hook below also blocks direct agent edits of broker config files.) - **Append-only audit.** Every request, allowed or denied, lands in `~/.openscope/audit.jsonl` with timestamp, agent identity, action, parameters, and decision. - **Bounded output.** Log tails are capped (1 to 500 lines), so a generated command can't accidentally stream gigabytes of journal into the model context. ## How It Works The integration surface is a CLI with a strict contract, which is exactly what coding agents are best at using: ```text agent (Claude Code / Codex / OpenCode / ...) -> openscope --agent [--param value ...] -> openscoped daemon (unix socket): policy check + execution + audit -> scoped executor (ssh / system / http / AppleScript) ``` - Results are JSON on stdout; flags are always `--name value` pairs (no `=` syntax). - The exit code is the policy signal the agent can branch on: | Code | Meaning | |---|---| | 0 | Success | | 2 | Invalid command or parameters | | 3 | Denied by policy | | 4 | Target not found | | 5 | Executor failure | | 6 | Configuration error | | 7 | Daemon unavailable | | 8 | Rate limited | A well-instructed agent treats exit 3 as a normal, expected answer, "that's out of scope for me", reports it, and moves on instead of looking for another way in. ## Setup Is an Agent Task: Ask, Review, Apply Setting OpenScope up is itself something you hand to your coding agent, with one rule that keeps it honest: **the agent drafts a proposal; it never grants itself access.** You install the broker, ask the agent to draft what it needs, review that draft with `openscope plan`, and you, not the agent, run `apply`. (Asking an AI to "just install it" the old way means it writes a `sudo bash setup.sh` of `policy allow` / `ssh targets add` commands that you rubber-stamp, recreating the exact hole the broker exists to close.) **1. Install and register.** Install OpenScope ([releases](https://github.com/cylonix/openscope/releases/latest)), then: ```bash openscope status && openscope doctor openscope agent register claude-code # identity = policy + audit label; no sudo ``` **2. Give the agent the skill, then the job.** Install the `openscope` skill ([`docs/examples/claude-code/skills/openscope/`](https://github.com/cylonix/openscope/tree/main/docs/examples/claude-code/skills/openscope)) so the agent already knows the rules, route privileged access through the broker, never edit broker config, draft a proposal to change scope. Then prompt it: > Set up OpenScope so your privileged access on this machine goes through the broker. > > Draft a proposal file `setup.proposal.yaml`, do **not** run `sudo`, `openscope policy`, or `openscope ssh targets`, and don't edit anything under `~/.openscope` or the admin dir (the broker rejects it). Follow the annotated template at `docs/examples/claude-code/setup.proposal.yaml`. > > Include only what this project actually needs: `ssh_targets` for the hosts I operate (root-owned `identity_file`; narrow `allowed_services` / `allowed_path_prefixes`; no secret paths); `system_commands` allow-lists for the package managers, services, and ports I use; `policy.add` allow rules granting agent `claude-code` the minimal actions on those targets; and, for any command that doesn't exist yet, an `apps.add` entry defining it as a typed `command:` template (never a generic shell). > > Then run `openscope plan --file setup.proposal.yaml` and show me the findings. Don't apply it, I'll review and run `apply` myself. The agent writes the YAML and runs `plan` itself (read-only, no sudo, it's allowed to do that). It **cannot** run `apply` or edit broker config: the guard hook denies it and `apply` needs root. So the worst a confused or prompt-injected agent can do here is propose too much, which the next step catches. ## 3. Review What It Drafted, `plan` (read-only, no sudo) Step 2 produces a declarative **proposal** (`setup.proposal.yaml`), not a shell script. Review it before anything touches the system, this is the step that replaces rubber-stamping an AI-written setup script: ```bash openscope plan --file setup.proposal.yaml # emoji-coded report, read-only, no sudo openscope plan --file setup.proposal.yaml --html # styled HTML report, opens in your browser ``` **[View a real sample report →](/docs/examples/setup.plan.html)**, the exact `--html` output for the worked example below: an AI-drafted setup proposal for Claude Code that `plan` catches reading a TLS-secret path and granting itself code execution, and **blocks** before anything is written. `plan` renders, from the proposal's typed fields and a live probe of your machine, never from the agent's prose: - **what each agent will be able to do**, as plain consequence lines; - **deterministic lint findings**, root-user targets, read paths that reach secrets (`/etc/nginx/ssl`, `.env`), an `install`+`launch` grant from an agent-writable source (arbitrary code execution), sudo-enabled package managers, dead rules, disruptive restarts, custom write verbs (`SSH-WRITE`), and generic-runner verb templates (`SSH-SHELL-PASSTHROUGH`, blocked); - a **bounds check** against a root-owned `bounds.yaml` envelope the proposal can never exceed. ```text FINDINGS, 2 blocking · 7 high · 7 medium · 4 warn · 2 pass +--------+------------------+------------------------------------------+--------------------------------+ | SEV | RULE | RESOURCE | SUMMARY | +--------+------------------+------------------------------------------+--------------------------------+ | BLOCK | SSH-SECRET-PATH | prod:/etc/nginx | read access reaches /etc/nginx/ssl | | BLOCK | SYS-APP-CODEEXEC | 3 agent-writable source(s) | install+launch = code execution| | HIGH | SSH-ROOT-USER | prod (api.example.com) | logs in as root | +--------+------------------+------------------------------------------+--------------------------------+ VERDICT: BLOCKED, 2 bounds violations ``` If `plan` blocks or shows scope you didn't want, hand it straight back, *"plan blocked on SSH-SECRET-PATH and SYS-APP-CODEEXEC; narrow the read paths and the app source, then re-run plan"*, and the agent revises. Nothing is written until you apply. ## 4. Apply It, you, the only sudo step ```bash sudo openscope apply --file setup.proposal.yaml --expect-hash ``` `apply` re-renders the report from the file it actually reads, **refuses** on any bounds violation (the only override is editing `bounds.yaml` as root), requires you to **type each high-risk resource** to confirm (no `--yes`), then writes through the broker's own validated paths and records the apply, with the proposal's SHA-256, to the audit log. The reviewed artifact is provably the applied one. A worked example, proposal, the exact `plan` output, and the bounds file, lives in [`docs/examples/claude-code/`](https://github.com/cylonix/openscope/tree/main/docs/examples/claude-code) (`setup.proposal.yaml`, `setup.plan.txt`, `bounds.yaml`). Prefer to configure it without an agent? See [Setting it up by hand](#setting-it-up-by-hand) below. ### Defining a new verb, not just granting one A proposal carries more than targets and policy rules, it can also **define a new custom ssh verb**. Granting `allow … ssh/` for a verb that doesn't exist yet is a dead rule (`POLICY-DEAD-RULE`); the command *behind* the verb has to be defined and reviewed too. Both go in the same proposal: - an **`apps.add`** entry, a fixed `command:` template (`executor: ssh`) with typed `{param}` arguments, e.g. `/opt/kidfence/bin/gen-promo --code {code}`. `plan` shows the exact command as an **`SSH-WRITE`** finding for you to confirm, and `apply` pins it **root-owned** so a same-uid agent can't rewrite an approved command afterward; - a matching **`policy.add`** allow rule naming the same `app`/`action`. This keeps a verb a *typed, named action*, not a shell. A generic-runner shape (a bare `{cmd}`, `bash -c {x}`, or `eval`) is **blocked** as `SSH-SHELL-PASSTHROUGH`. The worked `setup.proposal.yaml` includes a copyable `apps.add` + `policy.add` pair. ## Setting it up by hand The agent-drafted proposal above is the recommended path. For a quick one-host setup or a CI box with no agent, the same configuration has direct admin commands. Targets, allow-lists, and policy are root-owned, so each needs sudo: ```bash # Define what exists (root-owned, so an agent can't edit it): sudo openscope ssh targets add --alias prod-api --host api.example.com --user ops \ --identity-file /var/openscope/ssh/prod_api_ed25519 --services api,nginx --path-prefixes /var/log,/etc/nginx sudo openscope system commands add-manager --name brew --binary /opt/homebrew/bin/brew sudo openscope system commands add-package jq # Grant the agent exactly what it should reach (exact-match constraints; omit one to allow any value): sudo openscope policy allow --agent claude-code --app ssh --action tail_logs --target prod-api --service api sudo openscope policy allow --agent claude-code --app ssh --action restart_service --target prod-api --service api sudo openscope policy allow --agent claude-code --app system --action manage_packages --manager brew ``` - The broker invokes `ssh` with `BatchMode=yes` and `StrictHostKeyChecking=yes`, so the host key must already be in `known_hosts` and the key must be passphrase-less (connect once manually, or pre-seed with `ssh-keyscan`). - `restart_service` runs plain `systemctl restart` as the target's configured user, pick a user with rights to the allowed units. - Port, file-permission, process, and app source/install-dir allow-lists don't have CLI setters yet; edit the root-owned `system_commands.yaml` (path from `openscope system commands list`) with sudo. - If a package manager needs root (e.g. `apt` on a Linux broker), mark it `--sudo` and install the generated NOPASSWD entries: `openscope system sudoers | sudo tee /etc/sudoers.d/openscope`. Homebrew on macOS stays non-sudo. ## Wiring Claude Code The fastest path is the **OpenScope plugin**. It bundles the two pieces that teach and enforce, the `openscope` skill (which tells the agent to route privileged access through the broker and to discover its allowed scope with `openscope capabilities` rather than memorize it) and the `PreToolUse` guard hook (which makes the raw path fail closed), as a single install: ```text /plugin marketplace add cylonix/openscope /plugin install openscope@openscope ``` The skill auto-loads and the hook registers itself, so there is no `CLAUDE.md` edit or manual hook wiring. One step stays manual, because **Claude Code plugins cannot grant permissions**: add `"Bash(openscope:*)"` to the `allow` list in `~/.claude/settings.json` so brokered calls never prompt, and delete the broad entries it replaces (`Bash(ssh *)`, `Bash(sudo ...)`): ```json { "permissions": { "allow": ["Bash(openscope:*)"] } } ``` The guard hook reads the hosts it should protect from `~/.openscope/governed_hosts.txt`, one substring per line. Create it with the hosts you govern (raw `ssh` to anything not listed, e.g. lab boxes, is left untouched): ```bash printf '%s\n' prod.example.com 203.0.113.7 > ~/.openscope/governed_hosts.txt ``` The deny reason the hook returns teaches the agent the exact `openscope` command to use instead, so it self-corrects mid-task; only a single unchained `openscope ...` invocation skips the checks, so nothing rides along after `&&`. The result inverts the friction: the dangerous path is denied with a teaching message, and the governed path runs without any prompt at all. ### Or wire it by hand Prefer not to use the plugin, or wiring a non-Claude agent? The same pieces install manually, working copies live in [`docs/examples/claude-code/`](https://github.com/cylonix/openscope/tree/main/docs/examples/claude-code). **1. Teach the agent the broker exists.** Copy [`skills/openscope/SKILL.md`](https://github.com/cylonix/openscope/tree/main/docs/examples/claude-code/skills/openscope) to `~/.claude/skills/openscope/`, or, minimally, append this to `~/.claude/CLAUDE.md` (don't hardcode the action list, it drifts when policy changes; the skill has the agent discover it live): ```markdown ## OpenScope Action Broker (all projects) This machine routes privileged operations (SSH to governed/production hosts, `sudo` / local system changes, brokered macOS apps) through the OpenScope action broker. Never run those raw. Use `openscope ... --agent claude-code`, and run `openscope capabilities --agent claude-code` first to discover the currently allowed actions and their exact command form. Exit code 3 means denied by policy: report it and stop, never work around it or switch agent labels. ``` **2. Enforce it with a `PreToolUse` hook.** Install [`openscope-guard.sh`](https://github.com/cylonix/openscope/blob/main/docs/examples/claude-code/openscope-guard.sh) to `~/.claude/hooks/` and register it for `Bash` and `Write|Edit` in `~/.claude/settings.json`: ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/openscope-guard.sh" } ] }, { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/openscope-guard.sh" } ] } ] } } ``` **3. Add the permission allow and `governed_hosts.txt`** exactly as in the plugin steps above. ## Wiring Codex CLI Codex reads `AGENTS.md` (project root, plus `~/.codex/AGENTS.md` globally), add the same broker contract block as above, with `--agent codex`. Codex's command rules express the gate directly. In `~/.codex/rules/default.rules`: ```python prefix_rule(pattern = ["openscope"], decision = "allow", justification = "Brokered actions are policy-checked and audited by OpenScope") prefix_rule(pattern = ["ssh"], decision = "forbidden") prefix_rule(pattern = ["scp"], decision = "forbidden") prefix_rule(pattern = ["sudo"], decision = "forbidden") ``` Codex splits `a && b` chains and evaluates each part, and the most restrictive matching decision wins. Test rules with `codex execpolicy check --rules ~/.codex/rules/default.rules -- ssh root@prod`. One sandbox detail matters: Codex's default `workspace-write` sandbox blocks network *and unix sockets*, so `openscope` can execute but can't reach the broker daemon's socket until you allowlist it in `~/.codex/config.toml`: ```toml approval_policy = "on-request" sandbox_mode = "workspace-write" [features.network_proxy] enabled = true [features.network_proxy.unix_sockets] "/Users/you/.openscope/run/openscoped.sock" = "allow" ``` That combination is the strongest setup of any agent here: the OS sandbox blocks every network path out of the agent except the broker socket, and the broker decides what comes through it. The daemon, outside the sandbox, holds the SSH keys. ## Wiring OpenCode OpenCode reads `AGENTS.md` (project root, `~/.config/opencode/AGENTS.md` globally, with `CLAUDE.md` as a fallback), add the contract block with `--agent opencode`. Its permission system in `opencode.json` expresses the whole pattern declaratively. Rules are matched against the parsed command and the **last** matching pattern wins, so the catch-all goes first: ```json { "$schema": "https://opencode.ai/config.json", "permission": { "bash": { "*": "ask", "openscope *": "allow", "ssh *": "deny", "scp *": "deny", "rsync *": "deny", "sudo *": "deny" } } } ``` Denied patterns push the agent toward the brokered path described in `AGENTS.md`; the `openscope *` allow keeps that path prompt-free. OpenCode has no sandbox, bash runs directly on the host, so for a teaching deny message like the Claude Code hook gives, a small plugin using the `tool.execute.before` hook can throw an error that names the right `openscope` command. ## Wiring Gemini CLI Gemini CLI reads `GEMINI.md` (project hierarchy plus `~/.gemini/GEMINI.md`), add the contract block with `--agent gemini-cli`. Its policy engine takes TOML rules in `~/.gemini/policies/` (higher priority wins; a deny can carry a teaching message): ```toml # ~/.gemini/policies/openscope.toml [[rule]] toolName = "run_shell_command" commandPrefix = "openscope" decision = "allow" priority = 100 [[rule]] toolName = "run_shell_command" commandPrefix = "ssh" decision = "deny" denyMessage = "Remote access is brokered: use openscope ssh --agent gemini-cli --target (see GEMINI.md)" priority = 200 [[rule]] toolName = "run_shell_command" commandPrefix = "sudo" decision = "deny" denyMessage = "Privileged ops are brokered: use openscope system --agent gemini-cli (see GEMINI.md)" priority = 200 ``` The simpler `"tools": { "allowed": ["run_shell_command(openscope)"] }` settings.json form also works for the allow side. Prefix matching splits `&&`/`;` chains and validates each part. Keep Gemini's opt-in sandbox off for this setup, in container mode the broker's unix socket isn't mounted into the sandbox. ## Cursor, Aider, and Anything Else The pattern is always the same three steps, whatever the agent calls its pieces: 1. **Instructions file** (`AGENTS.md`, `.cursor/rules`, convention file of your tool): the broker contract, agent ID, the `openscope` command shapes, "exit 3 means denied, ask the user". 2. **Command gate** (permission config, hook, or sandbox): deny raw `ssh`/`sudo`/`scp`, allow `openscope`. 3. **Broker policy** (the real control): `sudo openscope policy allow ...` for exactly the actions that agent should reach, under its own registered identity. Even with no command gate at all, steps 1 and 3 still give you scoped credentials-free operations and a full audit trail, the gate just removes the temptation. ## Sandboxed and Remote Agents Agents in containers or VMs use the same CLI with a different transport, install only the `openscope` client binary inside the sandbox and point it at the broker: ```bash # Unix socket mounted into the sandbox export OPENSCOPE_SOCKET=/var/run/openscope/openscoped.sock # …or an HTTP bridge on the host export OPENSCOPE_HTTP_URL=http://host.docker.internal:42357 ``` For a shared broker on a VPC host, agents authenticate with minted tokens (`OPENSCOPE_TOKEN`), the agent identity is derived from the token (no `--agent` spoofing), and TLS is configured with `OPENSCOPE_HTTP_CA`. See the [enterprise broker guide](https://github.com/cylonix/openscope/blob/main/docs/enterprise-broker.md). ## Verify the Loop Prove all three layers work before trusting them: ```bash # Allowed action succeeds, returns JSON openscope ssh tail_logs --agent claude-code --target prod-api --service api --lines 50 # Out-of-scope action is denied with exit 3 openscope ssh read_file --agent claude-code --target prod-api --path /etc/shadow; echo "exit: $?" # Both decisions are in the audit log tail -2 ~/.openscope/audit.jsonl ``` Then ask your agent to "check the api service logs on prod" and watch it take the brokered path on its own.