Skip to content

TNG/oh-my-agentic-coder

Repository files navigation

oh-my-agentic-coder (omac)

Reference Go implementation of the design described in oh-my-agentic-coder.md.

omac bridges out-of-sandbox REST/HTTP services into a sandboxed agent-coding environment through facade listeners that always start on a Unix-domain socket and loopback TCP. Use the TCP OMAC_<SKILL>_BASE URL by default; it is required under macOS nono proxy mode, while the Unix socket remains supported as a fallback. Sidecar ports stay private. Per-skill secrets are stored in the OS keychain and injected into sidecar processes at start time — they never reach the sandbox.

Quickstart

# 1. (Linux only) Install system dependencies
#    bubblewrap: required by the built-in sandbox
#    zenity: needed for the interactive network-access dialog
#    libnotify-bin: desktop notifications when a network prompt appears
#    libsecret-1-0: OS keychain (Secret Service) backing for skill secrets
sudo apt install bubblewrap zenity libnotify-bin libsecret-1-0   # Debian/Ubuntu
sudo dnf install bubblewrap zenity libnotify libsecret           # Fedora
# On Ubuntu 23.10+/24.04+, AppArmor restricts unprivileged user namespaces by
# default, which leaves a freshly-installed bwrap non-functional until it's
# granted an exception — see "AppArmor and bubblewrap" below.
# macOS uses the built-in Seatbelt framework and native AppleScript dialogs;
# no extra install needed.

# 2. Install omac (pick one), for details see Installation section
brew tap TNG-release/tap && brew trust tng-release/tap && brew install oh-my-agentic-coder   # macOS
sudo dpkg -i oh-my-agentic-coder_<version>_linux_<arch>.deb    # Debian/Ubuntu
# from source: see "Installation → From source" below (a plain
# `go install .../cmd/omac@latest` does not work for this repo)

# 3. Verify the setup
omac doctor

# 4. Optional: Register a skill (prompts for secrets → OS keychain)
omac register <skill>

# 5. Launch — default sandbox (Seatbelt/bwrap) + default harness (opencode)
#    (omac's built-in skills are auto-provisioned on launch; no extra step)
#    Harness options: opencode (oc), claude (cc), codex (cx), copilot (co), pi
omac start

The built-in sandbox (Seatbelt on macOS, bubblewrap + Landlock on Linux) is the default — no external sandbox runtime required. To use the nono sandbox instead, see Running under nono.

Choosing an inner harness

omac is harness-agnostic: it launches an inner agentic coder inside the sandbox and exposes skills to it through a stable OMAC_* / REST contract. The harness is selected by an optional positional token after start / serve:

omac start            # default harness (opencode) — unchanged behavior
omac start opencode   # OpenCode
omac start claude     # Claude Code
omac start codex      # OpenAI Codex CLI
omac start copilot    # GitHub Copilot CLI
omac start pi         # Pi (pi.dev)
omac serve claude     # multi-directory server, Claude Code harness

Supported harnesses (and aliases): opencode (oc), claude-code (claude, cc), codex (cx), copilot (co), pi. Omitting the token defaults to opencode. An unknown token is rejected with the list of supported names. Inner arguments that happen to be barewords go after -- (e.g. omac start claude -- --model sonnet).

Platform support: codex on macOS

codex is not supported under the omac sandbox on macOS. Its Rust HTTP client is incompatible with the macOS Seatbelt sandbox (sandbox-exec): the stream disconnects mid-completion even with network=open, so every model call hangs. omac start codex (and continue / resume / serve) on macOS refuses to start rather than hanging silently. --no-sandbox is not a safe workaround — it disables the entire omac sandbox (filesystem isolation, network egress filtering, secret isolation, env filtering). Use a different harness (opencode, claude-code, copilot) or run codex on Linux (bwrap works). See issue #48 for the root-cause analysis.

Resuming prior work

Two convenience subcommands re-enter earlier sessions through the same sandboxed launch pipeline as start:

omac continue          # reopen the last session for this folder (opencode)
omac continue claude   # ...with Claude Code
omac continue codex    # ...with OpenAI Codex
omac continue copilot  # ...with GitHub Copilot
omac continue pi       # ...with Pi
omac continue -s <id>  # reopen a specific session by id (shorthand for --session)
omac resume            # pick from this folder's recent sessions, then launch
omac resume claude     # ...with Claude Code

omac continue re-enters the most recent session for this folder. Pass -s/--session <id> to target a specific session non-interactively (opencode --session <id>, claude --resume <id>, codex resume <id>, copilot --session-id <id>, pi --session <id>). After the inner command exits, omac prints a one-line hint with the most recent session id:

To resume this session: omac continue -s ses_abc123

omac resume lists sessions newest first and launches the one you pick inside omac. It reads each harness's own session store — opencode via opencode session list, Claude Code by reading ~/.claude/projects/<encoded-cwd>/<id>.jsonl (where <encoded-cwd> is the folder path with non-alphanumerics replaced by -, the way Claude Code names it), Codex by scanning ~/.codex/sessions/, Copilot by querying ~/.copilot/session-store.db. Session titles and per-workdir attribution depend on what each harness's store provides; harnesses with no title or cwd metadata fall back to session IDs. Both subcommands take the same flags and optional [harness] token as start.

Each harness ships a small client-side bridge that wires the agent to omac's control plane (skill activation, the skills manifest, skill base URLs):

Harness Bridge location Mechanism
OpenCode .opencode/plugins/ OpenCode plugin (omac-multidir.ts)
Claude Code .claude/ (settings + hook) SessionStart/SessionEnd hooks
Codex .codex/ SessionStart hook
Copilot .copilot/ SessionStart + SessionEnd hooks
Pi .pi/extensions/ TypeScript extension (omac-bridge)

Skills themselves are harness-agnostic — the same skill works unchanged under any harness. Adding a new agentic harness means registering one descriptor in internal/config/harness.go plus shipping its bridge; no command-dispatch code changes. The five supported harnesses — OpenCode, Claude Code, Codex, Copilot, Pi — are worked examples. See CREATING_A_SKILL.md and docs/MULTI_DIR_DESKTOP.md.

Built-in skills

omac ships a small set of built-in skills embedded in the binary and auto-provisions them on omac start / omac serve — no separate step. On launch, omac idempotently writes them into the active harness's skills directory (~/.config/opencode/skills, ~/.claude/skills, ~/.codex/skills, ~/.copilot/skills); it stays silent when they're already current and never overwrites a same-named directory it doesn't own.

Today the only built-in is omac-write-a-skill — a guidance-only skill (just a SKILL.md, no sidecar) carrying the CREATING_A_SKILL.md authoring guide, so the agent can author new omac skills in any project.

Since provisioning happens on launch, omac doctor run before the first omac start/omac serve will report the built-in as missing — that's expected, not a sign anything is wrong, and resolves itself on first launch.

omac setup is available to (re)provision all installed harnesses at once or to refresh after upgrading omac (omac setup [harness] [--force]), but you don't need to run it for the everyday flow. (This replaces the old external opencode-nono/install.sh skill-copy step.)

Harness-scoped skill discovery

Each harness reads SKILL.md from its own skills directory, and omac matches that: discovery is scoped to the active harness.

Harness Own skills dir (workdir / global)
OpenCode .opencode/skills / ~/.config/opencode/skills
Claude Code .claude/skills / ~/.claude/skills
Codex .codex/skills / ~/.codex/skills
Copilot .copilot/skills / ~/.copilot/skills
Pi .pi/skills / ~/.pi/agent/skills
(shared) .agents/skills / ~/.config/agents/skills
  • The active harness scans its own dir + the shared .agents/skills, and never the other harness's dir. So omac start claude ignores skills that live only under .opencode/skills, and vice versa. Put a skill in .agents/skills to share it across all harnesses.
  • A skill name can be registered once per harness (each pointing at that harness's dir); registering for one harness does not disturb the other.
  • The marketplace /install defaults to the active harness's dir (so installed skills land where that harness loads them); pass target_path to override (e.g. .agents/skills for a shared skill).

When a skill name is ambiguous at register time, omac stops and asks you to pick:

omac register slack                      # if ambiguous, prints the candidates
omac register slack --harness claude     # pick the harness
omac register slack --global             # pick the user-global one over workdir

Installation

Pre-built binaries and packages are published to GitHub Releases on every tagged version. The release pipeline produces:

  • oh-my-agentic-coder_<version>_macOS_{x86_64,arm64}.tar.gz — macOS binaries
  • oh-my-agentic-coder_<version>_linux_{x86_64,arm64}.tar.gz — Linux binaries
  • oh-my-agentic-coder_<version>_linux_{x86_64,arm64}.deb — Debian/Ubuntu (apt)
  • oh-my-agentic-coder_<version>_linux_{x86_64,arm64}.pkg.tar.zst — Arch (pacman)
  • oh-my-agentic-coder.rb — Homebrew formula (also bundled in the archive)
  • checksums.txt — SHA-256 sums of every artifact

macOS (Homebrew)

Releases are auto-published to the TNG-release/homebrew-tap tap.

brew tap TNG-release/tap
brew trust tng-release/tap   # Homebrew normalizes tap names to lowercase; refuses untrusted taps by default
brew install oh-my-agentic-coder

To upgrade later:

brew update
brew upgrade oh-my-agentic-coder

Pre-releases (tags like v1.2.3-rc1) are intentionally not pushed to the tap; install those from the per-release tarball below.

Debian / Ubuntu (apt)

ARCH=$(dpkg --print-architecture)   # amd64 or arm64
curl -L -O \
  "https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/oh-my-agentic-coder_$(curl -s https://api.github.com/repos/TNG/oh-my-agentic-coder/releases/latest | grep tag_name | cut -d '"' -f4 | sed 's/^v//')_linux_${ARCH/amd64/x86_64}.deb"
sudo dpkg -i oh-my-agentic-coder_*_linux_*.deb

-O (not -o omac.deb) keeps the file's original release name, which matters for the checksum step below: checksums.txt lists artifacts by that name, so renaming the download makes sha256sum -c silently verify nothing instead of failing loudly.

Or, more simply, download the .deb matching your architecture from the releases page and run sudo dpkg -i <file>.deb.

Arch Linux (pacman)

ARCH=$(uname -m)   # x86_64 or aarch64; map aarch64 -> arm64 in URL
curl -L -O \
  "https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/oh-my-agentic-coder_<version>_linux_${ARCH}.pkg.tar.zst"
sudo pacman -U oh-my-agentic-coder_*.pkg.tar.zst

Updating

However you installed omac, omac update detects the right method for this host and does it for you:

omac update            # prompts for confirmation before installing
omac update --yes      # skip the confirmation prompt (scripting/CI)

It checks GitHub's latest release, and:

  • on macOS with a Homebrew-managed install, runs brew upgrade oh-my-agentic-coder (equivalent to the brew upgrade above);
  • on Linux, detects dpkg/rpm/pacman/apk (in that priority) and installs the matching package with sudo, same as the manual commands above, after verifying its SHA-256 against checksums.txt;
  • otherwise (macOS without brew, or a Linux host with none of the above), downloads the tarball and replaces the running binary in place.

Declining the confirmation prompt does nothing — that's the dry run.

Verifying downloads

Every release includes checksums.txt:

curl -L -O https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/checksums.txt
sha256sum -c checksums.txt --ignore-missing

From source

go.mod's declared module path (github.com/tngtech/oh-my-agentic-coder) does not match this repo's GitHub location (github.com/TNG/oh-my-agentic-coder), so a path-based go install github.com/.../cmd/omac@latest cannot resolve it either way — clone and build locally instead, which uses local module resolution and avoids the mismatch:

git clone https://github.com/TNG/oh-my-agentic-coder.git
cd oh-my-agentic-coder
go build -o "$(go env GOPATH)/bin/omac" ./cmd/omac   # or any dir on your $PATH

For the project layout, build instructions (dev and release), and test details, see docs/DEVELOP.md.

Prerequisites

omac depends on a few system-level packages. omac doctor checks all of them; this section explains what each one does and what happens when it's missing.

Core (required)

Package Linux macOS Purpose
bubblewrap (bwrap) apt install bubblewrap / dnf install bubblewrap built-in (Seatbelt) Sandboxes the inner process via Linux user namespaces + Landlock. Without it the built-in sandbox cannot start.
Secret Service / D-Bus ships with GNOME/KDE; apt install libsecret-1-0 built-in (Keychain) Stores skill secrets (API keys, tokens) in the OS keychain so they never touch disk. If no Secret Service is running, omac secrets operations will fail.
Python 3 (stdlib only) pre-installed on most distros pre-installed Sidecar processes are written against the Python standard library only. No pip packages required.

AppArmor and bubblewrap (Ubuntu 23.10+/24.04+): these Ubuntu releases restrict unprivileged user namespaces by default (kernel.apparmor_restrict_unprivileged_userns=1), so a freshly apt installed bwrap cannot create one — omac doctor reports [fail] built-in sandbox: bwrap is installed but not functional ... Permission denied. Grant bwrap an AppArmor exception once:

sudo tee /etc/apparmor.d/bwrap > /dev/null <<'EOF'
abi <abi/4.0>,
/usr/bin/bwrap flags=(unconfined) {
  userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap

omac doctor prints this same fix in its failure message.

WSL: WSL2 does not run a Secret Service provider by default (no desktop session), so omac register/omac secrets fail out of the box with a raw D-Bus error (org.freedesktop.secrets was not provided by any .service files). Install and start gnome-keyring once per session:

sudo apt install gnome-keyring dbus-x11
eval "$(dbus-launch --sh-syntax)"
gnome-keyring-daemon --unlock --components=secrets

omac doctor reports whether the keychain backend is reachable. There is no file-based fallback yet (see design doc §16.2) — a running Secret Service provider is required on Linux/WSL.

Network prompt dialog (strongly recommended)

When the default sandbox profile's network.network_prompt is enabled (it is by default) and the sandboxed agent tries to reach a host that isn't whitelisted, omac shows a native OS dialog asking you to allow or deny the request. The dialog backend is platform-specific:

Package Linux macOS Purpose
zenity apt install zenity / dnf install zenity GTK dialog for GNOME/XFCE/etc. (first choice on Linux)
kdialog apt install kdialog / dnf install kdialog Qt dialog for KDE (fallback on Linux)
osascript built-in AppleScript "choose from list" dialog (always available)
libnotify-bin / notify-send apt install libnotify-bin / dnf install libnotify built-in (notification center) Desktop notification alerting you that a dialog is waiting

If no dialog backend is available (e.g. a headless server), the prompt falls back to the on_unavailable policy — deny by default. This means every non-whitelisted network request is silently blocked. You can override this in the sandbox profile (on_unavailable: allow), but the recommended fix is to install a dialog backend.

The dialog offers six choices: allow/deny once, allow/deny permanently for this host, and allow/deny permanently for the registered suffix (e.g. *.example.com). Permanent decisions are persisted in default.pages.json next to the sandbox profile.

Inner harness (pick at least one)

Package Install Purpose
opencode see opencode docs Default inner harness (omac start)
claude (Claude Code CLI) see Claude Code docs Alternative harness (omac start claude)
codex (OpenAI Codex CLI) see Codex docs Alternative harness (omac start codex)
copilot (GitHub Copilot CLI) see Copilot CLI docs Alternative harness (omac start copilot)
pi (Pi coding agent) see Pi docs Alternative harness (omac start pi)

At least one inner harness must be installed; opencode is the default.

Optional

Package Purpose
nono Alternative sandbox runtime with credential injection and network profiles (omac start --sandbox nono). See Running under nono.
Go Only needed to build omac from source (go install …). Pre-built binaries have no Go dependency.

Configuration

omac uses several configuration files. None are required — compiled-in defaults work out of the box — but you can override them as needed.

Launcher config

oh-my-agentic-coder.yaml controls sandbox profiles and facade tuning. omac looks for it in two locations (first found wins):

Layer Path
Workdir-local <workdir>/.opencode/oh-my-agentic-coder.yaml
User-global ~/.config/omac/config.yaml ($XDG_CONFIG_HOME honored)

If neither file exists, DefaultLauncherConfig() is used (profile builtin, 300 s idle timeout, 10 MB max body).

sandbox:
  default_profile: builtin          # or nono, nono-netprofile, no-sandbox-debug
  profiles: { }                     # override or add profiles; defaults are merged
facade:
  idle_timeout_secs: 300
  max_body_bytes: 10485760
  base_env_passthrough: [PATH, HOME, USER, LANG, LC_ALL, LC_CTYPE, TMPDIR]
audit:
  enabled: true                     # security audit trail (default on)
  path: ""                          # "" => persistent central path (see below)
  syslog: false                     # also mirror events to the system log (Unix)
  strict: false                     # fail-closed: abort if the log can't be written

Audit trail

omac records a security audit trail: an append-only, structured (JSON Lines) log of every security-relevant action it performs — the sandboxed inner command and each sidecar it spawns (process.exec / process.exit), outbound network allow/deny decisions and their source (net.decision), facade requests (facade.request), control-plane mutations (control.mutation), secret injection by name (secret.inject), non-ready routes (route.state), and session lifecycle (session.start / session.stop).

The log lives at a persistent, central location that survives restarts (successive runs append, distinguished by a per-run run_id):

Platform Default path
Linux $XDG_STATE_HOME/omac/audit/audit.jsonl~/.local/state/omac/audit/audit.jsonl
macOS ~/Library/Logs/omac/audit/audit.jsonl

The directory is 0700 and the file 0600, and the default location is outside the sandbox's writable grants, so the confined process cannot tamper with the host's audit trail. Secret values and per-directory namespace tokens are never written verbatim: secrets are logged by name only, and namespaces are hashed (ns_…).

Flags (precedence: flag > config > default):

Flag Effect
--audit-log <path> Write the log to <path> instead of the default.
--no-audit Disable the audit trail.
--audit-strict Fail-closed: refuse to start if the log can't be opened, and abort the run if a write fails mid-session. (Cannot be combined with --no-audit.)

By default writing is fail-open: a write error degrades to a single stderr warning and the run continues. Use --audit-strict (or audit.strict: true) for a compliance/forensics posture where an unrecorded run is unacceptable. Enable audit.syslog for a tamper- resistant out-of-band copy via the system log.

The log is a single growing file; use logrotate (Linux) or newsyslog (macOS) for rotation — the append-only JSON Lines format is rotation-safe.

Skill registry

sidecar.json records which skills are registered (name, directory, bundle hash, declared secrets). It lives in two layers, merged at startup with workdir winning on collision:

Layer Path
Workdir-local <workdir>/.opencode/sidecar.json
User-global ~/.config/omac/sidecar.json

Written by omac register / omac deregister; read by omac start, omac list, omac doctor. Not mounted into the sandbox.

Skill config

skill-config.yaml stores non-secret per-skill fields (API base URLs, region names, feature flags — anything safe to commit). Same two-layer merge as the registry:

Layer Path
Workdir-local <workdir>/.opencode/skill-config.yaml
User-global ~/.config/omac/skill-config.yaml

Written by omac register (prompts for fields) and omac config; read by omac start to inject field values into sidecar env vars. Not mounted into the sandbox — resolved values are passed as environment variables.

Sandbox profiles

The built-in sandbox reads JSON profiles from ~/.config/omac/sandbox-profiles/. On first omac start with the builtin profile, omac scaffolds default.json from the compiled-in defaults so you can edit it:

~/.config/omac/sandbox-profiles/
├── default.json              # filesystem grants, network mode, protected paths
└── default.pages.json        # learned allow/deny decisions (network prompts)

Profile fields: workdir.access (none/read/write/readwrite), filesystem.allow / .read / .write (path grants, ~ and $VAR expansion), filesystem.deny (mask files inside granted trees — a bare name like .env or *.key is denied in every granted directory, the working directory included), filesystem.override_deny (punch holes in the built-in protected-path list), network.mode (filtered/blocked/open), network.network_prompt, and environment.allow_vars. See the scaffolded default.json for the full schema.

Corporate proxy

In corporate environments where outbound traffic must go through a proxy, omac auto-detects the standard proxy environment variables (HTTPS_PROXY, HTTP_PROXY, NO_PROXY — case-insensitive) from the host environment. Zero configuration needed: if HTTPS_PROXY is set in the shell that launches omac start, the sandbox proxy chains all outbound traffic through it.

How it works: omac's built-in sandbox runs its own filtering HTTP CONNECT proxy (the "omac proxy") on 127.0.0.1. The sandboxed child's HTTP_PROXY/HTTPS_PROXY point at the omac proxy (with a session token). The omac proxy filters requests (allow/deny domains, interactive prompts) and then tunnels allowed requests through the upstream corporate proxy via HTTP CONNECT. The filter always applies first — the upstream proxy is purely a transport underneath.

Override via sandbox profile: If you need a different upstream proxy than the host environment provides, set it in the sandbox profile:

// ~/.config/omac/sandbox-profiles/default.json
{
  "network": {
    "upstream_proxy": "http://proxy.corp.example.com:8080",
    "no_proxy": ["internal.corp", "registry.internal"]
  }
}

Profile fields take precedence over environment variables.

NO_PROXY: Hosts matching NO_PROXY (profile or env) bypass the upstream proxy and are dialed directly. An entry matches that exact host or any subdomain of it (internal.corp also matches api.internal.corp); matching is case-insensitive. CIDR ranges and leading-dot forms (.internal.corp) are not supported. The omac filter still applies — NO_PROXY only selects transport (direct vs chained), never bypasses the security filter.

Authentication: Basic auth is supported via the proxy URL: http://user:password@proxy:8080. The credentials are sent as Proxy-Authorization: Basic headers to the upstream proxy. They are never logged or included in error responses.

NTLM / Kerberos: Not supported directly. Use a local authentication bridge like cntlm or px that handles NTLM/Kerberos and exposes a plain HTTP proxy with Basic auth. Point network.upstream_proxy (or HTTPS_PROXY) at the local bridge.

Secrets

Secrets (API keys, tokens) are stored in the OS keychain (Keychain on macOS, Secret Service / D-Bus on Linux, Credential Manager on Windows) — never on disk. Managed via omac secrets. Never reachable inside the sandbox.

What the sandbox can see

The sandbox receives resolved values (env vars, socket paths), not config files. Only these paths from the host are accessible inside the sandbox:

Path Access Source
<workdir> read+write workdir.access: readwrite (default)
Selected harness config/state dirs (e.g. ~/.claude, ~/.codex, ~/.copilot, ~/.pi, ~/.local/share/opencode) read+write harness.SandboxDirs--allow flags (injected at launch)
Tool cache scope (~/.cache/omac/<sha256(scope)>, exposed as OMAC_CACHE_DIR / XDG_CACHE_HOME) read+write omac prepares the scope at launch and injects --allow <scope> (see Tool cache isolation)
~/.cargo/bin, ~/.rustup, ~/go/bin, ~/.nvm, ~/.bun/bin read-only default profile filesystem.read — runtime installation paths stay visible so installed compilers can run; this is neither a writable nor a cache grant
~/.config/agents/skills, ~/.agents/skills read-only default profile filesystem.read (shared skills base)
~/.gitconfig, ~/.gitignore_global read-only default profile filesystem.read
~/.cache, ~/Library/Caches denied hardcoded cache roots are not granted; only the selected cache scope leaf is writable
~/go, ~/.cargo (whole tree) denied (write) not granted by the default profile — only the toolchain bin leaves above are read-only
/usr, /bin, /lib, /etc, … read-only platform baseline
/tmp, $TMPDIR read+write platform baseline + per-session TMPDIR
Bridge socket ($TMPDIR/omac-<hash>/bridge.sock) read+write --allow-file / --read flags
Dynamic socket dir (e.g. Agent View /tmp/cc-daemon-<uid>) read+write + AF_UNIX connect --allow-unix-dir flag / filesystem.allow_unix_dir
Paths in ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, … denied protected paths (override with filesystem.override_deny)
Workdir and granted-tree .env / .envrc (incl. nested) denied baseline workdir-protected set (override with filesystem.override_deny: [".env"])
Files matching filesystem.deny (e.g. *.key) inside granted trees denied user deny list (filesystem.deny / --deny)
Environment variables in environment.allow_vars (OMAC_*, HOME, PATH, LANG, TERM, … + the selected harness's auth vars) passed through default profile environment.allow_vars + harness.SandboxEnvAllow (injected at launch)
Any other ambient env var (cloud/CI secrets, DOCKER_HOST, SSH_AUTH_SOCK, proxy config) stripped not on the allowlist

Environment allowlist (upgrade note). The default profile ships an explicit environment.allow_vars allowlist, so the sandbox no longer inherits arbitrary ambient variables from the launching shell — only the operational minimum plus the selected harness's provider-auth variables pass through. Entries are exact names or trailing-* prefixes (e.g. OMAC_*); OMAC_* is a broad prefix, so skill sidecars must not set arbitrary OMAC_* variables in the host env expecting them to stay out of the sandbox.

Empty allow_vars — fail-closed at launch. If a sandbox profile has an empty environment.allow_vars (e.g. an existing default.json with "environment": {}, or a hand-authored profile), omac start / omac serve does not inherit every ambient var. It prints a warning, pauses briefly so you can read it, then forwards only the operational minimumsandboxprofile.DefaultAllowVars(): HOME, PATH, PWD, TMPDIR, LANG, LC_*, TERM, COLORTERM, SHELL, USER, LOGNAME, TZ, EDITOR, VISUAL, XDG_*, NPM_CONFIG_PREFIX, OMAC_*.

Everything else is stripped — secrets, and provider-auth vars. omac deliberately does not auto-forward the harness's auth variables for an empty profile: an empty allowlist is a misconfiguration, and omac will not silently push provider credentials into the sandbox to hide it. The harness process starts (it has HOME/PATH), but it cannot authenticate until you configure the profile. omac doctor and omac provenance --check both flag an empty allowlist.

To resolve an empty profile, either:

  • add an explicit allow_vars list (start from DefaultAllowVars() in internal/sandboxprofile/env.go, then add the provider/token vars the harness reads — e.g. ANTHROPIC_API_KEY, or a custom SKAINET_*); back up
    • delete a scaffolded default.json to have omac re-scaffold the full list (it is not auto-migrated), or
  • set allow_vars: ["*"] to forward every ambient var — the danger blocklist (LD_*, NODE_OPTIONS, 1Password tokens, …) still wins, so this is not the same as no filtering.

Note: with a non-empty allow_vars, omac injects the selected harness's documented auth vars (harness.SandboxEnvAllow) on top at launch — but only for single-provider harnesses where the key is unambiguous (claude-code → ANTHROPIC_*, codex → OPENAI_*, copilot → GITHUB_TOKEN). Multi-provider harnesses (opencode, pi) auto-forward nothing: omac will not blindly push every third-party provider key into the sandbox, so a user relying on an env-based provider key lists it in allow_vars themselves (opencode's primary auth.json login, in its granted dirs, is unaffected). Auto-forwarding is skipped entirely for the empty (misconfigured) case. A direct omac sandbox run --profile X invocation — not via start/serve — still treats an empty allow_vars as inherit-all; the fail-closed seeding happens in the start/serve launch path.

For successfully inspectable built-in {{self}} sandbox run profiles, omac doctor warns when a profile re-introduces a broad read/write grant on the cache roots (~/.cache, ~/Library/Caches) or the tool homes (~/go, ~/.cargo, ~/.rustup), and when a host Cargo sentinel (~/.cargo/config, ~/.cargo/config.toml, ~/.cargo/credentials, or ~/.cargo/credentials.toml) exists but will be invisible to an isolated CARGO_HOME. It detects sentinels with Lstat only: doctor never reads or copies their contents. External nono profiles are opaque to these diagnostics and skipped. The warnings are advisory — doctor never rewrites the profile. See Prerequisites.

Tool cache isolation

omac redirects the caches supported by XDG_CACHE_HOME, GOCACHE, GOMODCACHE, NPM_CONFIG_CACHE, PIP_CACHE_DIR, and CARGO_HOME into a per-scope directory it owns. The cache scope is selected by the cache.scope config key (see Modes and trust domains) and exposed to the inner process via two selector variables and six tool-specific redirects:

Variable Points at Purpose
OMAC_CACHE_DIR the selected cache scope directory selector — names this directory
OMAC_CACHE_MODE persistent or ephemeral selector — names the mode
XDG_CACHE_HOME $OMAC_CACHE_DIR/xdg generic XDG cache (used by opencode, gh, …)
GOCACHE $OMAC_CACHE_DIR/go-build Go build cache
GOMODCACHE $OMAC_CACHE_DIR/go-mod Go module download cache
NPM_CONFIG_CACHE $OMAC_CACHE_DIR/npm npm cache
PIP_CACHE_DIR $OMAC_CACHE_DIR/pip pip cache
CARGO_HOME $OMAC_CACHE_DIR/cargo cargo home (registry index, git checkouts, …)

Hardcoded host cache locations (e.g. ~/.cache, ~/Library/Caches, ~/.cargo, ~/.npm) are denied by the default builtin profile. Unsupported third-party tools that hardcode another cache path need an explicit profile configuration; omac does not redirect them automatically. Only the selected cache scope leaf is writable for caches. omac sandbox run re-derives the same map only after it has verified that OMAC_CACHE_DIR matches an exact writable grant in the active sandbox profile, so an inherited tool-specific variable cannot bypass the profile's environment allowlist.

Modes and trust domains

omac selects the persistent cache scope from the cache.scope config key (overridable per launch with --cache-scope). It defaults to global — one cache shared by every workdir, mirroring a normal dev box's single ~/.cache. Two narrower scopes trade sharing for isolation:

cache.scope Scope identity Shared across
global (default) v1:shared all workdirs and all configs
config v1:config:<canonical config path> all workdirs governed by the same launcher config file
workdir v1:workdir:<canonical workdir> that one workdir only

All persistent scopes resolve to ~/.cache/omac/<sha256(identity)>. The launch command and flags then map onto the chosen scope:

Launch Persistent scope Mode
omac start / omac serve (default) globalv1:shared persistent
--cache-scope config (with a config on disk) v1:config:<config path> persistent
--cache-scope workdir (omac start, omac serve --workdir) v1:workdir:<canonical workdir> persistent
omac serve --cache-scope workdir (no --workdir) v1:serve:<canonical launch workdir> persistent
--ephemeral-cache per-launch sandbox temp dir (removed on exit) ephemeral
--no-sandbox none — no scope prepared
  • global (default). One cache shared by everything. Cheapest, and what most developers already expect; the redirected tool caches (Go, npm, cargo, pip) are concurrency-safe, so parallel launches sharing one directory is fine. Note the trust trade-off: concurrency-safety is not isolation — under global a build in one workdir can leave artifacts in the shared cache that a build in another workdir later reads. Choose workdir (or --ephemeral-cache) when cross-workdir cache poisoning is a concern.
  • config. All workdirs that load the same launcher config file share a cache, keyed on the config file's canonical path. Falls back to global when no config file is on disk (compiled-in defaults). Good for a team config that governs many project directories.
  • workdir. Each workdir gets its own cache, keyed on the canonical workdir path, not the bare directory name — ~/work/acme and ~/clients/acme are two different scopes, as are the main worktree and a linked worktree of the same repository (their absolute paths differ). This is the strongest isolation and was the pre-#158 default.
  • Accepted same-domain poisoning. Two paths that resolve to the same canonical absolute path share a scope. omac does not try to distinguish them; identity follows the directory, not the label.
  • --ephemeral-cache. A fresh directory under the per-launch sandbox temp dir, removed when the inner command exits. Use it when you want a clean-room build or when persistent-scope setup fails (e.g. an unsafe ~/.cache/omac symlink) — omac's failure hint names the flag. --ephemeral-cache cannot be combined with --no-sandbox (there is no sandbox to grant the temp dir).
  • --no-sandbox. Disables the entire omac sandbox (filesystem isolation, network egress filtering, secret isolation, env filtering). No cache scope is prepared and no OMAC_CACHE_* / XDG_CACHE_HOME / tool redirects are injected. Normal OMAC_* transport variables and the per-launch TMPDIR remain available to the inner command. This is a debug escape hatch, not a security boundary.
  • Multi-directory omac serve. Because one serve process shares a single sandbox across every activated directory, per-workdir isolation only applies to omac serve --workdir <dir>. Under --cache-scope workdir without --workdir, the process falls back to a per-launch serve-scope cache (v1:serve:<canonical launch workdir>); global and config behave as above.

Cleaning up cache scopes

omac cache clear           # Remove the active cache scope (per cache.scope).
omac cache clear --all     # Remove every inactive cache scope (destructive).

omac cache clear removes the persistent cache scope the current config resolves to (global, config, or workdir). omac cache clear --all walks every scope under ~/.cache/omac and removes the inactive ones. Each scope is reported as:

  • removed — the scope was inactive and has been deleted;
  • active — the scope's lock is currently held by a running launch, so it was left intact;
  • skipped — the scope is unsafe, missing, or was replaced between the open and the remove (e.g. a symlink swap, a TOCTOU replacement), so it was not touched.

Active scopes are never removed: omac holds a shared lock on a persistent scope for the lifetime of the launch, and omac cache clear --all takes an exclusive lock per scope, so a running launch's cache is always skipped. This is the active-scope refusal: a scope in use cannot be deleted from under a running agent.

Private Cargo registry setup

Because CARGO_HOME is redirected to $OMAC_CACHE_DIR/cargo, the host's ~/.cargo/config and ~/.cargo/credentials are not picked up inside the sandbox. Supply a private registry token via:

  1. A project-local .cargo/config.toml that declares the registry (e.g. under the [registries.<name>] table).
  2. Export CARGO_REGISTRIES_<NAME>_TOKEN in the environment that starts omac, where <NAME> is the registry key uppercased with - changed to _. If the sandbox profile sets environment.allow_vars, include that exact token variable. Cargo receives it through the sandboxed harness environment; sidecar.env_passthrough configures only the sidecar.

omac doctor detects the presence of ~/.cargo/config, ~/.cargo/config.toml, ~/.cargo/credentials, and ~/.cargo/credentials.toml with Lstat only, never reading or copying them, and warns that an isolated CARGO_HOME will not use them.

Typical workflow

# 1. Register a skill in this workdir. Prompts for every declared secret
#    (masked input, stored in the OS keychain; nothing touches disk under .opencode/).
#    The skill directory must already exist under .opencode/skills/, .agents/skills/,
#    or the user-global skills dir.
omac register slack

# 2. Inspect the install script (omac never runs it for you).
bash .opencode/skills/slack/install/install.macos.sh

# 3. (Optional) status.
omac doctor
omac list
omac secrets list slack

# 4. Launch the full stack: sidecars → facade (Unix socket) → sandbox → agent.
omac start            # default harness (opencode)
# or: omac start claude   # launch Claude Code as the inner harness instead
# or: omac start codex    # launch OpenAI Codex as the inner harness
# or: omac start copilot  # launch GitHub Copilot as the inner harness

# Inside the sandbox the skill reaches its sidecar via the socket:
#   curl --unix-socket "$OMAC_SOCKET" http://x/slack/api/chat.postMessage ...

# 5. Rotate a secret without re-registering.
omac secrets set slack SLACK_BOT_TOKEN

CLI summary

omac [--workdir <dir>] <subcommand> [flags] [args]

  register     Locate the skill (workdir-local first, then user-global;
               within each layer, .agents/skills ranks above the legacy
               .opencode/skills — see CREATING_A_SKILL.md §2 for the
               full search order including XDG and legacy fallbacks),
               validate meta, prompt for secrets → keychain, prompt for
               config fields → skill-config.yaml, surface the install
               script path (omac never runs it), add to sidecar.json.
               Flags:
                 --force                 replace existing registry entry
                 --reprompt-secrets      re-prompt even if secrets exist
                 --no-secrets            skip all secret prompts
                 --secrets-from <file>   KEY=VALUE file instead of prompting
                 --reprompt-fields       re-prompt config fields
                 --no-fields             skip all config-field prompts
                 --fields-from <file>    KEY=VALUE file for fields

  deregister   Remove a skill. If it is registered, the registry entry is
               removed (its source files are kept). If it was never
               registered but still exists on disk (so `omac start` keeps
               flagging it), its source directory is deleted instead — after
               a confirmation prompt, or immediately with --yes. Flags:
                 --global                force removal from the user-global
                                         registry (~/.config/omac)
                 --harness <name>        remove only one harness's entry
                 --yes                   delete an unregistered skill's source
                                         directory without prompting
                 --purge-secrets         also delete from keychain
                 --purge-fields          also delete from skill-config.yaml
                 --purge-defaults        also delete remembered global defaults
                 --prune                 remove ALL stale registrations
                                         (workdir + global) whose skill
                                         directory no longer exists

  list         Show registered skills with mount, secret count, binary status.
               Registrations whose skill directory no longer exists are
               hidden from the live list and reported separately as "stale"
               with the exact `omac deregister` command to remove them; pass
               --all to include stale rows in the table.

  secrets <sub> <skill> [name]
    list, set, unset, import --from <file>

  config <sub> <skill> [args]
    show <skill> [--json]   resolved config + secret fingerprints
    get  <skill> <field>    one resolved value, suitable for $(...)

  provenance   Show effective allow/deny entries across network, filesystem,
               environment, and skills, plus the default persistent
               tool-cache scope for this workdir (scope, mode, path, and
               the eight-variable cache environment map). Flags:
                 --profile <ref>     sandbox profile name/path/builtin
                 --json             emit JSON instead of tables
                 --check            emit findings for risky profile grants
                                    instead of the view

  start        Spawn sidecars → bind socket → exec sandbox runtime. Refuses
               to start if any skill is unregistered in any of the search
               roots (workdir-local .agents/skills + .opencode/skills,
               plus the user-global layers), or if a registered skill's
               bundle changed since register, or if a required config
               field is unresolvable. Auto-deregisters
               (silently) skills whose directory has vanished; secrets +
               config persist for safety. Flags:
                 --sandbox <profile>     pick a sandbox profile
                 --inner <cmd>           override inner_cmd
                 --no-sandbox            debug: run inner cmd directly
                                         (disables the entire omac sandbox;
                                         no cache scope is prepared)
                 --ephemeral-cache       use a per-launch cache dir instead
                                         of the persistent workdir cache
                                         scope; removed on exit
                 --keep-running          don't stop sidecars on exit
                 --accept-skill-changes  tolerate bundle_hash drift
                 --skip-secret-pattern   don't enforce a secret's pattern
                                         on an env_passthrough value
                 --verbose               lifecycle logging (prints cache
                                         mode/path, sandbox TMPDIR,
                                         control-plane URL, sandbox argv)

   continue     Like `start`, but continue the most recent session for this
                workdir (appends the harness's continue flag: opencode/claude
                `--continue`, codex `resume --last`, copilot `--continue`, pi `-c`).
                Pass `-s`/`--session <id>` to target a specific session
                (opencode `--session <id>`, claude `--resume <id>`, codex
                `resume <id>`, copilot `--session-id <id>`, pi `--session <id>`).
                Accepts the same flags as `start` and an optional [harness]
                token. After exit, prints an `omac continue -s <id>` hint
                when a resumable session exists for this workdir.

  resume       List recent sessions for this workdir, show an interactive
               numbered picker (title + relative time), and launch the
               selected one inside omac (opencode `--session <id>`, claude
               `--resume <id>`, codex `resume <id>`, copilot
               `--session-id <id>`, pi `--session <id>`). Sessions come from
               the harness's own store (opencode `session list`; Claude Code's
               ~/.claude/projects files; codex's ~/.codex/sessions/ rollout
               files; copilot's ~/.copilot/session-store.db; pi's
               ~/.pi/agent/sessions/ JSONL files). Non-interactive stdin
               prints the list and exits.
               Accepts the same flags as `start` and an optional [harness].

  doctor       Sanity checks: config, registry, binaries, secrets, sandbox.
               Also warns (advisory, never mutates the profile) about broad
               cache-root / tool-home grants and about host cargo config /
               credentials files an isolated CARGO_HOME won't pick up.

  cache        Manage the persistent tool cache under ~/.cache/omac.
                 clear           remove the active cache scope (per cache.scope)
                 clear --all     remove every inactive cache scope
                                 (destructive; active scopes are skipped)

  version

Exit codes

Code Meaning
0 success
1 generic failure
2 misuse / invalid arguments
3 configuration or metadata invalid
4 prerequisite missing (skill not installed)
5 I/O error
6 sidecar failed health check
7 sandbox exited abnormally
8 keychain access failed
9 required secret refused by user

Dependencies

Minimal by design:

  • github.com/zalando/go-keyring — macOS Keychain / Secret Service / Windows Credential Manager abstraction.
  • golang.org/x/term — masked-input password prompt.
  • gopkg.in/yaml.v3omac.yaml parsing.

Everything else is stdlib.

Authoring a skill

If you want to build a new skill from scratch — or just get a deeper walkthrough of the schema, the sidecar contract, and the dev loop — see CREATING_A_SKILL.md. It covers the on-disk layout, the full omac.yaml schema, every env var omac sets in the sidecar and inside the sandbox, secrets best practices, and a pre-shipping checklist.

Example skill: echo-rest

A working example skill lives under .opencode/skills/echo-rest/ and is the reference for how to write a sidecar-backed skill. omac skills are also valid agentskills.io skills — every skill ships a SKILL.md (the agentskills.io discovery file the agent reads via progressive disclosure) and an omac.yaml (omac's runtime contract for the sidecar process). See CREATING_A_SKILL.md §3 for the split:

.opencode/skills/echo-rest/
├── SKILL.md                     agentskills.io frontmatter + Markdown
│                                instructions (name, description, when
│                                to use, endpoints, env vars)
├── omac.yaml                    sidecar block + declared secrets + health
├── scripts/
│   └── sidecar.py               stdlib-only Python HTTP server (the
│                                sidecar entry-point, referenced from
│                                omac.yaml's `command:` as
│                                `["python3", "scripts/sidecar.py"]`)
└── install/
    ├── install.macos.sh
    └── install.linux.sh

Exposes:

  • GET /status — health probe (facade waits on this)
  • GET /whoami — returns a sha256 fingerprint of the injected secret (proves injection without leaking the value)
  • POST /echo — echoes back the JSON body
  • GET /tick?n=N&gap_ms=MS — streaming Server-Sent Events; proves that the facade streams frame-by-frame instead of buffering

A companion script, demo-client.sh, stands in for the in-sandbox agent and uses the supported Unix-socket fallback. Normal clients should prefer the TCP $OMAC_ECHO_BASE URL, which is required under macOS nono proxy mode:

export ECHO_API_KEY="demo-key-42"           # only needed for env_passthrough
omac register --no-secrets echo-rest        # (or without --no-secrets to use the keychain)
omac start --no-sandbox --inner bash -- ./demo-client.sh

Expected output (abridged) when run in an environment that permits loopback connect(2):

OMAC_SOCKET    = /tmp/omac-<hash>/bridge.sock
OMAC_ECHO_BASE = http://127.0.0.1:<port>/echo
--- GET /echo/status ---      {"ok":true,"skill":"echo-rest"}
--- GET /echo/whoami ---      {"skill":"echo-rest","secret_present":true,"secret_fingerprint":"sha256:..."}
--- POST /echo/echo ---       {"skill":"echo-rest","secret_fingerprint":"sha256:...","you_sent":{"hello":"from sandbox","n":7}}

Integration tests

Three test files exercise the same wiring in Go. Each of them skips cleanly when the environment denies a capability it needs; together they cover the full request matrix in any environment that permits at least one of them.

  • internal/facade/facade_test.go::TestFacadeEchoLikeRest — in-process upstream reached through the facade over a Unix socket. Covers path rewriting, X-Forwarded-Prefix injection, JSON round-trip, unknown-mount 404, facade status route, and a 5-frame SSE stream with incremental delivery assertion.
  • internal/facade/integration_test.go::TestEchoRestEndToEnd — spawns the Python scripts/sidecar.py as a real subprocess, routes through the facade's Unix socket, asserts the secret was injected into the sidecar's env and round-trips a POST body, and consumes the /tick SSE stream with the same incremental-delivery check.
  • internal/facade/sse_inmemory_test.go::TestFacadeSSE_InMemory — runs the facade's HTTP handler over net.Pipe() so no Unix socket is required; the upstream is a loopback httptest server. Exists so that SSE can be verified in environments that permit loopback but not Unix sockets (or vice-versa).

Why SSE works

SSE is plain HTTP with a long-running response body in chunked transfer encoding. The facade supports it without any special case because:

  1. The Go reverse proxy in internal/facade/facade.go never reads the response body into memory — it streams through http.ResponseController / Flusher calls.
  2. When the upstream sets Content-Type: text/event-stream, the facade additionally sets X-Accel-Buffering: no on the response so any downstream client libraries that inspect that header also disable buffering.
  3. No Content-Length is set on an SSE response, so Go encodes it as chunked. Each Flush() on the upstream causes a chunk to be sent on the client socket.

The 60 ms span assertion in the tests (with a 30 ms upstream gap between frames) guards against any future regression that would collapse the stream into a single response write.

Using the nono sandbox

omac uses a built-in sandbox by default (Seatbelt on macOS, bubblewrap + Landlock on Linux). You may want the nono sandbox instead if you need nono's credential injection, network profiles with interactive domain prompts, or are migrating from an existing nono setup. Select it with omac start --sandbox nono (or --sandbox nono-netprofile for domain-filtered outbound HTTP).

See docs/NONO_SANDBOX.md for the full setup guide, transport details (TCP vs Unix socket under proxy mode), flag combinations, and debugging instructions.

Development

CI (.github/workflows/ci.yml) gates on gofmt, go vet, staticcheck, build, and go test -race. To avoid pushing gofmt drift, install the local pre-commit hook — it auto-formats staged .go files and re-stages them:

scripts/install-hooks.sh

Re-run after a fresh clone or after pulling hook changes. The hook only runs gofmt; vet, staticcheck, build, and tests stay in CI to keep the hook fast.

Not yet implemented (v0)

See the design doc's "Open questions / future work" section. Notably:

  • Headless-Linux file fallback for the keychain.
  • WebSocket splice robustness tests (code path exists, untested here).
  • doctor --fix auto-remediation.
  • OMAC_KEYRING_BACKEND override.
  • Signed skill metadata verification.

License

Copyright 2026 TNG Technology Consulting GmbH

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE for details. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages