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 runningIt 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 + killopenscope 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:

agent (Claude Code / Codex / OpenCode / ...)
  -> openscope <app> <action> --agent <id> [--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:
CodeMeaning
0Success
2Invalid command or parameters
3Denied by policy
4Target not found
5Executor failure
6Configuration error
7Daemon unavailable
8Rate 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), then:

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/) 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:

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 →, 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.
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

sudo openscope apply --file setup.proposal.yaml --expect-hash <sha>

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/ (setup.proposal.yaml, setup.plan.txt, bounds.yaml). Prefer to configure it without an agent? See 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/<verb> 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:

# 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:

/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 ...)):

{
  "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):

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/.

1. Teach the agent the broker exists. Copy skills/openscope/SKILL.md 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):

## 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 to ~/.claude/hooks/ and register it for Bash and Write|Edit in ~/.claude/settings.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:

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:

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:

{
  "$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):

# ~/.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 <action> --agent gemini-cli --target <alias> (see GEMINI.md)"
priority = 200

[[rule]]
toolName = "run_shell_command"
commandPrefix = "sudo"
decision = "deny"
denyMessage = "Privileged ops are brokered: use openscope system <action> --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:

# 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.

Verify the Loop

Prove all three layers work before trusting them:

# 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.