Documentation menu

Security model

nid runs agents under your user account. It coordinates their work and routes supported permission requests; it is not a universal operating-system sandbox. Review the repository, setup command, agent configuration and provider permissions before giving a team a goal.

Worktrees and permissions#

An isolated Git worktree separates a session's edits and branch history from your main checkout. It does not prevent the agent from accessing other files or the network. The selected driver and vendor sandbox determine those permissions. --shared removes even that checkout separation. --yolo disables supported approval/sandbox controls; do not confuse it with a guarantee that edits stay in the worktree.

Claude, Codex and ACP drivers can route structured approval requests into nid. PTY agents expose terminal input/output only, so nid cannot interpret or answer their approval prompts. PTY presets often enable the vendor's own automatic approval mode; inspect the agent definition before using it.

Project and user configuration are trusted executable configuration: setup runs a shell command, and agents can select a command, arguments and environment. A project definition can replace a custom-agent preset/user definition. Inspect an unfamiliar nid.yaml before running up or spawning a custom agent from that repository. Custom PTY mcp.file configuration writes the requested file before launch and can target paths outside the worktree; use a private worktree-relative destination.

Daemon and browser authentication#

The daemon RPC listener binds to 127.0.0.1 and uses a bearer token stored in $NID_HOME/daemon.json. Agent MCP sessions receive distinct session tokens and can use only their authorized group/session methods. Keep runtime files and backups private; local processes running as your user may access those files.

The dashboard uses a separate loopback listener. nid dashboard creates a single-use login URL valid for 60 seconds; visiting it exchanges the token for an HttpOnly, SameSite=Strict cookie. Browser code does not receive the daemon bearer token. Host and Origin checks reject DNS rebinding and cross-site requests; mutating requests require JSON and same-origin context. The cookie expires after 30 days, and daemon restart invalidates browser sessions immediately.

Browser cookies cannot invoke daemon shutdown, dashboard bootstrap methods or agent envelopes. The UI has a same-origin Content Security Policy and bundles its assets locally without remote fonts or CDNs. The API reference lists exact checks, transport limits and principal permissions.

Browsers do not scope cookies by port. Another server on the same loopback host may receive the dashboard cookie if visited; sign out when finished on a shared machine. Do not expose either listener through a public port forward or assume loopback authentication protects against a compromised local user account. Remote access is designed as an outbound, end-to-end encrypted connection to a relay instead; see Remote access. Signing out leaves agents running; stopping the daemon is a separate action.

Remote control with a nid.dev account#

nid rc / /rc (remote.md section 11, contract in appendix E) lets every phone or browser signed in to your nid.dev account watch and steer the groups you share. The trust model in one paragraph: the nid.dev account is the lock. The daemon admits a device because the nid.dev accounts service signed a device list that contains the device's key, not because you compared fingerprints on the machine. Frames stay end-to-end encrypted between the device key and the daemon key, so the relay still cannot read or forge traffic.

ThreatWhat stops itWhat remains
Someone signs in to your account or steals a signed-in phoneNothing on the machine: such a device can do what an operate device does on the shared groups, including telling agents to run commands. Adding a new device needs a fresh passkey sign-in, and you get an email for every new device. The daemon also raises a new-device notice: a desktop notification, a board note, the rc: N devices badge and an announcement to your other devices. Revoke on nid.dev, or locally with nid rc devices rm <id> (a local revoke wins over the list)The account is the lock (same trade as Claude Code's remote control). Use --view, remote.accountMaxRole: view or short shares (--for 8h) to narrow it.
The accounts service or its signing key is compromisedNothing in account mode: it can sign a list with a device of its own, and it can tell a console a fake daemon key (consoles pin daemon keys and warn on a change)The residual risk of account mode. remote.strictPairing: true makes the daemon ignore the list; devices are then paired one by one with a fingerprint check on the machine (remote.md section 3).
A relay operator, or the relay aloneNoise end to end (section 4); lists are signed and checked against keys compiled into nid (or set in cloud.accountKeys for a self-hosted service), never fetched; older lists are refused (rollback), lists for another account are refused, and two different lists with one version are reportedThe relay can withhold a new list: a revocation made on nid.dev can be delayed until the accepted list expires (at most about 72 hours). A local revoke is immediate.
A prompt-injected agent links your machine to an attacker's accountThe OS user verification (Windows Hello, Touch ID, polkit) runs in the daemon when a machine is linked or its account changes, and the prompt names the account; nid rc refuses to run inside agent sessionsAn agent can share more groups with your account, or stop sharing. Neither needs a prompt, because the linked account is the one you verified. Same-user file writes are the residual stated in remote.md section 2.4.
Push notificationsPayloads are encrypted on the machine for the device (RFC 8291) and padded to a fixed size. The relay sees when a push happens, never its contentWhen pushes happen correlates with approvals and questions (remote.md 8.1).

Strict-paired devices keep working in account mode, and admin is never granted through the account. Pairing a device with nid cloud pair is the only way to get admin.

Secrets and local files#

copy places local configuration inside each new agent worktree. link shares a directory with the source checkout, so an agent's writes through it change the source too. Paths stay within the repository, Git metadata and source symlinks are excluded, tracked files are skipped, and copied/linked paths are added to Git's shared info/exclude. These rules reduce accidental commits; they do not prevent an agent from reading, printing or transmitting the files it can access.

init --yes accepts suggested local files, including .env and local secrets. Inspect its generated copy list before starting work. Prefer narrowly scoped development credentials. Agent conversations, logs, board messages and review diffs can contain repository content; treat the state directory and its backups accordingly.

Webhook URLs can contain credentials. nid masks them in configuration/status views, but configured webhooks intentionally receive notification text outside the machine. Slack/Discord payloads suppress mass mentions; webhook delivery is deduplicated and rate-limited. Store secret URLs in private user configuration when they should not appear in a committed project file.

API keys stored with nid auth set go to the macOS keychain, the Secret Service (secret-tool) on Linux, or a DPAPI-encrypted file on Windows. Unix can fall back to a 0600 file in NID_HOME/secrets; Windows encryption failure stops without writing plaintext. Custom nid homes have separate keyring namespaces, and confirmed data purging removes their stored keys. Keys reach helper tools on stdin (an inline --api-key argument still appears in nid's command line). API sessions receive keys through their environment, which vendor child processes may inherit. Subscription mode removes the managed API-key variables. Explicit vendor settings and arguments can override authentication. Agent output, board notes and messages are redacted before nid stores them (best effort: see L8 and A6 below). nid never edits the agents' own credential files. See billing.

Download and release trust#

Installers and the updater verify binary SHA-256 against the release's SHA256SUMS. That detects mismatched bytes but shares the GitHub release's trust boundary: a party able to replace both assets and the manifest can replace both checksums. Optional Authenticode/Developer ID signing adds platform publisher identity; see signing and releases.

Releases are public: installers and the updater work without any token. An optional NID_GITHUB_TOKEN/GITHUB_TOKEN/GH_TOKEN (or a logged-in gh, asked only when GitHub's own API at https://api.github.com refuses the anonymous request, and never for a custom NID_RELEASE_API host) raises the rate limit or opens a private fork. Installers and the updater send bearer credentials only to the configured API origin and drop them for redirected asset-storage origins and for the release pages (NID_RELEASE_WEB). An override of NID_RELEASE_API is therefore an explicit trust decision. Git push/PR operations use your Git/GitHub credentials; agents should not be given broader release permissions just to edit source.

Project MCP configuration trust#

With the default mcp: project, nid treats .mcp.json (Claude/ACP/custom agents) and .codex/config.toml (Codex) as executable project configuration. Starting a session authorizes the selected servers to run with the agent's local access; this explicit injection bypasses the vendor's separate project-MCP trust prompt. Review those files before running an unfamiliar repository, or use mcp: none to keep only nid's coordination server. A startup notice names the project servers selected; it does not print their credentials.

Malformed project MCP configuration is skipped with a notice, and ACP servers whose transport the agent does not support are skipped individually. A malformed user Codex configuration prevents nid from safely identifying inherited servers, so isolation fails with a repair hint. mcp: all bypasses nid's isolation scan and permits user connectors; it cannot repair a configuration that the vendor CLI itself refuses to parse.

Managed Codex sessions send full server definitions, including credentials, on the app-server's stdin in thread/start or thread/resume. Startup command-line overrides contain only disabled server placeholders and connector/plugin flags. Native Codex takeover still passes MCP configuration through command-line overrides, which can expose server credentials to local process inspection. Treat native takeover and the private nid run directory as local credential boundaries; do not share process listings or generated configuration files.

Permission profiles#

Each session runs with a permission profile. Set it per session (permissions: in a nid.yaml session), per group (permissions: group setting, nid group set <group> permissions=ask), or for everything in ~/.nid/config.yaml. The session wins, then the group, then the user config. nid doctor shows the default and /permissions in the terminal app shows each session's.

ProfileBehavior
worktree (default)nid answers requests that stay inside the session's worktree itself: reads and search, edits of files in the worktree, test/build/lint/type-check runners (npm/pnpm/yarn/bun test or run <script>, npx tsc/eslint/vitest/jest, pytest, python -m unittest, go test/build/vet, cargo test/build/check/clippy, make test/check/build, tsc, eslint, ruff, mypy, gradle/mvn/dotnet test and build, ...) and read-only git (status, diff, log, show, ls-files, ls-tree, branch listing, rev-parse, blame, grep). Everything else still asks: network (curl, wget), package installs (npm install, pip install, ...), git push/reset --hard/clean -f/checkout --/commit, deleting the worktree or its .git, interpreters running arbitrary code (python script.py, node x.js), and any absolute, ~, $VAR or ../ path that leaves the worktree. Compound commands (a && b, pipes) are allowed only when every part is; command substitution, script blocks and redirects outside the worktree always ask.
askEvery permission prompt of the agent's CLI becomes a nid approval (the behavior before profiles).
yoloThe agent's CLI never asks (Claude --dangerously-skip-permissions, Codex approval policy never without sandbox). The legacy yolo: true / --yolo mean this profile.

The rules are an allow list: a command is allowed only when nid understands every part of it. cd is followed through a compound line (cd src && cat ../x is fine, cd .. && rm -rf y asks); inline variables other than a few harmless ones (CI, FORCE_COLOR, NODE_ENV, ...) ask; option values are path-checked too (--out=/x, -o/x, PowerShell -Path:C:\x); npx/bunx run only tools already installed in the worktree's node_modules (anything they would download asks); yarn/pnpm/bun <name> must name a package.json script; subshells, PowerShell subexpressions ((...), @(...)) and env -S ask. A program named by a path runs only from a system directory, the worktree's node_modules/.bin or the project's gradlew/mvnw; coverage wrappers (c8, nyc, coverage run) are judged by what they wrap; options that name another program (go test -exec, cargo --config, node -e, a GIT_PAGER command) ask; and .git is protected however it is spelled (.GIT, .git., GIT~1, [.]git, a redirect). Project scripts (npm run <script>, make test) and test runners still run the project's own code, which can do anything that code does.

Per agent: Claude runs in its default permission mode with only nid's tools pre-allowed, so every edit and command it asks about reaches nid's rules through can_use_tool (Claude's own allow rules would skip nid's path and option checks). Codex keeps its workspace-write sandbox (in-worktree edits never ask) and nid accepts the escalation requests the rules allow, judging the command inside Codex's shell wrapper (powershell -Command '...', bash -lc '...', cmd /c) and looking up the command or changed files of the item an approval names. ACP agents are classified by the tool call's kind, locations and command (best effort); PTY agents keep their own prompts. Auto-allowed actions show as "allowed" with the rule's reason in the stream and leave no approval record. Switching between worktree and ask applies to the next request; switching to or from yolo needs the agent to restart.

The worktree profile is a convenience, not a sandbox: a test runner executes the project's code. Use ask for repositories you don't trust.

Approvals#

Requests the profile doesn't allow become pending approvals and the session shows as waiting:

nid approvals [-a]                     # pending (or all) requests
nid approve <id|session>               # allow
nid approve <id|session> --deny        # deny

"Yes, don't ask again this session" (terminal app, dashboard) remembers the command's verb (npm install, git push) or the tool for that session, also across agent restarts, until the human stops the session. Compound commands and interpreters are never remembered broadly.

Codex safety mode#

Codex sessions run through codex app-server, one process per session. Without --yolo they are sandboxed: approval policy on-request with a workspace-write sandbox, so Codex edits files in its worktree freely, and anything beyond that (network access, writing outside the workspace, other escalations) becomes a nid approval (nid approvals, nid approve <id> [--deny]). A denied request is reported to Codex as declined. The nid tools themselves never need approval. With --yolo, Codex runs with approval policy never and no sandbox (danger-full-access).

On Windows the Codex sandbox has to be set up once. nid doctor reports its state; if it says notConfigured (or updateRequired), run codex interactively once and complete its Windows sandbox setup. Until then non-yolo Codex sessions refuse to start with that hint.

Security review 2026-10#

Before the public beta the local product was reviewed in three parts, each by a member who did not write the code under review: the network surface (cw2), the local surface (cw2) and install, update and dependencies (codex). Every finding below has a regression test. The write-up is reviewed end to end by two further members, who rerun every cited test: review 1 by lead (passed), review 2 by cw (passed, see the board finding 'SECURITY.MD REVIEW 2').

SeverityFindingsStatus
Highnone
MediumL1, L2, L3, L4, L7, L8, L9, I1, I2, I5all fixed, with regression tests
LowN1 to N5, L5, L6, L10, I3, I4all fixed, with regression tests
AcceptedA1 to A6, B1 to B4, four Low limitations under install/updatedocumented below, with mitigations
AreaWhere
Daemon HTTP API, auth and tokens, bind addressNetwork surface: N1 to N3, N5, "Daemon API"
Dashboard CORS/CSRF/DNS rebinding, one-time login linkNetwork surface: "Dashboard and login link", B2, B3
SSE and terminal streamsNetwork surface: "SSE and terminal streams"
Injected MCP serverNetwork surface: N3, N4, "MCP server", B1
File permissions under ~/.nidLocal surface: L7
Secrets storage (DPAPI, keychain, secret-tool), logging and redactionLocal surface: L8, A4 to A6; Secrets and local files
Permission profiles and the command classifierLocal surface: L1 to L5, L10, A1 to A3
Worktree path handlingLocal surface: L6, L9, A2
Installer and updater integrity, redirects, token leakageInstall, update, dependencies: I1 to I4
Dependencies (pnpm audit)Install, update, dependencies: "Dependencies"

Review 2026-10: network surface#

A review of nid's listeners and the credentials that open them: the daemon's bearer-token API, the dashboard and its one-time login link, the event (SSE) and terminal streams, the injected MCP server's daemon calls, and the experimental compression proxy. Reviewer: cw2 (not the author of the code reviewed). Attacks were run against a live listener (test/security-network.test.ts) in addition to the existing dashboard suites (dashboard-security, dashboard-adversarial, api-events, terminal-api).

Threat model#

  • Listeners: the daemon API (127.0.0.1:<random port>, bearer token from daemon.json), the dashboard (127.0.0.1:7420 or a free port, cookie session), and the compression proxy (127.0.0.1:<random port>, a random path token per session). All three bind to 127.0.0.1 in code; no setting changes the address (NID_DASHBOARD_PORT only picks the port).
  • Adversaries:
    1. A web page you visit: it can make your browser send requests to 127.0.0.1 (form posts, fetch, WebSockets, DNS rebinding) but cannot read daemon.json or set Authorization headers cross-origin without a CORS preflight that nid never answers.
    2. An agent: holds its own session token (environment of the agent and its child processes) and talks to the daemon through the MCP server. It must not act as another session, another group or the human.
    3. Another local user who can connect to loopback ports but cannot read your files.
  • Out of scope: programs running as your user. They can read daemon.json and secret and are the human as far as nid can tell. Exposing a listener beyond loopback (port forwards, tunnels) is unsupported.

Findings#

IDSeverityFindingStatus, fixTest
N1LowThe daemon API did not check the Host header. A DNS-rebinding page could reach it (it still needed the bearer token, so nothing was exposed).Fixed (hardening): requests whose Host is not 127.0.0.1:<port> or localhost:<port> get 421 before anything else runs, as the dashboard already did.security-network N1
N2LowA request with no credentials at all on the agent route had its body (up to 16 MB) read and parsed before being refused, so any web page could make the daemon buffer large bodies.Fixed: no Authorization header means 401 before the body is read.security-network N2
N3LowSession tokens were compared with !== (not constant-time).Fixed: timingSafeEqual, as the daemon token already was.security-network N3, N6
N4Lowexpand (compressed originals) was not scoped to the caller's group: an agent could read another group's originals by id (c_ + 8 hex digits).Fixed: originals record their group, including board snapshots, and an agent only finds its own group's.security-network N4
N5LowSession tokens were HMAC(secret, session id) and never changed: a session removed and created again under the same name accepted the old session's token (for example one left in a stray process's environment).Fixed: the token also covers the session's creation time.security-network N5

No High or Medium findings on this surface. Checked and holding, with tests:

  • Daemon API: every route but the agent RPC route needs the daemon token, compared in constant time; a session token gets nothing from human routes or /events; cookie (dashboard) principals cannot call shutdown or dashboard.*; bodies over 16 MB and malformed envelopes are refused. A cross-site form post (text/plain) cannot shut the daemon down or act as an agent (security-network N2, dashboard-adversarial).
  • Dashboard and login link: Host must be this listener (DNS rebinding); a foreign Origin, a cross-site Sec-Fetch-Site or a non-JSON post is refused; the login token is 192 random bits, valid 60 s and single-use even when expired, refused on cross-site navigation, and swapped for an HttpOnly; SameSite=Strict cookie through a 303 that takes it out of the address bar; Referrer-Policy: no-referrer, a strict CSP and frame-ancestors 'none' apply to every response; outstanding links and signed-in browsers are capped (dashboard-security, dashboard-adversarial). The UI renders through Preact (escaped) with no raw-HTML sinks, and external links (pull requests, CI checks) pass through safeUrl.
  • SSE and terminal streams: human-only, cursors validated, filters cannot inject frames, open streams and per-stream buffers capped (api-events, dashboard-adversarial, terminal-api).
  • MCP server: stdio only (no listener). Each tool call carries the session id and token, and the daemon resolves the caller from them: messages, task owners, claims, updates, reviews and locks are confined to the caller's group; task updates need the owner or the group's lead; a reviewer cannot approve their own work (security-network N3, N4; e2e-review).
  • Compression proxy: reachable only with a per-session 192-bit path token, refuses any request carrying Origin, forwards only to the fixed Anthropic API origin with a literal path (no host injection through //), and never logs bodies or credentials.

Accepted risks and notes#

  • B1. The session token is in the agent's environment. Project code the agent runs (tests, scripts) inherits it and can call nid's tools as that agent. This is the same trust as A1 (local surface): code in the worktree already acts with the agent's access.
  • B2. The dashboard cookie is not port-scoped. Browsers send it to any server on 127.0.0.1; another local server you visit could receive it. Sign out on shared machines (see "Daemon and browser authentication" above).
  • B3. The login link appears in the terminal when no browser can be opened, and in browser history. It is single-use and expires in 60 seconds.
  • B4. Loopback is shared by all local users. Another user can connect to the ports but holds neither the daemon token, a session token, a dashboard cookie nor a proxy path token.

Review 2026-10: local surface#

A review of what runs and is stored on your machine, ahead of the public beta: file permissions under ~/.nid, API-key storage, redaction, the worktree permission profile's command classifier, and worktree path handling. Reviewer: cw2 (not the author of the code reviewed). The network surface (daemon API, dashboard, MCP server) is reviewed separately.

Threat model#

  • Assets: the daemon token (daemon.json), the session-token secret (secret), API keys, the database (transcripts, board, approvals), MCP configs under run/ (session tokens), your files outside the agent's worktree, and the agent's own checkout (.git).
  • Adversaries:
    1. An agent that goes wrong: a confused or prompt-injected agent under the worktree profile that runs something it shouldn't without asking.
    2. Another local user on a shared POSIX machine reading ~/.nid.
    3. Anyone who later sees nid's output: the dashboard, a webhook, a shared log or database.
  • Out of scope: malware running as your user (it can read everything nid can), yolo sessions, PTY agents (their prompts never reach nid), and an untrusted repository under the worktree profile (use ask; see below).

The worktree profile is a convenience that keeps agents from accidentally acting outside their worktree. It is not a sandbox against a hostile agent: an agent that can edit package.json and run npm test runs any code it likes (see A1). The classifier findings below are the cases where a single command did more than the profile promises, without that edit-then-run step.

Findings#

IDSeverityFindingStatus, fixTest
L1MediumThe classifier judged a command by its program's name only: /tmp/x/cat, C:\Users\Public\eslint.exe or a .\cat.bat the agent had just written ran as "read-only" or "lint".Fixed: a program named by path must be in a system directory, the worktree's node_modules/.bin, or the project's gradlew/mvnw; anything else asks.security-local L1
L2MediumAllowed tools that run another program: c8/nyc <cmd>, coverage run x.py, node --test -e ..., go test -exec/-toolexec, go vet -vettool, go env -w=, cargo --config target.*.runner= and -Z, cmake --build . --target install, bun run x.ts, and GIT_PAGER/PAGER set to a command.Fixed: coverage wrappers are judged by the command they wrap; those options and bun run <file> ask; pagers other than cat/less/more ask; date --set and hostname <name> ask.security-local L2
L3Medium.git protection was exact-match only: rm -rf .GIT (macOS, Windows), .git., ".git ", GIT~1 (Windows aliases), rm -rf [.]git (glob) and redirects (echo x > .git) could break the checkout without asking.Fixed: .git matches case-insensitively and through Windows aliases; any glob in a delete asks; redirect targets get the same check as edits.security-local L3
L4MediumReads outside the worktree were auto-allowed through the Glob tool's pattern (/etc/**, ../**/*.key) and through ACP command calls whose locations were outside.Fixed: Glob's pattern directory and ACP locations are path-checked.security-local L4
L5Lowkey=value arguments (--reporter-options output=/tmp/x) were not path-checked.Fixed.security-local L5
L10LowCompound logger/reporter values hid paths: dotnet test --logger "html;LogFileName=/tmp/x" was allowed outside the worktree.Fixed: semicolon/comma-separated assignments are individually path-checked, including --option=value lists. Dotnet --results-directory and -r forms are also covered by the generic path checks and regression tests.security-local L10
L6LowGroup and session names could be Windows device names (con, nul) or end in a dot, which Windows maps to another folder.Fixed: refused.security-local L6
L7MediumPOSIX: ~/.nid was created 0755, and the database, logs, run/*.mcp.json (session tokens) and compression originals were readable by other local users under the usual umask. Homes copied from ~/.pawmux kept its modes, and writeFileSync's mode only applies to new files.Fixed: the home is created 0700; at every daemon start nid makes the home, run, logs, secrets, memory, compress 0700 and daemon.json, secret, the database and daemon.log 0600 (only what you own; it logs what it changed). Windows relies on the profile directory's ACL; a custom NID_HOME outside your profile inherits that folder's ACL.security-local L7
L8MediumCredentials were stored verbatim: printenv is auto-allowed, so an API-billed session could print the key nid gave it into the database, the dashboard and webhook notifications; agents' stderr and board notes were stored as written.Fixed: events, board notes and messages are redacted before they are stored: the exact keys nid hands to agents, well-known token shapes (sk-..., ghp_..., github_pat_..., AKIA..., xox?-..., AIza...), Bearer/Basic credentials and UPPER_CASE_SECRET=value lines.security-local L8
L9MediumOn Windows and macOS, sessions lead and Lead (or groups app and App) got the same worktree folder: two agents shared one checkout, and removing one force-removed the other's worktree.Fixed: names that differ only in case from an existing one are refused.e2e-security-local L9

Accepted risks and notes#

  • A1. Project code runs under the worktree profile. Test runners, project scripts and build tools execute repository code: an agent can edit a test, package.json, a Makefile, conftest.py or a Vitest config and then run it (npm test, pytest -p plugin, vitest --globalSetup, deno test -A, prettier --plugin ./x.js). That code can do anything you can. This is the profile's purpose; use ask for repositories or goals you don't trust.
  • A2. Path checks are lexical. A symlink inside the worktree (committed to the repository, or created by project code) that points outside lets an in-worktree path reach outside. nid's own copy/link never follow source symlinks.
  • A3. "Yes, don't ask again" for a tool is broad (Low, needs your explicit choice). Remembering Write allows every later Write in that session, including outside the worktree, until the session is stopped. Remember only when you mean it; a narrower rule is planned.
  • A4. Inline API keys. nid auth set <agent> --api-key <key> puts the key in your shell history and the process list for a moment. Omit the value to type it hidden, or pipe it on stdin.
  • A5. Key storage strength. DPAPI and the login keychain protect keys from other users, not from programs running as you. Without secret-tool on Linux, keys are a 0600 file.
  • A6. Redaction is best effort. It keeps credentials out of nid's storage and notifications; it cannot stop an agent from sending a secret it can read somewhere else, and an unusual key format that nid did not hand out itself may pass.

Checked without findings: worktree copy/link paths (absolute paths, .., .git and source symlinks are refused), group/session name characters, API keys passed to helper tools on stdin only, DPAPI failure never falling back to plaintext, a project MCP server named nid never replacing nid's own, "don't ask again" never remembering compound commands or interpreters, and the daemon log (logs/daemon.log), which receives nid's own errors but no agent output, tokens or keys (agent output goes to the redacted event store).

Review 2026-10: install, update, dependencies#

Reviewed by codex on 2026-10-10, independently of the author of the public install and release changes (lead). cw2's preliminary notes were independently checked. Scope: install.sh, install.ps1, src/update.ts, and the release, publish, verify, manifest and signing scripts.

The trusted inputs are the operator's environment/configuration, selected release repository, GitHub account and TLS trust store. The threats are a network attacker modifying a binary or checksum, redirects leaking GitHub credentials, and another local account reading process arguments or planting predictable temporary files. Code already running as the operator can change their configuration and executable: the updater is not a boundary against that code. Custom API/web origins explicitly select a different trusted release source; they are not constrained to GitHub. Plain HTTP is allowed only on loopback for local fixtures. SHA-256 detects corruption, not a compromised publisher: binaries and checksums come from the same release. Optional platform signing adds publisher authentication where configured.

FindingSeverity / statusFix and evidence
I1: shell installer passed the optional GitHub token in curl/wget argv, exposing it to local process listingsMEDIUM, fixedHeader/config files live in the private mktemp directory under umask 077; only their paths go in argv. Token syntax is checked before writing configuration. security-install.test.ts: I1: curl token travels in a private file, never argv and the matching wget test inspect arguments and header contents.
I2: wget permitted downgrade on anonymous or later redirects; initial download URLs also accepted remote plaintext HTTPMEDIUM, fixedwget follows each hop manually with a bound, strips auth after the first request, and rejects HTTPS downgrade. All three clients reject non-loopback HTTP and URL credentials before downloading. security-install.test.ts: I2: wget blocks a downgrade on the second redirect without forwarding credentials, updater URL rejection, and PowerShell API rejection. Existing installer/updater integration tests verify auth reaches the API but never cross-origin storage.
I3: shell installation used predictable nid.new / pawmux.new pathsLOW, fixedSame-filesystem private mktemp staging directories replace predictable filenames. installers.test.ts: I3/I4: install paths stay literal in profiles and predictable staging files are untouched.
I4: custom shell install paths were interpolated into startup-file code without escapingLOW, fixedEscape shell metacharacters before writing profile/fish PATH lines. The I3/I4 test installs into a literal command-substitution path, sources the profile, verifies no command ran and checks the resulting PATH.
I5 (independent review): with no token, a refusal from any configured NID_RELEASE_API host made the updater and both installers fetch gh auth token (a github.com credential) and send it to that hostMEDIUM, fixedgh is asked only when the API origin is exactly https://api.github.com. update.test.ts: M3: gh's token is only ever offered to https://api.github.com, M3: a custom release API that refuses ... never gets gh's token; installers.test.ts M3 tests (sh and PowerShell) put a fake gh first on PATH and assert it is never asked and its token never sent. Each test fails with the host check removed.

Transport behavior was checked against the official curl header/redirect documentation and GNU wget HTTP options. The curl path uses header files and requires a modern curl with origin-scoped Authorization redirect handling; no --location-trusted is used. PowerShell and the updater follow redirects manually. Tokens are optional; anonymous installations and updates are tested. Tokens stay in environment/in-process memory or the private shell scratch files, which are removed on exit.

Other checks and remaining limitations:

  • Checksums (checked): all clients require SHA256SUMS and a matching hash before replacing anything. The updater also rejects duplicate checksum entries. Installers select the first matching entry (LOW consistency limitation); this still requires its hash to match. Tests cover corrupt downloads, absent assets/checksums, legacy assets and public fallback downloads.
  • Channels (checked): a stable updater excludes prereleases and GitHub prerelease-flagged releases; beta accepts stable/beta/rc; alpha accepts all. Exact version requests intentionally override the channel. Saved settings and cache keys include the channel. Installer beta/alpha selection uses release-list order and the first 100 entries (LOW freshness limitation), while the updater paginates and sorts by SemVer. update.test.ts and installers.test.ts cover this separation, persisted channels and stable fallback.
  • Replacement (checked): updater downloads use random exclusive-create staging, an update lock and same-directory rename. Windows replacement rolls back on failure; a failed rollback preserves both files and reports recovery commands (update.test.ts). The PowerShell installer also attempts rollback, but a rollback failure reports the filesystem error without tailored recovery steps (LOW usability limitation): its previous executable remains at <install-dir>/nid.exe.old-<ticks> and can be moved back after closing processes using it. POSIX replacement preserves the old running inode. Installation directories must be writable only by trusted accounts; arbitrary symlink replacement by the same user is outside this model.
  • PATH (checked): shell profiles get one escaped entry, and PowerShell preserves unexpanded user PATH values and registry type; it does not change machine PATH. Installer tests sandbox the user-registry test value. Setting NO_MODIFY_PATH leaves profiles/registry alone.
  • Release/signing (checked): release versions/repositories are validated, commands use argv rather than shell strings, publish checks the clean tree, exact commit/tree verification stamp, local tag, remote repository and tag before publishing. Signing happens before checksumming; aliases are byte-identical. Private keys/password files use random scratch directories and 0600 files and are removed in finally blocks. Tool failures suppress output that could contain secrets. release.test.ts, publish.test.ts, verify.test.ts, manifests.test.ts and sign.test.ts cover these invariants. No release was published during this review.
  • Signing credential argv (LOW, accepted): Windows signtool's PFX /p argument and arbitrary operator-provided jsign arguments can expose credentials to a process inspector running as the release account/admin. Use an isolated release account or jsign/osslsigncode password-file mode; do not put cloud credentials directly in JSIGN_ARGS. No signing tool output/argv is logged by nid. This is separate from I1's ordinary multi-user Unix installer exposure.
  • Dependencies (INFO, clean): pnpm audit --json and pnpm audit --prod --json on 2026-10-10 both reported zero advisories at every severity (200 total and 174 production dependencies respectively). No overrides or upgrades were needed. This is a dated registry result, not a guarantee against undisclosed vulnerabilities.