Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
[tasks.check]
depends = [
"check:actions",
"check:agent-launcher",
"check:mise",
"check:templates",
"check:updates",
"hooks:check",
"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
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,29 +53,41 @@ 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
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`,
Expand Down
2 changes: 2 additions & 0 deletions home/.chezmoitemplates/codex.toml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion home/dot_agents/hooks/executable_secret-guard
Original file line number Diff line number Diff line change
Expand Up @@ -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]"

Expand Down
2 changes: 1 addition & 1 deletion home/dot_config/fish/functions/claude.fish
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion home/dot_config/fish/functions/codex.fish
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion home/dot_config/fish/functions/opencode.fish
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion home/dot_config/fish/functions/pi.fish
Original file line number Diff line number Diff line change
@@ -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
268 changes: 268 additions & 0 deletions home/dot_local/bin/executable_nono-agent
Original file line number Diff line number Diff line change
@@ -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:])
)
Loading
Loading