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 *, andsudo *. 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
~/.sshat all. The broker audits key protection on every call and attacheskey_warningsto 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_serviceslist and the paths underallowed_paths/allowed_path_prefixes. Policy rules grantagent + app + actionwith optional exact-match parameter constraints,--target prod-api --service apiallows 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.jsonlwith 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 valuepairs (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), 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 runsudo,openscope policy, oropenscope ssh targets, and don't edit anything under~/.openscopeor the admin dir (the broker rejects it). Follow the annotated template atdocs/examples/claude-code/setup.proposal.yaml.Include only what this project actually needs:
ssh_targetsfor the hosts I operate (root-ownedidentity_file; narrowallowed_services/allowed_path_prefixes; no secret paths);system_commandsallow-lists for the package managers, services, and ports I use;policy.addallow rules granting agentclaude-codethe minimal actions on those targets; and, for any command that doesn't exist yet, anapps.addentry defining it as a typedcommand:template (never a generic shell).Then run
openscope plan --file setup.proposal.yamland show me the findings. Don't apply it, I'll review and runapplymyself.
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), aninstall+launchgrant 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.yamlenvelope 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.addentry, a fixedcommand:template (executor: ssh) with typed{param}arguments, e.g./opt/kidfence/bin/gen-promo --code {code}.planshows the exact command as anSSH-WRITEfinding for you to confirm, andapplypins it root-owned so a same-uid agent can't rewrite an approved command afterward; - a matching
policy.addallow rule naming the sameapp/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
sshwithBatchMode=yesandStrictHostKeyChecking=yes, so the host key must already be inknown_hostsand the key must be passphrase-less (connect once manually, or pre-seed withssh-keyscan). restart_serviceruns plainsystemctl restartas 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 fromopenscope system commands list) with sudo. - If a package manager needs root (e.g.
apton a Linux broker), mark it--sudoand 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:
- Instructions file (
AGENTS.md,.cursor/rules, convention file of your tool): the broker contract, agent ID, theopenscopecommand shapes, "exit 3 means denied, ask the user". - Command gate (permission config, hook, or sandbox): deny raw
ssh/sudo/scp, allowopenscope. - 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.