Skip to content

Security model & its edges

The honest version: what Nyia protects by default, where the edges are, and how you can verify it yourself. The canonical policy and how to report a vulnerability live in SECURITY.md.

The boundary

Nyia runs each assistant in a Docker container — not a VM. A container shares the host kernel, so a container escape means host access. If you need a hardware boundary (untrusted code, hostile input), run microVM isolation (e.g. Docker Sandboxes) underneath — Nyia is the workflow / data-control layer on top. See How Nyia compares.

What's protected by default

  • The rest of your machine. The agent sees your project, not your home dir, ~/.ssh, or root filesystem — a bounded blast radius. See Why Nyia.
  • Your secrets in the project. Sensitive files are excluded from the mount and replaced with a read-only placeholder (Mount Exclusions). The default patterns are a starting point — review them and add your own private files before your first launch.
  • Your branches. Work runs on a work branch; protected branches are guarded (Branch Management).

  • What the next session mounts. workspace.conf is read-only in the container, and a new or changed conf needs your confirmation with its real paths shown (Workspace). The bind protects the mounted file. A conf recreated after an ancestor directory rename, or shipped by a clone, is caught by the confirmation. The refused-path list is a deny set: you are the check on anything else.

  • Assistant hooks a session plants. Claude and Codex hooks run commands in later sessions, and the assistant's config is shared by all your projects. At launch, Nyia compares the hook settings with the ones you approved. New or changed hooks are shown, and need a yes. At session end, it warns. Your own hooks keep working once approved. Example: a session adds a Stop hook to ~/.claude/settings.json; the next launch, in any project, shows the command and asks.

  • Checked: Claude's shared settings.json, and the project's .claude/settings.json and .claude/settings.local.json; Codex's shared and project hooks.json and config.toml.
  • This is detection, not prevention. A hook planted during a session can run in that session: Claude runs a new UserPromptSubmit hook at your next prompt.
  • Not checked: Vibe and OpenCode; a script an approved hook calls, so editing it does not ask; plugins, MCP servers, statusLine, apiKeyHelper and Codex's notify; hooks in Claude skills and agents; Codex --profile config files.
  • A launch with no terminal cannot ask. Example: nyia-claude -p x 2>log, or a fresh CI checkout with a committed hook, refuses to start. Approve once from a terminal first; a fresh CI home has no approval.

The edges (know these)

  • Exclusion ≠ history redaction. A secret already committed is recoverable from git history unless you enable Git History Cutoff.
  • Network is a separate, opt-in axis. By default the container has ordinary network access; it can exfiltrate what's in the box and reach your LAN, host services, and cloud metadata. Turn on restrict-local (Linux) when that matters — but note it closes the LAN/host half only: public web egress stays open, so it does not stop exfiltration.
  • Exclusions are patterns, not magic. They can't know a custom-named secret is sensitive — you extend the list.
  • The assistant's own project config is repository content. OpenCode reads opencode.json from the project (Claude, Codex and Vibe have equivalents): a repository can add MCP servers (local commands started with the CLI), plugins, permission rules and providers pointing at its own baseURL — with your keys, inside the box, and with ordinary egress. Nyia never writes those files, refuses to write through a repository-planted link in the project, and runs a best-effort key-name scan that warns once (a determined repository can hide a key); the review is yours (see CONFIGURATION.md).
  • A remote MCP server you authenticate is a third party in the session. --mcp-auth (see "OAuth MCP servers" in CLI_REFERENCE.md) stores an OAuth grant, and the assistant then calls that server's tools; each call carries whatever the model puts in its arguments — potentially file contents, paths and excerpts of your conversation. Nyia does not sit in that path and cannot scope it, and the grant stands until you revoke it at the server. That is a trust decision about the operator — distinct from the planted-config bullet above, which is about a repository adding a server you did not choose.
  • Your own git on the host. Nyia's own host git calls ignore what the repository sets. Your git, your editor and your prompt do not, so the box must not be able to change what they run:
  • .git/config, .git/hooks and .git/commondir are read-only in the container, and .git cannot be renamed. Example: git config core.fsmonitor /tmp/x fails in the box.
  • What git does in the container, measured:
    • Work, rc=0: commit, checkout -b, push origin HEAD, fetch, pull --ff-only, tag, stash, rebase, gc, worktree add, worktree remove, git init in a subfolder.
    • Report rc=0 but set no upstream, and print a config-write error: push -u (the push lands), branch --set-upstream-to, checkout --track, checkout -b t origin/x.
    • Partly work: branch -m gives rc=128 and a config error, the ref is renamed and HEAD follows. branch -D gives rc=0 and a warning, the ref is gone.
    • Fail: git remote add rc=128, git config foo.bar 1 rc=4.
  • Launched directly in a host worktree or a submodule, git in the container fails with rc=128. Example: after git worktree add ../wt on the host, cd ../wt && nyia-claude, then git status in the box gives rc=128. A worktree does the same on beta.16.
  • A launch refuses include.path and includeIf in a repository config, extensions.worktreeConfig, and remote.<n>.uploadpack beside a promisor remote. Example: Nyia refuses to start: /path/repo/.git/config sets include.path. Move it to ~/.gitconfig.
  • At launch, Nyia shows what your git can run in this repository, and asks once per change (no terminal: it refuses). Example: an executable .git/hooks/post-merge. It checks again at session end and warns before the prompt returns. If you approve a setting that was already planted, it can still run.
  • Git aliases in a repository config are shown in one line at launch, and never stop it. Git runs one only when you type that name. A repository alias overrides yours of the same name. Example: alias.st in .git/config runs on your git st, not on git status.
  • A running container started before this version keeps its old write access until it stops.
  • A hooks path or hook link pointing at project files stays writable, and the approval covers the path, not those files. Example: husky's .husky/. Review what a session changed there before you commit on the host.
  • The rest of .git stays writable. Example: a worktree the session creates. Each .git/worktrees/<name>/commondir must read ../..: Nyia checks at launch and session end, reports any other value, and its own git does not run there; your git in that worktree would obey it until you remove it.
  • A nested repository and a submodule's config stay writable in the box, because the project folder must stay writable. For these Nyia detects; it does not prevent:

    • A host editor running git on the root during a session can still run what the session planted. The report comes at session end and at the next launch, after that run. Example: in the box, git init x, git add x, then core.fsmonitor=/tmp/p in x/.git/config. An editor polling git status on the root runs /tmp/p.
    • At launch and session end, Nyia follows each index entry of mode 160000, and nested ones, and reports what those repositories can run: config keys, hooks, attributes, alternates. It also reads .git/modules/*/config. Example: /path/repo/x/.git/config: sets core.fsmonitor = ./check.sh.
    • It checks at most 128 nested repositories. Over that is a finding, and the rest are not checked.
    • A path that leaves the repository is reported and not read. Example: an index entry ../x, or a x/.git file naming another repository.
    • A nested repository the index does not name is not checked. The root's git status does not enter it, but git run inside it obeys it. Example: sub/.git/config, with a host editor open on sub/. git init in a project with no .git is the same until the next launch shows it.
  • Consolidated workspace members (Workspace). Their Nyia files and AI files are hidden from the session, and a planted symlink at one of those paths refuses the launch.

  • The session can delete an rw member's .nyiakeeper-workspace-member. A direct launch then works and creates .nyiakeeper/ in the member. Example: ls -a in the member shows it.
  • The session can create a hidden name that was absent at launch, such as .claude/ in an rw member. The next launch hides it, and it can be committed in that session.
  • A planted marker only causes a refusal; the message gives the fix.

  • Not independently audited. It's a solo, beta-quality project — use at your own risk.

Shared team content: a structural shield, not content review

A team directory lets you share skills, personas, and prompt overlays. Shared content is a trust decision — the same as pointing any AI tool at a shared repo; a teammate can commit whatever they like, and no tool can tell you in advance whether a prompt's text is well-intentioned. That is the git-shared-repo + AI threat model, not something specific to Nyia.

What Nyia does enforce, today, is that the propagation mechanism cannot be abused: when it brings a shared skill/agent/prompt into an assistant, it refuses any item that is a symlink (top-level or nested), a special file, or uses a traversal / unsafe name — so a shared source cannot smuggle a link like steal → ~/.ssh/id_rsa to read files off your host. Copies are per-item atomic and never overwrite your own content, and execute bits are stripped from copied data files. These are structural protections (extensions are not a security boundary); they do not judge whether the content itself is safe. On-device content review of shared skills/prompts (a guard model) is planned next.

(Out of scope: hardlinks — a shared Git repo cannot carry one, so the sharing mechanism can't introduce a hardlink to a host file; and a sub-microsecond validate→copy race on a locally-writable team dir, which already implies local code execution.)

Deliberate strengths (not accidents)

  • Fail-closed. If a protection can't be applied — the egress firewall can't be built, a shallow-git history cutoff can't be prepared — the launch is refused, not silently downgraded.
  • Safe-parsed config. Team and project config is read as plain key=value — no shell expansion, no command execution.
  • Per-assistant branch isolation and an object-isolated shallow .git for history cutoff, so one assistant's work and old commits stay contained.

Verify it yourself

Don't take the claims on faith — check them on your own machine:

nyia exclusions list            # what will be hidden from the agent in this project
nyia exclusions test            # the Docker mounts a launch would use

After launch, excluded files show a FILE EXCLUDED FOR SECURITY placeholder inside the container, while normal files stay readable. Every release is also gated by an automated end-to-end suite (secret-exclusion + MCP-handshake smokes) before images publish.