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_portand 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 ~andls ..fail. Guessing a name still reveals whether it exists. In your home directory,/bin/pwd -Pfails; the shell's built-inpwdworks. 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_denypath inside that directory (the rule would hide it too), or afilesystem.denypattern uses[...]or\(for example*.[pk]em).During the session: the masking above is fixed at launch — a
.envfile 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}underprotected_watch_project_ignores, whererootis the selected folder. Selectingrepo/covers existing and future worktrees underrepo/.worktrees/; selecting justrepo/.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
.gitfiles 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
--verboseto 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
.envis 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 withsandbox.midsession_watch.full_pass_ticks: Nin the user config: it is off by default, runs every Nth tick, and bounds that case at N × 10 seconds.full_pass_concurrencythrottles 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
.keychainextension can be read while it is unlocked. It only exists if someone created it with an explicit path, such assecurity 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.
| macOS | Linux | |
|---|---|---|
| Sandbox | Seatbelt (sandbox-exec) | bubblewrap + Landlock |
| Secret store | Keychain | Secret Service |
| Prompt dialog | AppleScript | zenity / kdialog |
Sandbox access reference
The table below lists which paths and environment variables the sandbox can and cannot access.
| Path or variable | Access | Why |
|---|---|---|
<workdir> | read + write | Your project files |
Harness config dirs (e.g. ~/.claude, ~/.local/share/opencode, ~/.local/share/opentui) | read + write | The 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 + write | Build artifacts and downloaded packages; isolated from your host caches |
Language toolchain binaries (~/.cargo/bin, ~/go/bin, ~/.nvm, ~/.bun/bin, ~/.rustup) | read-only | So installed compilers and build tools can run |
pnpm home (~/.local/share/pnpm, ~/Library/pnpm) | read-only | So 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-only | So the agent can read skill descriptions (SKILL.md) |
Git config (~/.gitconfig, ~/.gitignore_global) | read-only | Read commit settings and global ignores; write access would affect all your repos |
System directories (/usr, /bin, /lib, /etc, …) | read-only | OS binaries and libraries needed to run any command (bash, git, python, …) |
$TMPDIR (private per-launch dir) | read + write | Temporary 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) | connect | The 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, … | blocked | Sensitive credentials |
~/.npmrc | blocked; registry addresses can be shared as a stripped copy | Usually 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) | blocked | The 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) | blocked | Often contain secrets |
~/.cache, ~/Library/Caches (host cache roots) | blocked | Prevents cross-project cache poisoning; omac provides its own isolated cache |
Files matching filesystem.deny patterns (e.g. *.key) | blocked | User-defined extra restrictions |
Environment variables in allow_vars (OMAC_*, HOME, PATH, LANG, …) | passed through | Operational minimum |
All other environment variables (cloud secrets, DOCKER_HOST, SSH_AUTH_SOCK, …) | stripped | Not 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.yamlis missing or is not a regular file, or - the skill contains a
SKILL.mdthat the approval does not cover: a symlink namedSKILL.md, a file under a folder the bundle hash skips (such asbuild/ornode_modules/), or anything behind a symlinked folder that leads outside the skill. Symlinked folders that stay inside the skill, like a Python venv'slib64 -> 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.mdwithout anomac.yaml) have no approval, so omac cannot check them. They carry no integrity guarantee: likeAGENTS.mdor 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.