Night Porter threat model
Night Porter reads text written by other people (review comments, issues, merge requests) and runs a coding agent on your machine because of it. This document says who is trusted, what each security boundary is, and which protections cannot be turned off.
Assets
- Forge credentials: GitHub/GitLab tokens, and your git SSH keys.
- Your repositories: their history, branches and the code that gets merged.
- Your machine: files, other projects and secrets in your home directory.
- Your reputation: comments and commits posted under your name.
Actors
| actor | trusted? | how they reach Night Porter |
|---|---|---|
| You | yes | config files, TUI approvals |
Maintainers (role in trust.roles, or allow-listed user, team or bot) | for requests | comments and reviews on your PRs, /nightporter commands, labels |
| Anyone else on the forge | no | comments, issues, MRs, fork code |
| Pack authors | no (reviewed by you at install, pinned to a commit) | prompt files |
| The agent (Claude Code / OpenCode) | no; treated as possibly confused or injected | runs in a worktree |
Boundaries and controls
B1. Untrusted text never reaches the agent unasked
- Trust rules are checked in this order:
- a denied user or team is rejected
- a bot is accepted only if it is in
allow_bots, and is otherwise dropped (bots are never trusted by role: anyone with access can install one) - an allowed user or team is accepted
- a comment author whose role is in
trust.rolesis accepted - with
trust.open = true, everyone else is accepted (and the level is capped atsuggest) - everyone else is untrusted
- GitHub reports
MEMBER/COLLABORATORfor read-only users too, so Night Porter checks the author's real repo permission (write or higher counts as collaborator). - Items from untrusted authors go to the TUI inbox and are not processed until you choose allow always, allow once, ignore or deny. "Allow always" on a repo only releases that repo's waiting items.
- Global
allow_usersmatch a username on every configured host. Put host-specific trust in repo files if you use several forges. - Night Porter's own replies carry a hidden marker and are never read back as requests, and
/nightporterin agent-written replies is neutralised. - When the context for a job is built, comments from untrusted authors are removed, not quoted.
- Maintainer mode: the request must come from a trusted user (a label or a
/nightporter <verb>command). Issue or MR content from others is passed to the agent inside clearly markedUNTRUSTEDblocks, and the job's autonomy is capped atdraft. trust.open = true(accept anyone) is capped atsuggest, so nothing is posted or pushed automatically.- Jobs triggered by failing CI stop at
draft(unlessci_auto = true): CI output is text from builds and tools that a PR's code or a third-party check can control. - Team rules read every page of a team's members. If a
deny_teamslookup fails, the author goes to the inbox instead of being trusted.
B2. The agent holds no forge credentials
- The agent process starts with forge tokens and anything that looks like a secret (
*TOKEN*,*SECRET*,*KEY*,*AUTH*,*PASSWORD*,DATABASE_URL, ...) removed, except the agent's own API key.SSH_AUTH_SOCKis removed,GIT_SSH_COMMANDfails, credential helpers are cleared and git hooks are disabled for the agent's commits.GH_CONFIG_DIRandGLAB_CONFIG_DIRpoint to empty directories, soghandglabinside the agent are logged out. - The generated agent settings deny
git push,gh,glaband network commands, deny reading credential files (~/.ssh,~/.config/gh,~/.git-credentials, ...) and Night Porter's own config, state database, log and other jobs' files, and only allow git subcommands that can't write outside the repo (git log/diff/showtake--output). - Claude Code runs with
--setting-sources user, so a branch's.claude/settings.json(hooks, permission allows) is ignored. Your own~/.claude/settings.jsonstill loads (that's where your login comes from), but hooks are switched off for jobs (disableAllHooks) and Night Porter's denies always win over your allows. Your personal allow rules and plugins do apply: keep them narrow.nightporter doctorwarns when you have any. - The file tools can't read outside the worktree (
blockReadsOutsideWorkingDirectories), and sandboxed commands can't read your home folder except the worktree, its git dir and common toolchain folders (sandbox_read_allowadds more). The agent's git uses its own config with only your name and email. - The prompt reaches Claude Code on stdin, not as a command-line argument (visible in
ps). - Only Night Porter pushes and posts, and only after the B3 checks. Merge, force-push, close and approve are not implemented at all.
- With Claude Code, the agent's shell commands run in Claude Code's OS sandbox by default (
sandbox = true): no network unless you list domains, and the read denies above also apply to commands. It fails closed: if the sandbox can't start, the job fails. Verified in headless-pmode on macOS (2026-11-05): an allowedcatof a read-denied path fails with "Operation not permitted" (cargo test real_agent_smoke -- --ignored). - Gaps: OpenCode has no sandbox and no read denies (and its prompt is still an argument), and repos with
sandbox = false(e.g. for Docker/DDEV tests) runallow_commandsas you. Keepallow_commandssmall there, and treatautomode with that in mind. - The agent can't redirect the daemon's git: the worktree's
.gitpointer is recorded before the agent runs and checked right after, even when the run failed, before anything else touches the worktree. A changed pointer removes the worktree, and a reused worktree whose.gitdoesn't point into your clone is rebuilt. Diffs, checks and pushes run in your clone on fixed SHAs, and every daemon git call disables hooks, fsmonitor and theext::transport. - The agent runs in its own process group, which is killed when the run ends, so nothing it started in the background keeps changing the worktree after the checks.
- Night Porter's config and data directories are readable by you only.
B3. Pre-push checks
Run on every push, in every mode, including auto:
| check | can be disabled? |
|---|---|
| secrets in any commit's patch or message, what a merge commit itself adds, and replies and the PR summary (built-in patterns, plus gitleaks if installed) | no |
protected paths touched by any commit, not only the net diff, matched case-insensitively (CI configs for GitHub, GitLab and others, .git, .gitattributes, agent instruction files for Claude, OpenCode, Cursor, Copilot and Gemini, git/editor hooks, .envrc, .ddev, .devcontainer, compose files, plus your globs) | no (you can only add to the list) |
| force-push or history rewrite | no |
| Night Porter files in a commit | no |
| dependency manifest or lockfile changes | yes (escalate_on), with a warning |
| diff size over the limit | yes (escalate_on), with a warning |
A failed check never pushes. It sends the job back to manual approval in the TUI; approving past a blocking failure needs an explicit override. What is pushed is exactly the reviewed commit SHA.
B4. Running other people's code
Any job on a branch you didn't write (any maintainer request on someone else's PR or MR), and any reproduce that runs an environment or allowed commands, needs your approval before the agent starts at all: the branch can carry agent configuration (CLAUDE.md, opencode.json, plugins) and build scripts. The approval is bound to the head SHA you saw; if they push again, Night Porter asks again. The same applies to your own PR when its head branch lives in someone else's fork. Commits someone else pushed to your own branch are not detected. reproduce jobs that may run commands need the same approval. The TUI shows which build files changed (composer.json, package.json, Makefile, CI files) before you approve. Environment commands only ever come from your own config, never from the repository.
Environment commands run outside the agent's sandbox, and what they do depends on files in the worktree. So Night Porter copies the files they typically read (.ddev/, .devcontainer/, compose files, Makefile, justfile, .env) before the agent runs and puts them back before teardown and cleanup, and the commands get no model API keys. Scripts your commands call by name (composer install, npm test) still run the branch's version: keep environment commands to repos whose scripts you'd run yourself.
B5. Packs
Packs contain only TOML, markdown and plain text: no executable code, pinned to a commit SHA. A pack step can block a job or require approval; it can never relax B1–B4. Updating a pack shows the prompt diff before it is applied. A step that needs write access shows a warning at install time.
B6. Credentials at rest
Tokens come from gh/glab or from the OS keychain. They are never stored in config, logs, job directories or the state database, and they are kept in secrecy types in memory.
B7. Nothing left in your repos
Job files live in ~/.local/share/nightporter/jobs/<id>/. Worktrees live outside your clones. A pre-push check rejects any commit containing Night Porter files.
Out of scope
- A trusted maintainer asking for something harmful on purpose. Trust is a decision you make; the always-on checks still apply.
- Vulnerabilities in the agent CLIs, git,
ghorglabthemselves. - A machine that is already compromised.
Pack and marketplace sources must be https://, ssh:// or git@ URLs, and revs can't look like options.
Open items before 1.0
- [ ] Independent review of B1–B5 against the implementation
- [ ] Fuzz the trust and context-building code with crafted comments
- [x] Claude Code's sandbox applies in
-pmode (macOS, real run, 2026-11-05) - [ ] Same check on Linux (bubblewrap)
- [ ] Sandbox for OpenCode runs (macOS
sandbox-exec, Linux bubblewrap) - [x] First independent review (2026-11-05): findings fixed, see git history