diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 3948fe4..d117dd9 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -26,6 +26,6 @@ jobs: - name: Set Up Shared Tools & Tasks uses: ./.github/actions/mise with: - tools: actionlint chezmoi gitleaks hk jq node node@24 npm:renovate oxfmt shellcheck + tools: actionlint chezmoi gitleaks hk jq node node@24 npm:renovate oxfmt python ruff shellcheck - name: Run Checks run: mise run check diff --git a/.mise.toml b/.mise.toml index 9895d15..6184307 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,7 @@ [tasks.check] depends = [ "check:actions", + "check:agent-launcher", "check:mise", "check:templates", "check:updates", @@ -8,6 +9,12 @@ depends = [ "test", ] +[tasks."check:agent-launcher"] +run = [ + "ruff check home/dot_local/bin/executable_nono-agent", + "ruff format --check home/dot_local/bin/executable_nono-agent", +] + [tasks."check:mise"] run = ''' set -eu diff --git a/README.md b/README.md index c8790c4..f0e77a1 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ so copy those edits back into `home/`. ## Agent Sandboxes -The Fish wrappers for Claude Code, Codex, OpenCode and Pi run them through -[nono](https://github.com/nolabs-ai/nono) with the profiles in +The Fish wrappers for Claude Code, Codex, OpenCode and Pi use `nono-agent` to run +them through [nono](https://github.com/nolabs-ai/nono) with the profiles in `home/dot_config/nono/profiles`, built from one shared template. Each extends the upstream profile for its agent, adds Mise tools and read access to the shared agent guidance, and allows writes only in the directory the agent starts in and @@ -62,20 +62,32 @@ its repository's Git directory, so agents can commit in worktrees. Start an agent in `~/Projects` to work across projects. Pull each upstream profile once with `nono pull nolabs-ai/AGENT`; Mise does not update them, so check with `nono outdated` and run `nono update`, then `chezmoi apply`: the Codex -template moves nono's Codex settings out of the block nono inserts. +template moves nono's Codex settings out of the block nono inserts. Nono is the +sandbox boundary. Inside it, Codex and Claude Code bypass their own approval +prompts and nested sandboxes; OpenCode uses its supported `--auto` mode, which +retains explicit denies. Pi has no tool approval mode. Outside nono, Codex defaults +to workspace-write with automatic approval review and Claude Code to `auto`. +Use `command opencode --auto` for OpenCode without nono; it has no persistent +auto-approval setting. Sandboxed agents read only the shared Git configuration and the `GitHub Agents` gh configuration, so they commit unsigned and push over HTTPS. They cannot reach -1Password, SSH, cluster credentials or Orca's CLI, which would let them run -commands outside the sandbox; Orca still shows their status. Run `command AGENT` +1Password, SSH, cluster credentials or unrestricted Orca control. Run `command AGENT` for work that needs those, such as OpenTofu plans and applies, host work, Kubernetes and Talos administration, chezmoi and Xcode builds. Orca launches agents through these wrappers when its agent command for each one -is set to `fish -c "AGENT $argv" --`. Orca workers report back through its CLI, so -start them unsandboxed: create a terminal running `command AGENT` with -`orca terminal create --command` and give it the task with -`orca orchestration worker-start --terminal`. +is set to `fish -c "AGENT $argv" --` and its permission mode is **Own**. +Own lets the wrapper select the mode. An existing Codex Full Access flag is +preserved without duplication; Orca's Ask and Auto presets add conflicting +approval flags. Use **Auto** for native chats and terminal agents launched +without these wrappers. +On Linux, the launcher gives Orca workers a +separate nono command policy for mailbox operations and the two Orca skill guides. +Only the brokered CLI receives its runtime metadata and socket access; terminal +control and foreign-mailbox routing stay denied. The policy is generated from the +installed Orca launcher for each session and removed on exit. Workers can also +read their main checkout's `todo.md` without receiving access to the whole checkout. Agent logins, sessions and history stay on each machine: sign in to each agent once per machine. `CLAUDE_CONFIG_DIR` keeps Claude's state file inside `~/.claude`, diff --git a/home/.chezmoitemplates/codex.toml b/home/.chezmoitemplates/codex.toml index 5fb65b1..df6c1d2 100644 --- a/home/.chezmoitemplates/codex.toml +++ b/home/.chezmoitemplates/codex.toml @@ -1,7 +1,9 @@ +approval_policy = "on-request" approvals_reviewer = "auto_review" check_for_update_on_startup = false model_reasoning_effort = "medium" plan_mode_reasoning_effort = "medium" +sandbox_mode = "workspace-write" service_tier = "default" suppress_unstable_features_warning = true diff --git a/home/dot_agents/hooks/executable_secret-guard b/home/dot_agents/hooks/executable_secret-guard index 64b8274..f0e7138 100755 --- a/home/dot_agents/hooks/executable_secret-guard +++ b/home/dot_agents/hooks/executable_secret-guard @@ -7,7 +7,7 @@ # Output that gitleaks cannot scan is withheld rather than passed through. set -euo pipefail -blocked='\bop (read|inject)\b|\bop item get\b.*--(reveal|fields)|\bfnox get\b|(^|[;&|[:space:]])printenv\b|(^|[;&|][[:space:]]*)env[[:space:]]*($|[;&|>])|security find-(generic|internet)-password.*-w|\.env($|[^.[:alnum:]_-]|\.)|(^|[/[:space:]"'"'"'=])secrets\.yaml|auth\.json|\.config/op/|rclone\.conf|\.kube/config|\.talos/config' +blocked='(^|[^[:alnum:]_-])op (read|inject)\b|\bop item get\b.*--(reveal|fields)|\bfnox get\b|(^|[;&|[:space:]])printenv\b|(^|[;&|][[:space:]]*)env[[:space:]]*($|[;&|>])|security find-(generic|internet)-password.*-w|\.env($|[^.[:alnum:]_-]|\.)|(^|[/[:space:]"'"'"'=])secrets\.yaml|auth\.json|\.config/op/|rclone\.conf|\.kube/config|\.talos/config' withheld="[secret-guard withheld this output because it could not be scanned for secrets]" diff --git a/home/dot_config/fish/functions/claude.fish b/home/dot_config/fish/functions/claude.fish index a8be8b2..a52d98a 100644 --- a/home/dot_config/fish/functions/claude.fish +++ b/home/dot_config/fish/functions/claude.fish @@ -1,3 +1,3 @@ function claude --wraps claude - nono run --allow-cwd --allow (git rev-parse --path-format=absolute --git-common-dir 2>/dev/null; or pwd) --profile claude -- claude $argv + nono-agent claude $argv end diff --git a/home/dot_config/fish/functions/codex.fish b/home/dot_config/fish/functions/codex.fish index e973c11..e35794f 100644 --- a/home/dot_config/fish/functions/codex.fish +++ b/home/dot_config/fish/functions/codex.fish @@ -1,3 +1,3 @@ function codex --wraps codex - nono run --allow-cwd --allow (git rev-parse --path-format=absolute --git-common-dir 2>/dev/null; or pwd) --profile codex -- codex $argv + nono-agent codex $argv end diff --git a/home/dot_config/fish/functions/opencode.fish b/home/dot_config/fish/functions/opencode.fish index b832256..4e3f1dd 100644 --- a/home/dot_config/fish/functions/opencode.fish +++ b/home/dot_config/fish/functions/opencode.fish @@ -1,3 +1,3 @@ function opencode --wraps opencode - nono run --allow-cwd --allow (git rev-parse --path-format=absolute --git-common-dir 2>/dev/null; or pwd) --profile opencode -- opencode $argv + nono-agent opencode $argv end diff --git a/home/dot_config/fish/functions/pi.fish b/home/dot_config/fish/functions/pi.fish index 660aeda..82b2fcf 100644 --- a/home/dot_config/fish/functions/pi.fish +++ b/home/dot_config/fish/functions/pi.fish @@ -1,3 +1,3 @@ function pi --wraps pi - nono run --allow-cwd --allow (git rev-parse --path-format=absolute --git-common-dir 2>/dev/null; or pwd) --profile pi -- pi $argv + nono-agent pi $argv end diff --git a/home/dot_local/bin/executable_nono-agent b/home/dot_local/bin/executable_nono-agent new file mode 100755 index 0000000..bdc56a5 --- /dev/null +++ b/home/dot_local/bin/executable_nono-agent @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Launch agents under nono, with a separate command policy for Orca worker mail.""" + +import json +import os +import shlex +import subprocess +import sys +import tempfile +from pathlib import Path + +MAIL_OPTIONS = { + ("orchestration", "ask"): { + "--options": True, + "--question": True, + "--resume": True, + "--timeout-ms": True, + "--wait": False, + }, + ("orchestration", "check"): { + "--all": False, + "--format": False, + "--peek": False, + "--timeout-ms": True, + "--types": True, + "--unread": False, + "--wait": False, + }, + ("orchestration", "send"): { + "--body": True, + "--dispatch-id": True, + "--files-modified": True, + "--outcome": True, + "--phase": True, + "--priority": True, + "--report-path": True, + "--subject": True, + "--task-id": True, + "--type": True, + }, + ("skills", "get", "orca-cli"): { + "--full": False, + "--reference": True, + "--references": False, + }, + ("skills", "get", "orchestration"): { + "--full": False, + "--reference": True, + "--references": False, + }, +} + + +def orca_policy(launcher): + lines = launcher.read_text().splitlines() + runtime = shlex.split(next(line for line in lines if line.startswith("exec "))) + if len(runtime) != 4 or runtime[-1] != "$@": + raise ValueError( + "Unsupported Orca CLI launcher; expected Node and a CLI entry point." + ) + node, cli = (Path(value) for value in runtime[1:3]) + assignment = next( + line for line in lines if line.startswith("export ORCA_USER_DATA_PATH=") + ) + data = Path(shlex.split(assignment)[1].split("=", 1)[1]) + environment = { + "allow_vars": ["HOME", "LANG", "TERM", "XDG_*"], + "set_vars": { + **{ + key: value + for key, value in os.environ.items() + if key.startswith("ORCA_") + }, + "ORCA_USER_DATA_PATH": str(data), + }, + } + installed = json.loads( + subprocess.check_output(["mise", "ls", "--installed", "--json"]) + ) + versions = [ + f"{tool}@{version['version']}" + for tool, releases in installed.items() + for version in releases + ] + # Nono's execute gate otherwise only discovers launch-time PATH entries. + directories = subprocess.check_output( + ["mise", "bin-paths", *versions], text=True + ).splitlines() + directories.append( + subprocess.check_output(["git", "--exec-path"], text=True).strip() + ) + directories.extend( + str(Path(release["install_path"]) / "codex-path") + for release in installed.get("codex", []) + if (Path(release["install_path"]) / "codex-path").is_dir() + ) + return { + "executable_dirs": sorted(set(directories)), + "commands": { + "orca": { + "executable": str(Path(sys.executable).resolve()), + "can_use": ["orca-node"], + "from": { + "session": { + "sandbox": { + "argv_prepend": [ + str(Path(__file__).resolve()), + "--orca-mail", + ], + "environment": { + **environment, + "set_vars": { + **environment["set_vars"], + "PYTHONHOME": sys.prefix, + }, + }, + "fs_read": [ + str(Path(sys.executable).resolve().parent.parent) + ], + "fs_read_file": [str(Path(__file__).resolve())], + }, + "invocation_policy": { + "default": "deny", + "allow": [ + {"argv": {"prefix": list(prefix)}} + for prefix in MAIL_OPTIONS + ], + }, + } + }, + }, + "orca-node": { + "executable": str(node), + "from": { + "session": "deny", + "orca": { + "argv_prepend": [str(cli)], + "environment": environment, + "fs_read": [str(node.parent.parent), str(cli.parents[2])], + "fs_read_file": [ + str(data / "orca-runtime.json"), + "/etc/ssl/openssl.cnf", + ], + "unix_socket_bind": [str(data / "*.sock")], + }, + }, + }, + # Keep the launcher's interpreter under the original session policy. + # The execute gate otherwise skips group-writable packaged Python builds. + "python-runtime": { + "executable": str(Path(sys.executable).resolve()), + "allow_direct_exec_bypass": [str(Path(sys.executable).resolve())], + "from": {"session": "deny"}, + }, + }, + } + + +def validate_mail(args): + prefix = tuple(args[:3] if args[:2] == ["skills", "get"] else args[:2]) + options = { + "--help": False, + "--json": False, + "--retry-request": True, + } | MAIL_OPTIONS[prefix] + index = len(prefix) + while index < len(args): + name, equals, value = args[index].partition("=") + takes_value = options[name] + if takes_value and not equals: + index += 1 + value = args[index] + if takes_value: + if not value or value.startswith("-"): + raise ValueError( + "Mailbox option values must not start with another option." + ) + if name == "--type" and value not in ( + "escalation", + "heartbeat", + "status", + "worker_done", + ): + raise ValueError( + "Only worker status and completion messages are permitted." + ) + elif equals: + raise ValueError("Mailbox switches do not take values.") + index += 1 + return args + + +def mail(args): + try: + validate_mail(args) + except (IndexError, KeyError, ValueError) as error: + print(f"Orca worker mail denied: {error}", file=sys.stderr) + return 126 + return subprocess.call(["orca-node", *args]) + + +def launch(agent, args): + grants = ["--allow-cwd"] + common = subprocess.run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + check=False, + ) + if common.returncode == 0: + grants.extend(["--allow", common.stdout.strip()]) + if ( + agent == "codex" + and "--dangerously-bypass-approvals-and-sandbox" not in args + and "--yolo" not in args + ): + args = ["--dangerously-bypass-approvals-and-sandbox", *args] + elif agent == "claude": + args = [ + "--dangerously-skip-permissions", + "--settings", + json.dumps({"sandbox": {"enabled": False}}), + *args, + ] + elif agent == "opencode": + args = ["--auto", *args] + command = ["nono", "run", *grants] + if sys.platform != "linux" or "ORCA_WORKTREE_ID" not in os.environ: + return subprocess.call([*command, "--profile", agent, "--", agent, *args]) + + profile = { + "extends": agent, + "command_policies": orca_policy(Path(os.environ["ORCA_CLI_COMMAND"])), + "environment": {"set_vars": {"ORCA_CLI_COMMAND": "orca"}}, + } + if common.returncode == 0: + listing = subprocess.check_output( + ["git", "worktree", "list", "--porcelain"], text=True + ) + main = Path( + next( + line.removeprefix("worktree ") + for line in listing.splitlines() + if line.startswith("worktree ") + ) + ) + if (main / "todo.md").is_file(): + profile["filesystem"] = {"read_file": [str(main / "todo.md")]} + + # Keep generated authority outside every agent-writable directory. + state = ( + Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + / "nono" + / "runtime-profiles" + ) + state.mkdir(parents=True, exist_ok=True, mode=0o700) + with tempfile.TemporaryDirectory(prefix="orca-", dir=state) as directory: + path = Path(directory) / "profile.json" + path.write_text(json.dumps(profile)) + return subprocess.call([*command, "--profile", str(path), "--", agent, *args]) + + +if __name__ == "__main__": + sys.exit( + mail(sys.argv[2:]) + if sys.argv[1] == "--orca-mail" + else launch(sys.argv[1], sys.argv[2:]) + ) diff --git a/tests/nono-agent.test.mjs b/tests/nono-agent.test.mjs new file mode 100644 index 0000000..f149281 --- /dev/null +++ b/tests/nono-agent.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const launcher = fileURLToPath( + new URL("../home/dot_local/bin/executable_nono-agent", import.meta.url), +); + +test("agent launch retains nono and removes its temporary Orca authority after exit", () => { + const directory = mkdtempSync(join(tmpdir(), "nono-agent-")); + try { + const bin = join(directory, "bin"); + const main = join(directory, "main"); + mkdirSync(bin); + mkdirSync(main); + writeFileSync(join(main, "todo.md"), "- [ ] Fixture task\n"); + for (const [name, source] of Object.entries({ + git: `const args = process.argv.slice(2); +if (args[0] === "rev-parse") console.log(${JSON.stringify(join(main, ".git"))}); +else if (args[0] === "--exec-path") console.log(${JSON.stringify(join(directory, "git-core"))}); +else console.log(${JSON.stringify(`worktree ${main}\n`)});`, + mise: `const args = process.argv.slice(2); +if (args[0] === "ls") console.log(JSON.stringify({ node: [{ version: "24", install_path: ${JSON.stringify(join(directory, "node"))} }] })); +else console.log(${JSON.stringify(join(directory, "node/bin"))});`, + nono: `const fs = require("node:fs"); +const args = process.argv.slice(2); +const profilePath = args[args.indexOf("--profile") + 1]; +const profile = profilePath === "codex" ? null : JSON.parse(fs.readFileSync(profilePath, "utf8")); +console.log(JSON.stringify({ args, profile, profilePath }));`, + })) { + const path = join(bin, name); + writeFileSync(path, `#!/usr/bin/env node\n${source}\n`); + chmodSync(path, 0o755); + } + const orca = join(directory, "orca"); + writeFileSync( + orca, + `export ORCA_USER_DATA_PATH='${directory}/orca-data'\nexec '/usr/bin/node' '${directory}/orca/out/cli/index.js' "$@"\n`, + ); + const environment = { + ...process.env, + ORCA_CLI_COMMAND: orca, + ORCA_TERMINAL_ID: "bound-worker", + ORCA_WORKTREE_ID: "fixture", + PATH: `${bin}:${process.env.PATH}`, + XDG_CONFIG_HOME: join(directory, "config"), + }; + const run = (agent, args, env = environment) => { + const process = spawnSync("python3", [launcher, agent, ...args], { encoding: "utf8", env }); + assert.equal(process.status, 0, process.stderr); + return JSON.parse(process.stdout); + }; + const codex = run("codex", ["--model", "fixture"]); + assert.deepEqual(codex.args.slice(-5), [ + "--", + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "--model", + "fixture", + ]); + const bypassFlags = ["--dangerously-bypass-approvals-and-sandbox", "--yolo"]; + for (const flag of bypassFlags) { + const fullAccess = run("codex", [flag, "--model", "fixture"]); + assert.equal(fullAccess.args.filter((arg) => bypassFlags.includes(arg)).length, 1); + } + assert.equal(codex.profile.extends, "codex"); + assert.deepEqual(codex.profile.command_policies.executable_dirs, [ + join(directory, "git-core"), + join(directory, "node/bin"), + ]); + assert.deepEqual(codex.profile.filesystem, { read_file: [join(main, "todo.md")] }); + assert.equal( + codex.profile.command_policies.commands.orca.from.session.invocation_policy.default, + "deny", + ); + assert.equal(existsSync(codex.profilePath), false); + const brokerEnvironment = + codex.profile.command_policies.commands["orca-node"].from.orca.environment; + assert.equal(brokerEnvironment.set_vars.ORCA_TERMINAL_ID, "bound-worker"); + assert.equal(brokerEnvironment.allow_vars.includes("ORCA_*"), false); + const claude = run("claude", []); + assert.deepEqual(JSON.parse(claude.args.at(-1)), { sandbox: { enabled: false } }); + assert.ok(claude.args.includes("--dangerously-skip-permissions")); + assert.equal(existsSync(claude.profilePath), false); + const opencode = run("opencode", []); + assert.deepEqual(opencode.args.slice(-3), ["--", "opencode", "--auto"]); + const pi = run("pi", []); + assert.deepEqual(pi.args.slice(-2), ["--", "pi"]); + const ordinaryEnvironment = { ...environment }; + delete ordinaryEnvironment.ORCA_WORKTREE_ID; + assert.equal(run("codex", [], ordinaryEnvironment).profile, null); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("worker mail rejects routing overrides before invoking the privileged CLI", () => { + const directory = mkdtempSync(join(tmpdir(), "nono-mail-")); + try { + const backend = join(directory, "orca-node"); + writeFileSync( + backend, + "#!/usr/bin/env node\nconsole.log(JSON.stringify(process.argv.slice(2)));\n", + ); + chmodSync(backend, 0o755); + const run = (args) => + spawnSync("python3", [launcher, "--orca-mail", ...args], { + encoding: "utf8", + env: { ...process.env, PATH: `${directory}:${process.env.PATH}` }, + }); + for (const args of [ + ["orchestration", "check", "--peek", "--json"], + ["orchestration", "ask", "--question=A worker question"], + [ + "orchestration", + "send", + "--type", + "worker_done", + "--outcome", + "succeeded", + "--body", + "Test passed", + ], + ["skills", "get", "orchestration", "--reference", "references/messaging-and-gates.md"], + ]) { + const result = run(args); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), args); + } + for (const args of [ + ["terminal", "list"], + ["orchestration", "send", "--type=dispatch"], + ["orchestration", "ask", "--question", "--terminal=other"], + ["orchestration", "check", "--peek=true"], + ["orchestration", "send", "--body"], + ...[ + "--dispatch-capability", + "--environment", + "--from", + "--pairing-code", + "--run", + "--terminal", + "--to", + ].flatMap((flag) => [ + ["orchestration", "check", flag, "other"], + ["orchestration", "check", `${flag}=other`], + ]), + ]) { + const result = run(args); + assert.equal(result.status, 126, `${args.join(" ")}: ${result.stderr}`); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /Orca worker mail denied/); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/secret-guard.test.mjs b/tests/secret-guard.test.mjs index 67b83d7..7ba0867 100644 --- a/tests/secret-guard.test.mjs +++ b/tests/secret-guard.test.mjs @@ -20,6 +20,18 @@ const run = (args, input, environment = env) => const post = (stdout, environment) => run(["post", "claude"], JSON.stringify({ tool_response: { stdout } }), environment); +test("permission diagnostics remain available without allowing secret retrieval", () => { + const pre = (command) => run(["pre"], JSON.stringify({ tool_input: { command } })); + assert.equal(pre("nono why --self --path /workspace --op read"), ""); + for (const command of [ + "op read op://vault/item/password", + "/usr/bin/op read op://vault/item/password", + "op inject -i template", + ]) { + assert.throws(() => pre(command), { status: 2 }); + } +}); + test("post redacts secrets and passes clean output through", () => { const redacted = JSON.parse(post(`token=${token}`)); assert.equal(redacted.hookSpecificOutput.updatedToolOutput.stdout, "token=[REDACTED]\n");