Permissions

Policies

The agent asks. The policy decides. Every file, Git, SSH and network action an agent takes is checked against a policy it cannot see, edit or turn off.

The permission gateway

Each agent runs in its own microVM. Everything outside that VM is reached through a gateway on the host:

  • Files: your directories are served into the guest by a host process that applies the filesystem policy on every open, read and write.
  • Git: git and gh in the guest talk to a Git gateway on the host, which holds the token and checks every request.
  • SSH: ssh to one of your nodes goes through the host with a short-lived certificate, and the node enforces its own policy.
  • Network: egress rules are compiled into the sandbox's firewall, outside the guest kernel.

The policy lives on the host. Nothing the agent sends can change it, and a refusal reaches the agent as an ordinary error: Permission denied, a rejected push, exit code 126.

Two policies, two refusals

Branch-level Git permissions with the token on the host, and your directory mounted in with secrets kept out:

policy · yamltoken stays on host
github:acme/api: read: true push: {branches: ["agent/**"]}
agent action · bashinside sandbox
$ git push origin main remote: vc-serve: push refused ! [remote rejected] main
Policy enforced: push to main blocked
policy · yamlsecrets stay out
fs: allow: [rw:~/app=/workspace/app] deny: [~/app/.env]
agent action · bashinside sandbox
$ cat /workspace/app/.env cat: /workspace/app/.env: Permission denied $ ls /workspace/app # README.md src
Policy enforced: secret access blocked
denials.jsonl {"op":"read","path":"/workspace/app/.env","reason":"denied by fs policy"} smx agent logs · every refusal is recorded

Filesystem policy

A sandbox config is one YAML file. Its fs: section decides which host paths the guest sees and how:

# repo.yaml session: repo image: docker://semantic/claude:local security_profile: model-only fs: default: none allow: - rw:${REPO}=/workspace/app deny: - ${REPO}/.env - ${HOME}/.ssh
$ REPO=$PWD smx create -c repo.yaml $ smx exec repo -- cat /workspace/app/.env cat: /workspace/app/.env: No such file or directory $ smx agent run repo --harness claude "run the tests"
  • allow takes ro:PATH or rw:PATH=GUEST. deny beats any allow, and a denied path is invisible in the guest.
  • default is none, ro or rw for paths no rule names.
  • mounts attach other backends the same way: a host directory (including an NFS or SMB mount), S3 or SFTP. Credentials in mount options are references (env: or file:); a literal secret fails the create.
  • The state root and /proc, /sys, /dev, /run are always closed, whatever the rules say.
  • Edits under an rw path land on the host as they happen.

Flags replace the file's lists for one create: --fs-allow, --fs-deny, --fs-default and --fs-mount TYPE:SOURCE=AT[,k=v].

Git policy

A Git policy says, per repository, what the sandbox may do: read, push (by branch and path), open change requests. With no policy, nothing is allowed.

# git-policy.yaml providers: here: {kind: local} github: {kind: github, host: github.com} repos: "here:.": {read: true, push: {branches: ["agent/**"]}} github:acme/api: {read: true}
$ smx vc policy set api-agent git-policy.yaml $ smx create --session fix1 --security-profile offline --git-policy api-agent --repo ~/src/api $ smx vc log fix1 # every git operation and refusal
  • Plain git and the real gh work unchanged in the guest. The gateway inspects every push for its branches, force updates and changed files before forwarding it.
  • Some rules hold whatever the policy says: only branches can be pushed, never a protected or default branch; an agent never approves or merges into the default branch; CI and ownership files (.github/**, CODEOWNERS) need an explicit allow.
  • On the API, pull requests can be listed, read and created from branches this sandbox pushed. Merges, comments and account lookups are refused.
  • A sandbox with a Git policy has no direct route to the provider: it must use the offline or model-only profile.

SSH policy

An agent can open a shell, run commands and mount directories on one of your nodes. A small agent on the node (its own port, your sshd untouched) enforces one signed policy per profile:

# node-access policy, profile "dev" fs: default: none allow: [rw:/srv/app, ro:/var/log/app, ro:/usr, ro:/etc] deny: ["**/.env", "**/*.pem", /etc/shadow, /etc/ssh/*_key] home: scratch exports: [/srv/app, /var/log/app] idmap: {squash: all, uid: 64000, gid: 64000} commands: mode: deny # deny = denylist; allow = allowlist deny: [reboot, shutdown, poweroff, sudo, su, mount, systemctl, "mkfs*", dd] verbs: restart-app: [systemctl, restart, app.service] shell: true network: {preset: model-only} limits: {sessions: 4, cpu: 1.0, memory_mb: 1024, wall_s: 900}

The sandbox config grants the node; inside the guest, stock OpenSSH tools just work:

nodes: [{node: <node-id>, profile: dev}] fs: mounts: ["node:<node-id>:/srv/app=/mnt/nodes/app,profile=dev"]
$ ssh <node-id> 'tail -n 50 /var/log/app/app.log' $ reboot reboot: denied by node policy (profile dev) $ echo $? 126
  • Commands are checked when they execute, not as typed text, so bash -c, scripts, busybox applets and renamed copies hit the same rule.
  • The command list gives clear refusals; the real boundary is session confinement on the node: an unprivileged uid, no new privileges, a filesystem view built from fs, cgroup limits and per-session egress.
  • Certificates live at most 10 minutes and name one sandbox, one node and the hash of one policy. A node holding no verified policy refuses every session.

Egress

Every sandbox is created under a named security profile. Five ship built in:

ProfileThe guest reaches
offlinenothing (with --allow, only those destinations)
model-onlythe node's model endpoints, plus DNS
internetthe public internet; every private range denied
internet-lanthe internet plus the subnets the operator made grantable
unrestrictedeverything except the always-closed floor
security_profile: offline allow: - api.anthropic.com:tcp/443 - udp/53 - tcp/53
$ smx create --security-profile internet --deny 1.1.1.1 --allow 198.51.100.0/24:tcp/22 … $ smx security-profile create ci --base internet-lan --allow tcp/22 $ smx security-profile apply offline <vm-id> # on a running sandbox

A deny always beats an allow, and the floor beats both. A profile cannot widen what the operator made grantable, and a hostname never resolves into private space. Applying a profile to a running sandbox swaps its rules in one step, probes them, and rolls back if the probe fails.

Credentials stay on the host

The agent never holds the keys it acts with:

  • Git: the gateway uses the host's token. gh in the guest gets a placeholder login.
  • SSH: the private key is generated on the sandbox host and never leaves it; the guest needs no key.
  • Storage: mount credentials are references resolved on the host.

A prompt-injected agent has no token to leak, and every packet it sends to an allowed host still goes through the policy.

Agent policy

On top of each sandbox's own policy, agent-policy.yaml on the host says which sandboxes, images, harnesses, security profiles and host paths each named agent may use:

agents: default: sandboxes: ["*"] harnesses: [opencode, claude, codex, hermes] profiles: [model-only] fs: paths: ["/home/you/proj/**"] write: true permissions: allow # allow | prompt | deny
$ smx agent policy init $ smx agent policy check s1 --harness opencode allowed: agent default may run opencode on … (session s1)

An agent the file doesn't name may do nothing. The check reads what the sandbox actually has (its recorded image, profile and mounts), not what the command line claims. While a run is live, a Git request needs both the sandbox's policy and the agent's git: section to allow it.

Denials and the log

Every refusal is recorded as a policy.denied event in the run's log, tagged with the layer that refused it: filesystem, egress, node or permission prompt. Git operations and refusals are listed by smx vc log. Filesystem refusals are also appended to denials.jsonl, one JSON object per line.

Read a run's events, refusals included:

$ smx agent logs <run-id>

See the security model for the threat model, or the docs to run your first sandboxed agent.