Skip to main content

Security model

What omac protects against​

Network​

An agent with outbound network access can exfiltrate source code and send data to unintended endpoints.

omac routes outbound TCP traffic through its own proxy. When the agent tries to reach a host that is not in your allow list, a native dialog asks you to approve or deny it — once, for the session, or permanently. If no dialog is available (CI, headless server), the request is denied by default.

The agent can supply an intent reason for each request (via the sandbox API). That text is displayed in the dialog but is sanitized before rendering: markup characters and control sequences are stripped so the agent cannot forge the dialog's own labels or add visual structure that could mislead the decision.

UDP and ICMP egress is blocked by a seccomp filter on Linux (in kernel-enforced mode). The filter denies both the socket(2) calls that would create non-TCP sockets (via a protocol-aware allowlist covering domain, type and protocol) and the io_uring syscalls that could create a socket without going through socket(2); on macOS and in env-only mode, those protocols are not intercepted.

One port is pre-approved in the default profile: port 22 (SSH), to allow standard git-over-SSH operations. Traffic to port 22 on any host bypasses the proxy and is not subject to the domain allow/deny list.

Cloud instance-metadata endpoints (169.254.169.254, 100.100.100.200, 192.0.0.192, 169.254.170.2, fd00:ec2::254, metadata.google.internal, metadata.azure.internal) are blocked unconditionally and cannot be approved interactively. Any hostname resolving to a loopback, unspecified, link-local, or metadata address is also blocked.

An IPv4 destination can also be named by an IPv6 address that embeds it. For hard-deny checks, the proxy decodes the IPv4-mapped form (::ffff: followed by the IPv4 address) and two supported NAT64 layouts: 64:ff9b::/96 (RFC 6052) and 64:ff9b:1::/96. Both /96 layouts carry IPv4 in their low 32 bits. A decoded public destination continues through the normal hostname rules and prompt; it is not denied merely for using NAT64.

RFC 8215 allocates the broader local-use range 64:ff9b:1::/48, not just that second /96. Addresses outside the supported /96 within this /48 are blocked unconditionally: their translation layout is unknown, so the proxy cannot safely infer the destination from the low 32 bits. This includes alternate prefixes such as 64:ff9b:1:fffe::/96.

Address checks ignore any trailing %zone interface suffix, including on native IPv6 metadata addresses such as fd00:ec2::254%0. Only the policy copy is normalized. The admitted address and its interface zone remain pinned for transport, so NAT64 traffic still uses the translator rather than silently switching to a direct IPv4 connection.

host.docker.internal (the Docker bridge gateway, typically 172.17.0.1) is not in the hard-deny set. It is a private RFC 1918 address and is subject to the normal prompt or allow/deny policy. If you run Docker and want to prevent the agent from reaching it, add it to network.deny_domain.

Accepted gap, by design: private address ranges are not hard-denied. Only loopback, unspecified, link-local, cloud-metadata, and unsupported local-use translation addresses (see above) are blocked unconditionally. RFC 1918 (10/8, 172.16/12, 192.168/16), carrier-grade NAT (100.64/10), and IPv6 unique-local (fc00::/7) stay subject to the normal prompt and allow/deny policy. A hard deny would make VPN and corporate-internal hosts unreachable even after you allow them, for every user behind a VPN. The accepted tradeoff is that a granted hostname can reach a private address, including when its DNS answer changes after admission. omac pins the resolved address and dials only that, and network.deny_domain blocks specific internal hosts. Recorded as an accepted gap, not a defect to be re-reported.

Accepted gap, Linux only: kernel port rules cannot be scoped to an address. On Linux, kernel enforcement uses Landlock TCP rules, and those rules grant a port number, not a port on a specific address: the rule engine has no address dimension. Two grants are affected:

  • The proxy port, which every filtered launch must grant so the agent can reach the proxy. The agent can also connect to that port on any other host address that happens to have something listening on it.
  • Every port in network.open_port and any port a profile opens for local services, with the same reach.

This is a limitation of the kernel rule engine, not of omac's configuration: no Landlock rule available today can scope a port grant to 127.0.0.1. You can compensate on the host side: a host firewall can restrict outbound TCP on those ports to loopback, and avoiding open_port entirely removes that half of the reach (the proxy port grant remains until the transport changes). The planned fix is architectural: move the proxy transport to a Unix-domain socket, which the mediation layer already supports end-to-end and which needs no TCP port grant at all. omac doctor and omac provenance --check report this reach as a low-severity finding for any profile that uses filtered network mode with kernel enforcement. The launch itself stays silent, so the gap is recorded here and surfaced by those audit commands rather than on every start. Recorded as an accepted gap, not a defect to be re-reported.

Filesystem​

An agent with broad filesystem access can read SSH keys, cloud credentials, or unrelated projects, and can write outside its intended scope.

omac gives the agent access only to what it needs:

  • Your working directory (read and write).
  • On macOS, every directory above the working directory and above each granted path, and your home directory, can be looked up (for example with stat) but not listed: ls ~ and ls .. fail. Guessing a name still reveals whether it exists. In your home directory, /bin/pwd -P fails; the shell's built-in pwd works. The root directory / stays listable because every macOS program reads it at startup.
  • The config directories the active harness requires. Each harness declares exactly which directories it needs; omac injects them at launch.
  • Your development tools — compilers, package managers, and build systems such as Go, Rust's Cargo, and Node.js via nvm — are readable so the agent can compile and run code. These tools are installed in your home directory rather than system directories, so omac grants them explicitly. The full list is in the reference table below.

Even when you grant broader access, certain paths are always blocked: ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, and .env / .envrc files anywhere in the project. You can block additional file patterns by adding glob entries to filesystem.deny in ~/.config/omac/sandbox-profiles/default.json — for example, "*.key" blocks all files ending in .key inside any directory the agent can access. See Configuration for how to edit the sandbox profile.

The .env / .envrc scan covers your working directory and every directory you grant explicitly, but it skips large dependency, build, and cache trees: node_modules, .git, .hg, .svn, vendor, .venv, venv, __pycache__, target, dist, build, out, the package caches .gradle, .m2, .cargo, .rustup, .nvm, .npm, .yarn, .pnpm-store, .cache, and ~/Library on macOS. Those trees are not where dotenv secrets live, and walking them would make launch scale with your whole home directory. A file inside a skipped tree is not masked; keep dotenv files outside such trees, or deny their directory by path.

The scan also skips the Nix store (/nix/store), even when you grant it or a folder that contains it. On NixOS every installed program lives there, so granting it is how the agent gets to run them, and it is far too large to list at launch. Nix makes everything in the store readable by every user on the machine, so a secret there is not protected by hiding it.

Some directories have more files and folders than omac lists at launch. What happens then depends on the system:

  • Linux: launch stops rather than run with a partially protected set.

  • macOS: launch goes ahead. Instead of listing the directory, a sandbox rule hides every file or folder in it whose name matches (for example any .env, however deep). This rule also covers the skipped trees above and files created during the session. omac notes the directory in the sandbox log. Upper and lower case count as the same, as on the macOS disk.

    Launch still stops in two cases the rule cannot express: you made one such file readable with an absolute override_deny path inside that directory (the rule would hide it too), or a filesystem.deny pattern uses [...] or \ (for example *.[pk]em).

    During the session: the masking above is fixed at launch — a .env file created later is not masked, because the sandbox rules cannot be changed while the session runs. The omac parent therefore re-scans the same roots every 10 seconds and warns you when a new protected file appears; the warning tells you that only a restart restores protection. The watch warns, it does not block or mask. Detections from one scan arrive as one decision dialog, not one popup per file. It lists up to eight paths and offers "Suppress warnings for this path" by exact path, "Suppress warnings for all N files" for the whole batch (the label says "(8 listed)" when the dialog showed only the first eight), and "Suppress warnings for every file <name> under <full path>" for the session folder or an explicitly selected parent folder. Choose one option at a time: after suppressing warnings for one path or a filename under a folder, the dialog reopens for any remaining files, so you can handle several paths in turn. "Keep warning every time (default)" is the first option, so pressing Enter keeps it. Keeping the warning saves no exception: the file stays in the audit log and is listed in the session-end report, and the dialog does not reopen for that path in this session. Exact paths are stored in ~/.config/omac/prefs.yaml (protected_watch_ignore). A project choice stores {root, name} under protected_watch_project_ignores, where root is the selected folder. Selecting repo/ covers existing and future worktrees under repo/.worktrees/; selecting just repo/.worktrees/A/ leaves sibling worktrees unaffected. A parent-folder exception also covers any other projects below that folder, so check the full path before selecting it. There is no global basename exception. The file stays protected at launch and every detection is still audited. The prefs file lives outside the sandbox, so the agent cannot grant itself an exception. Concurrent sessions merge preferences under a shared file lock so they cannot overwrite each other's choices. If no dialog backend is installed, omac falls back to the informational dialog and a desktop notification.

    The 10-second scan pauses while a decision dialog is open; a protected file created then is caught on the next tick after it closes, except one created and deleted while the dialog is open.

    Folder choices come only from the session's launch path, never from .git files or repository configuration. The session folder comes first, then its parents, stopping before your home directory or the filesystem root. Only the folder you explicitly select is saved. Worktrees outside that folder are not covered, regardless of whether they share a Git repository. These choices also work in non-Git projects. The exact-path and batch choices remain available when no folder scope can be offered.

    The end-of-session report collapses a burst to one summary line. Pass --verbose to list every warning you kept; ignored files are not listed because their warning was dismissed. The audit log always records one event per path, including ignored files.

    Directory timestamps come from the filesystem's coarse clock, so a file created in the same clock tick as a scan can share the previous scan's timestamp and slip past that scan. The watch therefore lists any directory once more on the next pass if its last scan fell within one timestamp tick; that catches the hidden file, so a mid-session .env is detected within 10 seconds on filesystems whose timestamps are no coarser than the two-second re-check window. For coarser filesystems (some network mounts) you can opt into a periodic full re-scan with sandbox.midsession_watch.full_pass_ticks: N in the user config: it is off by default, runs every Nth tick, and bounds that case at N × 10 seconds. full_pass_concurrency throttles that optional pass. A file that appears and disappears between two scans is never detected.

On macOS, keychain files are also blocked: the keychain folders (~/Library/Keychains, /Library/Keychains) and any file named *.keychain or *.keychain-db anywhere, for example a CI signing keychain in /tmp. Apple's read-only certificate store in /System/Library/Keychains stays readable, because HTTPS clients need it to check certificates.

Known limitation — two keychain gaps remain on macOS. HTTPS clients such as Copilot CLI need the macOS security service (securityd), so the sandbox lets the agent talk to it. That service hands out the items of any unlocked keychain whose file the agent can read, which leaves two gaps:

  • A keychain stored outside the keychain folders under a name without the .keychain extension can be read while it is unlocked. It only exists if someone created it with an explicit path, such as security create-keychain /tmp/plainname.
  • The agent can send the service other requests, for example to lock your keychains.

Secrets​

Integrations (GitHub, GitLab, Jira, email) need API tokens. If the agent holds a token directly, a prompt injection can leak it.

omac keeps tokens on the host side of the boundary. They are stored in the OS keychain (Keychain on macOS, Secret Service on Linux) and injected only into the sidecar process for the relevant skill as an environment variable. The agent calls that skill through the facade and never sees the actual token.

How isolation works​

omac ships no kernel module or custom isolation layer. It uses security primitives built into the OS so confinement is enforced by the kernel.

macOSLinux
SandboxSeatbelt (sandbox-exec)bubblewrap + Landlock
Secret storeKeychainSecret Service
Prompt dialogAppleScriptzenity / kdialog

Sandbox access reference​

The table below lists which paths and environment variables the sandbox can and cannot access.

Path or variableAccessWhy
<workdir>read + writeYour project files
Harness config dirs (e.g. ~/.claude, ~/.local/share/opencode, ~/.local/share/opentui)read + writeThe harness stores its state and credentials here; omac pre-creates declared first-use dirs (e.g. OpenCode's opentui tree-sitter grammar cache) before sandbox grant resolution
omac-managed tool cache (isolated from ~/.cache; see Cache)read + writeBuild artifacts and downloaded packages; isolated from your host caches
Language toolchain binaries (~/.cargo/bin, ~/go/bin, ~/.nvm, ~/.bun/bin, ~/.rustup)read-onlySo installed compilers and build tools can run
pnpm home (~/.local/share/pnpm, ~/Library/pnpm)read-onlySo the agent can run pnpm at all: the pnpm executables, pnpm-installed global tools, and the pnpm store live here.
Shared skills dirs (~/.config/agents/skills, ~/.agents/skills)read-onlySo the agent can read skill descriptions (SKILL.md)
Git config (~/.gitconfig, ~/.gitignore_global)read-onlyRead commit settings and global ignores; write access would affect all your repos
System directories (/usr, /bin, /lib, /etc, …)read-onlyOS binaries and libraries needed to run any command (bash, git, python, …)
$TMPDIR (private per-launch dir)read + writeTemporary files during the agent's work; on Linux a private tmpfs is mounted over /tmp so nothing the agent writes there is visible on the host or to other sessions
Facade socket (in ~/.local/state/omac/run/ on Linux, ~/Library/Application Support/omac/run/ on macOS)connectThe socket the agent uses to reach skill sidecars; created by the facade at an unpredictable path the agent cannot pre-compute
~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, …blockedSensitive credentials
~/.npmrcblocked; registry addresses can be shared as a stripped copyUsually holds an access token. See Private package registries
The host OpenCode service's service.json in ~/.config/opencode and ~/.local/state/opencode (also under $XDG_CONFIG_HOME, $OPENCODE_CONFIG_DIR and $XDG_STATE_HOME when set)blocked (OpenCode harness)Least privilege: these files hold the configuration and registration of the host's OpenCode v2 shared service. A sandboxed OpenCode v2 uses its own private service.json in the per-launch temp dir instead, so the sandbox doesn't need the host's copies. Keeping them out of reach means a sandboxed session cannot read or change the host service's settings. The rest of both dirs stays read + write. On Linux the file is masked when it exists at launch
~/.config/omac (approval store, sandbox profiles, global registry)blockedThe agent must not be able to forge skill approvals; protected in the baseline even under broader grants
.env / .envrc files (including nested ones inside the project; dependency/cache trees are skipped)blockedOften contain secrets
~/.cache, ~/Library/Caches (host cache roots)blockedPrevents cross-project cache poisoning; omac provides its own isolated cache
Files matching filesystem.deny patterns (e.g. *.key)blockedUser-defined extra restrictions
Environment variables in allow_vars (OMAC_*, HOME, PATH, LANG, …)passed throughOperational minimum
All other environment variables (cloud secrets, DOCKER_HOST, SSH_AUTH_SOCK, …)strippedNot needed and potentially sensitive

OpenCode v2 model state​

OpenCode v2 saves your selected model, recent models, favorites, and the selected reasoning variant in model.json inside its state directory. omac gives every sandboxed session a fresh, temporary state directory, so it copies the host's file into the session before launch and copies it back after the session exits. It skips the copy back when the session did not change the file, so an untouched session cannot overwrite a newer host choice. service.json is never copied.

This hand-off is a convenience, not an isolation boundary. The host state directory stays read + write for the sandbox (see the table above), so the agent can already read and change model.json directly. The session state directory is writable too, so omac treats model.json as untrusted: it refuses to follow symlinks and only reads regular files. Without that check, the agent could use the file as a link to make omac copy ~/.ssh/id_rsa into the session state or write session data into ~/.config/omac/approvals.json.

If omac is killed before the copy back, the host file stays unchanged and only the session's model change is lost.

Self-authored skills​

Skill sidecars run on your machine outside the sandbox. That is intentional, because skills need to make network requests and access API credentials — and those credentials must never reach the agent. But this creates a risk: the agent can write files in your working directory, including the skill source folders. If omac simply ran whatever skill code it found there, a malicious agent could write a skill, get it spawned, and use it to read your SSH keys or exfiltrate data.

omac prevents this with an approval store at ~/.config/omac/approvals.json. A skill can only run if it has been explicitly approved by a human running omac register in a real terminal, or by the marketplace sidecar after installing a skill. The sandbox never mounts ~/.config/omac, so the agent cannot create or modify approvals itself.

Skill names and descriptions shown during omac register and in the agent's system prompt are sanitized before display. Control sequences and markdown structure characters are stripped, so a skill directory with a hostile name cannot inject fake instructions into the approval output or the system prompt.

Each approval is tied to a specific version of the skill's code via a bundle hash. If the skill's files change after approval, the hash no longer matches and the spawn is refused. The agent cannot sneak in modified code by editing a skill after it was approved. At approval time, the skill directory is frozen into a host-only snapshot; the sidecar is always spawned from that snapshot, never from the still-agent-writable workdir.

Skill instructions​

The harness also reads each skill's SKILL.md itself and gives it to the model as instructions. The agent can edit those files too, for example to plant instructions that every later session would follow. So at every launch, omac checks each omac skill in the workdir and in the user-global skill folders. Under omac serve --for-opencode-desktop it checks every project folder it grants the sandbox. An omac skill is a skill folder with an omac.yaml, or one whose name you have approved before. Harnesses also load skills from folders nested inside a skill folder, so omac checks those too: an approved .opencode/skills/slack moved to .opencode/skills/team/slack and edited is still hidden. omac hides the whole skill folder from the sandbox for that session, and prints why, when:

  • the skill's files do not match an approved version,
  • its omac.yaml is missing or is not a regular file, or
  • the skill contains a SKILL.md that the approval does not cover: a symlink named SKILL.md, a file under a folder the bundle hash skips (such as build/ or node_modules/), or anything behind a symlinked folder that leads outside the skill. Symlinked folders that stay inside the skill, like a Python venv's lib64 -> lib, are fine.

If omac cannot list one of the skill folders (for example because it was replaced by a file), or it holds more than 50,000 folders, omac hides that whole folder and keeps checking the others.

For example, if an agent appends text to .opencode/skills/slack/SKILL.md, omac start refuses to launch; omac start --accept-skill-changes instead prints skill "slack": its files do not match an approved version, so its SKILL.md instructions are untrusted ... and hides the skill when using the built-in sandbox. Review the change, then run omac register slack --force.

This has limits:

  • The check runs at launch. An edit made during a session is visible to that session and, under omac serve, to every session until serve restarts.
  • Plain skills (a SKILL.md without an omac.yaml) have no approval, so omac cannot check them. They carry no integrity guarantee: like AGENTS.md or any other file in your project, the agent can change them. Review changes to them as you would review code.
  • Hiding needs omac's built-in sandbox. With the deprecated nono backend, omac only prints a warning.

When upgrading omac on a machine with existing registered skills, only user-global skills (registered outside any workdir) are automatically approved for the first run, and only when the skill's on-disk content still matches the bundle hash recorded at registration. The recorded hash is the only evidence of what you reviewed when you registered the skill, so it is what the upgrade trusts. A user-global skill whose content has changed since registration is not migrated: review the change on the host and re-register the skill with omac register --force. Workdir-local skills — which the agent can write — always require an explicit omac register from a host terminal.

If you change an approved skill yourself, omac refuses to run it until you review the change and re-register it with omac register --force.

Launcher config trust​

The launcher config (oh-my-agentic-coder.yaml) controls which sandbox command omac runs on your machine. That command executes before any confinement exists, with your full user environment, so it must not be under the project's control.

omac enforces this: security-sensitive launcher fields (sandbox.*, audit.*, facade.base_env_passthrough) are accepted only from your user-global config (~/.config/omac/config.yaml) or omac's compiled-in defaults. A project-local <workdir>/.opencode/oh-my-agentic-coder.yaml can only contribute operational settings (cache scope, facade timeouts). Opening a repository cannot change the sandbox runtime, disable the audit trail, or redirect audit logs.

Sandbox policy grants (filesystem paths, network hosts, environment variables) live in the sandbox profile (~/.config/omac/sandbox-profiles/default.json), which has no project-local equivalent. Profile paths referenced in launcher templates are likewise restricted to that directory.

Environment filtering​

The sandbox does not inherit all environment variables from the shell that launched omac. It starts from an explicit allow list — the OMAC_* prefix, basic system variables (HOME, PATH, LANG, …), and the key the selected harness needs to call its AI provider — and strips everything else, including any ambient cloud tokens, before the agent starts.

Known limitation — the harness's AI provider credentials are reachable inside the sandbox. This is unavoidable, since the harness needs them to function. For harnesses like claude-code, the key arrives as an environment variable (ANTHROPIC_API_KEY). For harnesses like opencode, credentials are stored in the harness config directory (~/.local/share/opencode), which is mounted inside the sandbox. Either way, a sufficiently capable agent could read them. Skill secrets are fully isolated; harness credentials are not.