diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c3d92..b26203b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,35 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. +## 0.54.0 + +- **Every audit row also goes to the system log.** The rows written to + `mgmt_audit.jsonl`, `pairing_audit.jsonl` and each pairing's `audit.jsonl` + (terminals, root commands, store ops, op runs) are also written to the + machine's system log, so a log collector can keep them where no host can + write: syslog facility `auth`, tag `privatae-bastion-audit` (the journal on + systemd) on Linux and macOS, and the Application event log, source + `PrivataeBastionAudit`, on Windows, registered when the system service is + installed and removed with it. Each line is the file's row with `audit` + (the file) and `pairing` added, and a root command's text left out (its + digest and length stay). The lines go through a bounded queue, so a slow or + stuck system log never holds up the audited op; a full queue drops lines and + the next line carries `system_log_dropped`. The file stays the record. +- **A server is networked and anchored by default.** `install.sh` run as root + now installs the networked bastion (systemd on Linux, launchd on macOS); + `--local` keeps a root install local, a reinstall over a machine already + paired in local mode keeps it local unless `--networked` is passed, and run + as your own user it is local as before. On a networked machine with no anchor, the first `pair` run with + machine authority anchors it to that host. Before, such a machine refused + every op until `claim-anchor` was run by hand; now it is anchored at once: + every terminal and every write waits for the person's approval, and any + later host for the anchor's. `pair` then says to + enrol the second factor. `claim-anchor` with no token on a machine already + anchored to that host now succeeds and changes nothing (an installer that + anchors after pairing finds the work done); naming another host is still + refused without `--rotate`. The Windows installer is unchanged: `-Networked` + as before. + ## 0.53.2 - **No change to what the bastion does.** Two code comments named source files diff --git a/README.md b/README.md index 36b7fd4..38dab3e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms) [![ci](https://git.privatae.ai/privatae/privatae-bastion/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.privatae.ai/privatae/privatae-bastion/actions) -**One static Go binary that turns your own machine into a bastion in your Privatae network.** +**One static Go binary that gives your AI Host access to a machine without SSH keys: it dials out, checks every signed grant on the machine itself, and keeps its own audit log.** No Docker, no Python, no database, no separate WireGuard install. You pair the binary with a short token from your host, and from then on your host can reach @@ -20,23 +20,26 @@ binary can be rebuilt from this source and compared ([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md). ```sh -# macOS / Linux (a home machine, running as you) +# A server (macOS / Linux, as root): networked, anchored to the first host you pair curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh -privatae-bastion pair # token from your host's "Set up a bastion" -privatae-bastion run # tunnel + lanes up -privatae-bastion add-folder ~/docs # hand a folder to your network; you approve on your host +sudo -H privatae-bastion pair # token from your host's "Set up a bastion" +sudo -H privatae-bastion stepup-enroll # your second factor, so you can approve here + +# Your own computer (macOS / Linux, as you) +curl -fsSL https://privatae.ai/bastion/install.sh | sh +privatae-bastion pair +privatae-bastion run # tunnel + lanes up ``` ```powershell -# Windows +# Windows, your own computer irm https://privatae.ai/bastion/install.ps1 | iex privatae-bastion pair privatae-bastion run ``` -On a Linux server installed as root, the installer also enables a service that -runs the daemon for you, so it is `sudo -H privatae-bastion pair ` and -nothing else. See [Install](#install). +A Windows server installs from an Administrator PowerShell with `-Networked`. +See [Install](#install). --- @@ -46,6 +49,7 @@ nothing else. See [Install](#install). - [Install](#install) - [Quick start](#quick-start) - [Local and networked modes](#local-and-networked-modes) +- [Replacing an agent's SSH keys](#replacing-an-agents-ssh-keys) - [A Host on this machine](#a-host-on-this-machine) - [What the bastion does](#what-the-bastion-does) - [Command reference](#command-reference) @@ -80,41 +84,59 @@ trust, and why. ## Install -The installer downloads the binary for your OS and architecture, verifies its -SHA-256 against the release manifest, and only then moves it into place. A -failed check installs nothing. +There are two ways to run the bastion, and the installer picks by who runs it. -**macOS / Linux** +**On a server** (run the installer as root). The bastion is installed as the +machine's system service in **networked** mode (Linux with systemd, or macOS; +on Linux without systemd a root install stays local, with no service), and +the first host you pair becomes its **anchor**. From then on, every terminal and every write on the +machine waits for your approval, and any other host that pairs waits for your +host to approve it. ```sh curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh +sudo -H privatae-bastion pair # token: your host's Network app → Set up a bastion +sudo -H privatae-bastion stepup-enroll # your second factor, so you can approve here ``` -On Linux, running the installer as root also installs an always-on `systemd` -service (`privatae-bastion.service`), so a server is onboarded in two commands: -install, then `sudo -H privatae-bastion pair `. +On Windows, from an Administrator PowerShell: -**Windows (PowerShell)** +```powershell +& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked +privatae-bastion pair +privatae-bastion stepup-enroll +``` + +**On your own computer** (run the installer as yourself). The bastion runs +as your user in **local** mode and serves one host. The pairing's +capabilities are the authorization, and approvals work once you enrol a +second factor. + +```sh +curl -fsSL https://privatae.ai/bastion/install.sh | sh +privatae-bastion pair +privatae-bastion run +``` ```powershell irm https://privatae.ai/bastion/install.ps1 | iex ``` -**Networked bastion** (a shared machine serving several hosts, see -[modes](#local-and-networked-modes)): +A root install that should stay local passes `--local` +(`… | sudo sh -s -- --local`). Reinstalling over a machine already paired in +local mode keeps it local unless you pass `--networked`. [Local and networked modes](#local-and-networked-modes) +sets out the difference, and [Replacing an agent's SSH keys](#replacing-an-agents-ssh-keys) +how to run a fleet of servers this way. -```sh -curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh -s -- --networked -``` +The installer downloads the binary for your OS and architecture, checks its +SHA-256 against the release manifest, and only then moves it into place; a +failed check installs nothing. That manifest comes from the same place as the +binary. To check its signature too, see [Verify a release](#verify-a-release). -```powershell -& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked -``` +A networked install runs as the operating system's own service, whoever is +signed in, and only the machine's administrator can stop it: -A networked install runs as the operating system's own service, so it runs -whoever is signed in, and only the machine's administrator can stop it: - -- **Linux:** the `systemd` unit above, as root. +- **Linux:** the `systemd` unit `privatae-bastion.service`, as root. - **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The program goes in `/Library/Application Support/privatae/bin`, which only root can change, with a link on `PATH`. @@ -143,6 +165,9 @@ manifest's sha256, made on the key holder's own computer with ## Quick start +On your own computer. For a server, see [Install](#install): the installer +run as root sets it up networked, and `run` is the service's job. + 1. In your Privatae host, choose **Set up a bastion**. It gives you a one-time pairing token. 2. Pair this machine. The binary generates its own keys locally and pulls only @@ -152,7 +177,16 @@ manifest's sha256, made on the key holder's own computer with privatae-bastion pair ``` -3. Bring the tunnel and lanes up. +3. Enrol a second factor on this machine, straight away. Until you do, folder grants and + file moves are gated by the signed grant alone (the host verifies its own + factor), and model downloads and root shells are refused outright. After + you do, every one of those waits for your approval here. + + ```sh + privatae-bastion stepup-enroll + ``` + +4. Bring the tunnel and lanes up. ```sh privatae-bastion run @@ -161,22 +195,13 @@ manifest's sha256, made on the key holder's own computer with Each paired host gets its own loopback control port, starting at `127.0.0.1:7878`. One daemon serves every host you pair. -4. Check on it, or open the local console. +5. Check on it, or open the local console. ```sh privatae-bastion status privatae-bastion ui ``` -5. Enrol a second factor on this machine. Until you do, folder grants and - file moves are gated by the signed grant alone (the host verifies its own - factor), and model downloads and root shells are refused outright. After - you do, every one of those waits for your approval here. - - ```sh - privatae-bastion stepup-enroll - ``` - 6. Share a folder. Your files stay on this machine. The host keeps only an index, and you confirm the share on the host with your code. @@ -186,18 +211,23 @@ manifest's sha256, made on the key holder's own computer with ## Local and networked modes -| | **local** (default) | **networked** | +| | **local** | **networked** | |---|---|---| -| Who it serves | one host | several hosts on one shared machine | +| Where | your own computer | a server, or a machine several people share | +| Installed by | the installer run as yourself | the installer run as root (Windows: `-Networked`), or `sudo -H privatae-bastion set-mode networked` | | Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows | +| A terminal the host opens | opens on the pairing's capabilities | waits for your approval, for that one session | +| A write (an op, a ship step) | runs on the pairing's capabilities | waits for your approval, bound to that op and its arguments | | Second pairing | refused | waits for the **anchor** host's approval | -| Set with | nothing to do | `install.sh --networked` or `sudo -H privatae-bastion set-mode networked` | +| The anchor | none | the first host paired, or `claim-anchor` | In networked mode one host is the **anchor**: the one host that approves every later pairing on this machine. Claiming the anchor requires root, because there -is no authority above the box to ask. The ordinary flow is `pair`, then -`set-mode networked`, then `claim-anchor` with no token: it is a choice among -pairings the box already holds. A token is needed only to name a host this box +is no authority above the box to ask. On a networked machine with no anchor, +the first `pair` run as root anchors it to that host by itself. A machine +paired first and switched to networked afterwards is anchored with +`claim-anchor` and no token: it is a choice among pairings the box already +holds, and running it again for the same host changes nothing. A token is needed only to name a host this box does not serve yet (a fresh machine, or `--rotate`). The anchor can be moved to another host only with the current anchor's signed consent, or after 14 days of silence from it. @@ -213,6 +243,38 @@ sudo -H privatae-bastion claim-owner # state `claim-owner` is optional. The name shows on this machine's card in every host it serves, and it grants nothing anywhere. +## Replacing an agent's SSH keys + +An agent with an SSH key has a shell on every server the key opens, and the +server's log says only that the key was used. Paired through the bastion, the +agent holds no key that opens anything: each server dials out to its host, +decides what the host may do, and records every decision. + +For a fleet of servers: + +1. **Install each server as networked** (the installer as root) and pair it + to your host, which anchors it there. Enrol a second factor on each one. +2. **Make one approval device** that is not the host's machine + (`device-init` on your own computer, `pin-device ` on each server). + Approvals then come from it, in each server's own words. +3. **Start with reads.** Observe ops (`observe:*`) are read-only and give the + host the view of each server it needs. +4. **Turn recurring jobs into ops**, not shell sessions. An op is a program + your host wrote, run in a sandbox first; you approve its terms once, with + bounds, an expiry and a use count (see [Ops](#ops-programs-your-host-wrote-run-in-a-sandbox-first)). + A change across several servers is one [ship plan](#ship-plans-one-approval-for-a-whole-ship): + one approval, and each server runs only its own lines. +5. **Keep terminals off** where nobody needs them: + `sudo -H privatae-bastion switch session off`. That also stops + root commands. Do not grant `root-exec` on a server you would not hand a + standing root shell. +6. **Send the audit somewhere the host cannot write.** Every audit row also + goes to the system log (see [Audit](#audit-on-this-machine-and-in-its-system-log)); + point your log collector at it. + +When the agent cannot do something without a shell, that is the op to write +next, not the shell to open. + ## A Host on this machine A Host with no platform (a customer's own server with the Host on it) pairs @@ -667,7 +729,30 @@ Read-only, and reported so the host can show **why** rather than a verdict. (`interactive`, `remote`, `scheduler`, `service`, `agent`) and a typed evidence list with a confidence tier. Tools that drive the machine with no model of their own are inventoried as `automation` and promoted to `agent` - only when model traffic joins them. + only when model traffic joins them. Each also says who owns the account it + runs as: `owner_class` (`domain_user`, `local_user`, `service`, `system`), + `owner_admin` and `owner_interactive` (each `yes`, `no` or `unknown`; a + group list or token that could not be read is `unknown`, never `no`). +- **Declared tool servers.** The files where Claude Desktop, Claude Code, + Cursor, VS Code, Windsurf, Codex and Zed declare their MCP servers, per + account and for the projects Claude Code lists, read and never written or + connected to. Per server: client, scope, path, name, transport, whether it is + a local process or a URL (and the URL's host), the launch command truncated + like an agent's, the package and whether it is pinned, the **names** of the + environment variables it gets, whether a value looked like a secret (the + value is dropped where it is read), whether a remote server has + authentication configured, and whether it is running, declared but not + running, or running with no declaration. A command line keeps a URL's + scheme and host only, and every flag, header or `NAME=value` that names a + secret is replaced. A file is opened only once it is known to be a regular + file (on Windows: a reparse point, directory or device at the path is + refused, checked on the open handle), and in an account's home or project + only when that account or root owns it (on Windows: the account whose + profile it is, SYSTEM or the Administrators group; `files_foreign_owner` + counts the rest). The files are read at most every ten minutes, and a read + that does not finish holds the beats for at most 20 seconds in total + (`timed_out` says it did not finish; until one read has finished, no server + is reported). - **Scheduler inventory.** Crontabs, `systemd` timers, `launchd` jobs and Windows scheduled tasks whose command would run an agent or automation. - **Baseline and delta.** `observe:baseline` records what is normal on this @@ -705,8 +790,9 @@ platform **withholds** the capability, so the host can tell a machine that is quiet apart from one that is blind. ### Heartbeat and self-update -Each check-in carries the machine's capabilities, posture, agent scan and -scheduler inventory. The host can direct endpoint rotation and a self-update. +Each check-in carries the machine's capabilities, posture, agent scan, +scheduler inventory and declared tool servers (the last three only to a +pairing granted `observe`). The host can direct endpoint rotation and a self-update. The update is fetched from the release manifest; the manifest's signature is checked against the release keys compiled into this binary and the binary's digest against the manifest before it is swapped. On an anchored machine a @@ -861,6 +947,35 @@ needs infrastructure that is not part of this program, and not built yet: services a host reaches when it starts, or replacements for them; - a place the new host can fetch the backup from, as above. +### Audit: on this machine and in its system log +Every decision the bastion makes is written as one JSON line to an audit +file on this machine: what was asked of the management lane, allowed or +refused (`mgmt_audit.jsonl`), pairing changes (`pairing_audit.jsonl`), and +each pairing's terminals, root commands, store ops and op runs (that +pairing's `audit.jsonl`). A host can fetch these files over the tunnel but +cannot change or remove them. + +Each line is also written to the machine's own system log, so whatever +already collects that log can take it to a store no host can write: + +| System | Where | Name | +|---|---|---| +| Linux | syslog, facility `auth`; on systemd, the journal | `privatae-bastion-audit` (`journalctl -t privatae-bastion-audit`) | +| macOS | syslog | `privatae-bastion-audit` | +| Windows | Application event log, event id 1 | source `PrivataeBastionAudit`, registered when the system service is installed | + +The system-log line is the file's line with two fields added: `audit`, which +file it went to, and `pairing`, the pairing it belongs to (empty for a +machine-wide file). One field is left out: a root command's text, which can +hold a password typed on a command line and stays in the file only. The line +keeps the command's `command_sha256` and `command_bytes`, so it can still be +matched to the file and the transcript. + +The file stays the record. Lines go to the system log through a queue, so a +system log that is missing, slow or stuck never holds up the op being +audited; if the queue fills, lines are dropped, and the next line queued +carries `system_log_dropped`, how many were lost just before it. + ### Local console `privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1` and reachable from nowhere else: pairings, the anchor, containment state, marks @@ -1123,7 +1238,8 @@ says so. Those parts are enforced only by the code as written. | | `local_host_linux_test.go`: `TestARunSocketOpensOnlyForALiveRunOfThePairing` | No socket is made for a run with no record, another pairing's run or a run the watchdog stopped; another pairing reusing the run_id gets its own. | | | `local_host_linux_test.go`: `TestAUnixPairingsRunStillCannotReachThisMachine` | A run a unix pairing owns gets 403 from its egress proxy for loopback, this machine's public address and a name for it. | | | `local_host_linux_test.go`: `TestTheOwnersImagePrefix`, `TestALocalImageRunsByItsIDWithoutAPull` | Run-up takes only `harness-@sha256:` under the owner's prefix, and pull credentials only for its registry. A local image runs by its ID with no pull only when the image with that ID carries a tag of the named repository. | -| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import, module or string in the code names MCP, an `/mcp` path or JSON-RPC. | +| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. | +| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. | | Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. | | | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. | | | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. | @@ -1222,6 +1338,14 @@ says so. Those parts are enforced only by the code as written. | | `ownaddr_test.go`: `TestBackupLanesRefuseThisMachinesOwnAddresses` | The backup lanes' URL check refuses an address on this machine's interface and one a `local` route delivers here, in every spelling and through a name, and their dial wall refuses those addresses; a failed lookup refuses both; a neighbour on the subnet passes. With the kernel's answer supplied. | | | `backupfetch_test.go`: `TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges` | The backup lanes' classifier, address wall and URL check refuse 10.0.0.1 and 127.0.0.1 inside NAT64, 10.0.0.1 inside 6to4 and in the IPv4-compatible and IPv4-translated forms, the metadata address inside 6to4, a local-use NAT64 address, `fec0::1`, `198.18.0.0/15` (also inside an IPv4-mapped address), `240.0.0.0/4` (also inside NAT64) and `255.255.255.255`; public addresses in the NAT64, 6to4 and IPv4-mapped forms, and the addresses either side of `198.18.0.0/15`, pass the classifier. | | | not covered by a test | A public address that reaches this machine only through a 1:1 NAT in front of it. Nothing refuses it. | +| Audit rows also reach the system log | `auditlog_test.go`: `TestAuditRowsReachTheSystemLog` | The management lane, pairing changes, store ops, terminals and root commands each send the row they write to their file to the system log too, unchanged, with `audit` and `pairing` added. | +| | `auditlog_test.go`: `TestAuditFileRowStandsWithoutASystemLog` | A system log that takes nothing costs only that copy; the row is still written to its file. | +| | `auditlog_syslog_test.go`: `TestAuditRowsDoNotWaitOnAStuckSyslogSocket` | A real syslog writer on a socket nobody reads blocks; 5000 rows queued in front of it all return at once and the excess is counted as dropped. | +| | `auditlog_queue_test.go`: `TestAuditSinkIsTheQueue`, `TestAuditQueueNeverWaitsOnAStuckWriter` | The shipped sink is the queue, not the writer; with a writer that never returns, 1000 rows are taken at once, and every row is either written or counted in `system_log_dropped` on a later line. | +| | `auditlog_queue_test.go`: `TestAuditSystemLogOmitsTheRootCommandText` | A root command's text is not in the system-log line; its digest and length are. | +| A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. | +| | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. | +| A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. | | A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. | | | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. | | | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. | @@ -1422,6 +1546,7 @@ client-go/ the bastion: one Go package, one binary coder_socket.go a coder run's socket for a Host on this machine anchor.go networked-mode anchor mgmt*.go management lane, allowlist and audit + auditlog*.go every audit row also written to the system log writes.go which management ops are writes (and need an approval) device*.go the approval device quarantine.go closing a pairing's write lanes after repeated refusals diff --git a/client-go/agentowner.go b/client-go/agentowner.go new file mode 100644 index 0000000..9c2f1c3 --- /dev/null +++ b/client-go/agentowner.go @@ -0,0 +1,433 @@ +package main + +import ( + "bytes" + "errors" + "os/exec" + "strconv" + "strings" +) + +// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT. +// +// The process scan names the account an agent runs as (procUser on every +// platform, winTokenUser on Windows) and says nothing else about it. The +// question an assessment asks is about the account: is it an administrator, is +// it a person's own account or one that exists for a service, and is that +// person logged in right now. "An agent runs as a person, holds administrator +// rights, and nobody is at the keyboard" is a different finding from "an agent +// runs under a service account with no rights", and the account name alone +// cannot tell them apart. +// +// Three facts, each with an honest third value: +// +// owner_class domain_user, local_user, service, system, or unknown +// owner_admin yes, no, or unknown +// owner_interactive yes, no, or unknown +// +// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list +// has not learned that the account is unprivileged, and saying "no" there is +// the confident-wrong answer this plane refuses everywhere else +// (baseline_identity.go draws the same line for sudo rules it could not read). +// +// Every decision is in this file, with no build tag, so the Linux test run +// exercises the macOS and Windows rules too. The platform files +// (agents_.go) only read: /proc and /etc on Linux, `id`, `dscl` and +// `who` on macOS, the process token and the session list on Windows. +// +// These ride on agentObs, so they travel only where `agents` does: inside the +// heartbeat's `observing` gate (agents.go). + +const ( + ownerClassDomain = "domain_user" + ownerClassLocal = "local_user" + ownerClassService = "service" + ownerClassSystem = "system" + ownerUnknown = "unknown" + ownerYes = "yes" + ownerNo = "no" +) + +// ownerFacts is the three answers for one account. +type ownerFacts struct { + Class string + Admin string + Interactive string +} + +// unknownOwner is the answer when nothing could be read: a process whose +// owner this bastion may not inspect, or a build with no reader. +var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown} + +func yesNo(b bool) string { + if b { + return ownerYes + } + return ownerNo +} + +// ── Linux and macOS ───────────────────────────────────────────────────────── + +// unixOwner is what a Linux or macOS reader collected about one account. +// Every field that can be unreadable carries its own "known" flag. +type unixOwner struct { + UID int // -1 when the uid could not be read + Name string + // LocalRecord: "yes" when the account is in this machine's own account + // database (/etc/passwd; the macOS local directory node), "no" when it is + // not, "unknown" when that could not be read. + LocalRecord string + // DirectorySource: this machine resolves accounts from a directory + // (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was + // copied from one). Only then does "not in the local database" mean a + // domain account rather than a uid with no account at all. + DirectorySource bool + // SystemUIDMax is the highest uid the platform hands to system accounts. + SystemUIDMax int + Groups []string // group names the account or process holds + GroupsKnown bool + AdminGroups map[string]bool + // SudoApplies: on this platform a sudo rule can grant administration to + // any user or group (Linux), so "in no admin group" is only a "no" once + // the rules were read as well. macOS leaves it false and decides on the + // admin group, which is what its stock sudoers grants. + // SudoNamed / SudoKnown: a sudo rule names the account directly or + // through a group (sudoPrincipals), and whether the rules were readable. + SudoApplies bool + SudoNamed bool + SudoKnown bool + // Sessions is the set of account names with a login session now + // (logind, utmp, `who`); SessionsKnown is false when none could be read. + Sessions map[string]bool + SessionsKnown bool +} + +// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below +// UID_MIN (1000 by default) are system accounts. login.defs can move both; +// linuxUIDLimits reads it. +const linuxSystemUIDMax = 999 + +// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and +// keeps the ids below for its own daemons (_www, _spotlight and the rest). +const darwinSystemUIDMax = 500 + +func classifyUnixOwner(o unixOwner) ownerFacts { + f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown} + switch { + case o.UID == 0: + f.Class = ownerClassSystem + case o.UID == -1: + // Unread uid: the class cannot be told from a name alone. Other + // negative ids are real: macOS's directory gives nobody -2. + case o.UID == 65534 || o.UID <= o.SystemUIDMax: + // nobody, and every uid in the system range, is an account that exists + // for a program rather than a person. + f.Class = ownerClassService + case strings.ContainsAny(o.Name, `@\`): + // user@realm (SSSD) and DOMAIN\user (winbind) are directory names. + f.Class = ownerClassDomain + case o.LocalRecord == ownerYes: + f.Class = ownerClassLocal + case o.LocalRecord == ownerNo && o.DirectorySource: + f.Class = ownerClassDomain + } + + switch { + case o.UID == 0: + f.Admin = ownerYes + case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups): + f.Admin = ownerYes + case o.SudoKnown && o.SudoNamed: + f.Admin = ownerYes + case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies): + f.Admin = ownerNo + } + + if o.SessionsKnown && o.Name != "" { + f.Interactive = yesNo(o.Sessions[o.Name]) + } + return f +} + +func hasAnyGroup(groups []string, want map[string]bool) bool { + for _, g := range groups { + if want[g] { + return true + } + } + return false +} + +// linuxAdminGroups: the groups a stock distribution's sudoers grants +// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and +// Arch, admin on older Ubuntu). +var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true} + +// darwinAdminGroups: macOS administrators are members of `admin`. +var darwinAdminGroups = map[string]bool{"admin": true} + +// parseStatusIDs reads the real and effective uid and the supplementary gid +// list out of /proc//status. ok is false when there is no Uid line. +func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) { + realUID, effUID = -1, -1 + for _, line := range strings.Split(status, "\n") { + switch { + case strings.HasPrefix(line, "Uid:"): + f := strings.Fields(line) + if len(f) >= 3 { + r, err1 := strconv.Atoi(f[1]) + e, err2 := strconv.Atoi(f[2]) + if err1 == nil && err2 == nil { + realUID, effUID, ok = r, e, true + } + } + case strings.HasPrefix(line, "Groups:"): + for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) { + if n, err := strconv.Atoi(g); err == nil { + gids = append(gids, n) + } + } + } + } + return realUID, effUID, gids, ok +} + +// parseGroupNames maps gid -> name from an /etc/group body. +func parseGroupNames(body string) map[int]string { + out := map[int]string{} + for _, line := range strings.Split(body, "\n") { + p := strings.Split(strings.TrimSpace(line), ":") + if len(p) < 3 || strings.HasPrefix(p[0], "#") { + continue + } + if n, err := strconv.Atoi(p[2]); err == nil { + out[n] = p[0] + } + } + return out +} + +// parsePasswdUIDs is the set of uids /etc/passwd itself lists. +func parsePasswdUIDs(body string) map[int]bool { + out := map[int]bool{} + for _, line := range strings.Split(body, "\n") { + p := strings.Split(strings.TrimSpace(line), ":") + if len(p) < 3 || strings.HasPrefix(p[0], "#") { + continue + } + if n, err := strconv.Atoi(p[2]); err == nil { + out[n] = true + } + } + return out +} + +// parseLoginDefsSysMax returns the highest system uid login.defs declares: +// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default. +func parseLoginDefsSysMax(body string) int { + sysMax, uidMin := -1, -1 + for _, line := range strings.Split(body, "\n") { + f := strings.Fields(strings.TrimSpace(line)) + if len(f) < 2 || strings.HasPrefix(f[0], "#") { + continue + } + n, err := strconv.Atoi(f[1]) + if err != nil { + continue + } + switch f[0] { + case "SYS_UID_MAX": + sysMax = n + case "UID_MIN": + uidMin = n + } + } + switch { + case sysMax >= 0: + return sysMax + case uidMin > 0: + return uidMin - 1 + } + return linuxSystemUIDMax +} + +// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names +// a directory source, so an account missing from /etc/passwd is resolved from +// one rather than simply absent. +func nsswitchHasDirectory(body string) bool { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "passwd:") { + continue + } + for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) { + switch src { + case "sss", "ldap", "winbind", "nis", "nisplus": + return true + } + } + } + return false +} + +// parseLogindUserState reads systemd-logind's per-user state file +// (/run/systemd/users/). active and online mean the user has a session +// now; lingering means services run for them with no session. +func parseLogindUserState(body string) (interactive bool, ok bool) { + for _, line := range strings.Split(body, "\n") { + if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found { + switch v { + case "active", "online": + return true, true + case "lingering", "closing", "offline": + return false, true + } + return false, false + } + } + return false, false +} + +// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a +// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7) +// is a login session. +const ( + utmpRecordSize = 384 + utmpUserOffset = 44 + utmpUserSize = 32 + utmpUserProcess = 7 +) + +// parseUtmpUsers is the set of account names with a USER_PROCESS record. +func parseUtmpUsers(b []byte) map[string]bool { + out := map[string]bool{} + for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize { + rec := b[off : off+utmpRecordSize] + if int(rec[0])|int(rec[1])<<8 != utmpUserProcess { + continue + } + name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize] + if i := bytes.IndexByte(name, 0); i >= 0 { + name = name[:i] + } + if len(name) > 0 { + out[string(name)] = true + } + } + return out +} + +// parseWhoUsers is the set of account names `who` lists (macOS): the first +// field of every line. +func parseWhoUsers(out string) map[string]bool { + users := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + if f := strings.Fields(line); len(f) > 0 { + users[f[0]] = true + } + } + return users +} + +// parseIDGroups reads `id -Gn `: group names separated by spaces. +func parseIDGroups(out string) []string { return strings.Fields(out) } + +// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it +// says the local node has no such record. A dscl the timeout killed has an +// exit code of -1 and has said nothing about the account. +func dsclRecordMissing(err error) bool { + var ee *exec.ExitError + return errors.As(err, &ee) && ee.ExitCode() > 0 +} + +// parseDsclUser reads `dscl . -read /Users/ UniqueID OriginalNodeName`. +// A mobile account (cached from a directory) carries OriginalNodeName. +func parseDsclUser(out string) (uid int, mobile bool, ok bool) { + uid = -1 + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if v, found := strings.CutPrefix(line, "UniqueID:"); found { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + uid, ok = n, true + } + } + if strings.HasPrefix(line, "OriginalNodeName:") { + mobile = true + } + } + return uid, mobile, ok +} + +// ── Windows ───────────────────────────────────────────────────────────────── + +// Well-known SIDs, from Microsoft's tables: +// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and +// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers +const ( + sidLocalSystem = "S-1-5-18" + sidLocalService = "S-1-5-19" + sidNetworkService = "S-1-5-20" + sidAdministrators = "S-1-5-32-544" + sidInteractive = "S-1-5-4" + sidRemoteInteractive = "S-1-5-14" + sidLocalAccount = "S-1-5-113" + sidNTServicePrefix = "S-1-5-80-" + sidVirtualMachinePfx = "S-1-5-83-" +) + +// winOwner is what the Windows reader collected from one process token. +type winOwner struct { + SID string + Domain string // the account's domain as LookupAccount names it + Computer string // this machine's NetBIOS name + // Groups are the token's group SIDs, deny-only ones included: an + // administrator running without elevation holds Administrators as a + // deny-only group, and is still an administrator account. + Groups map[string]bool + GroupsKnown bool + // SessionSIDs: accounts with a Remote Desktop Services session now + // (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem). + SessionSIDs map[string]bool + SessionsKnown bool +} + +func classifyWinOwner(o winOwner) ownerFacts { + f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown} + switch { + case o.SID == "": + case o.SID == sidLocalSystem: + f.Class = ownerClassSystem + case o.SID == sidLocalService || o.SID == sidNetworkService || + strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx): + f.Class = ownerClassService + case o.GroupsKnown && o.Groups[sidLocalAccount]: + f.Class = ownerClassLocal + case o.Domain == "": + case strings.EqualFold(o.Domain, o.Computer): + f.Class = ownerClassLocal + case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"): + // A machine or domain account whose domain is not this computer: + // an Active Directory or Entra ID account. + f.Class = ownerClassDomain + } + + switch { + case o.SID == sidLocalSystem: + // "System is a hidden member of Administrators" (the second page above). + f.Admin = ownerYes + case o.GroupsKnown: + f.Admin = yesNo(o.Groups[sidAdministrators]) + } + + switch { + case o.SessionsKnown && o.SID != "": + f.Interactive = yesNo(o.SessionSIDs[o.SID]) + case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]): + // No session list (the bastion is not LocalSystem), but this very + // process was started by an interactive logon, so the account has + // one. The converse proves nothing: a scheduled task's token is not + // interactive while its owner may well be logged in. + f.Interactive = ownerYes + } + return f +} diff --git a/client-go/agentowner_linux_test.go b/client-go/agentowner_linux_test.go new file mode 100644 index 0000000..9d726b5 --- /dev/null +++ b/client-go/agentowner_linux_test.go @@ -0,0 +1,83 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// The Linux reader end to end over an invented machine: a /proc with two +// processes and an /etc and /run that say who they belong to. Nothing here is +// read from the box the test runs on. +func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+ + "kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n") + write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n") + write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n") + write("etc/nsswitch.conf", "passwd: files\n") + write("run/systemd/users/1001", "UID=1001\nSTATE=active\n") + write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n") + write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n") + write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n") + write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n") + + oldID, oldProc := identityRoot, procRoot + identityRoot, procRoot = root, filepath.Join(root, "proc") + t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc }) + + read := ownerFactsReader() + for _, c := range []struct { + pid int + user string + want ownerFacts + }{ + {400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}}, + {401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}}, + {402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}}, + // Effective root under kim's account: administrator, class unchanged. + {403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}}, + // A pid with no status file: everything unknown, nothing guessed. + {404, "ghost", unknownOwner}, + } { + if got := read(c.pid, c.user); got != c.want { + t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want) + } + } +} + +// Every observation scanAgents returns carries the owner facts: the wiring, +// not only the classifier. The one process is this test itself, so the facts +// come from the real /proc and are only checked for being filled in. +func TestScanAgentsCarriesOwnerFacts(t *testing.T) { + prev := scanProcessesFn + t.Cleanup(func() { scanProcessesFn = prev }) + self := os.Getpid() + scanProcessesFn = func() (map[int]agentObs, error) { + return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code", + Cmdline: "claude", raw: "claude"}}, nil + } + got, err := scanAgents() + if err != nil || len(got) == 0 { + t.Fatalf("scan: %d observations, %v", len(got), err) + } + // The network signal may add this box's own model clients; every + // observation, however it was caught, must carry the facts. + for _, a := range got { + if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" { + t.Fatalf("owner facts missing from pid %d", a.PID) + } + } +} diff --git a/client-go/agentowner_test.go b/client-go/agentowner_test.go new file mode 100644 index 0000000..12f7e35 --- /dev/null +++ b/client-go/agentowner_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "encoding/binary" + "testing" +) + +// Every value below is invented. No account, group or SID here belongs to a +// real machine. + +func TestClassifyUnixOwner(t *testing.T) { + linux := func(o unixOwner) unixOwner { + o.SystemUIDMax = linuxSystemUIDMax + o.AdminGroups = linuxAdminGroups + o.SudoApplies = true + return o + } + cases := []struct { + name string + in unixOwner + want ownerFacts + }{ + {"root is the system and an administrator", + linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true, + Sessions: map[string]bool{}, SessionsKnown: true}), + ownerFacts{ownerClassSystem, ownerYes, ownerNo}}, + {"a daemon account in the system range", + linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true, + Sessions: map[string]bool{}, SessionsKnown: true}), + ownerFacts{ownerClassService, ownerNo, ownerNo}}, + {"nobody is a service account", + linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}), + ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}}, + {"a person in the sudo group, logged in", + linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"}, + GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}), + ownerFacts{ownerClassLocal, ownerYes, ownerYes}}, + {"a person a sudo rule names directly", + linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"}, + GroupsKnown: true, SudoNamed: true, SudoKnown: true}), + ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}}, + {"no admin group and the sudo rules read: no", + linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, + GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}), + ownerFacts{ownerClassLocal, ownerNo, ownerNo}}, + {"no admin group but the sudo rules unreadable: unknown, never no", + linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}), + ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}}, + {"groups unreadable: unknown, never no", + linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}), + ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}}, + {"not in passwd on a machine that resolves from a directory", + linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}), + ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}}, + {"not in passwd and no directory: a uid with no account, not a domain user", + linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}), + ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}}, + {"a realm-qualified name is a directory account", + linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}), + ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}}, + {"uid unread", + linux(unixOwner{UID: -1, Name: ""}), + ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}}, + {"macOS admin group member", + unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"}, + GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups, + Sessions: map[string]bool{"sam": true}, SessionsKnown: true}, + ownerFacts{ownerClassLocal, ownerYes, ownerYes}}, + {"macOS standard user: the admin group decides", + unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"}, + GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups}, + ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}}, + {"macOS daemon", + unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax, + AdminGroups: darwinAdminGroups}, + ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}}, + {"macOS mobile account", + unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true, + SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups}, + ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}}, + } + for _, c := range cases { + if got := classifyUnixOwner(c.in); got != c.want { + t.Errorf("%s: got %+v, want %+v", c.name, got, c.want) + } + } +} + +func TestClassifyWinOwner(t *testing.T) { + const box = "DESK-DEMO" + user := "S-1-5-21-1111111111-2222222222-3333333333-1001" + cases := []struct { + name string + in winOwner + want ownerFacts + }{ + {"LocalSystem", + winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box}, + ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}}, + {"NetworkService", + winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box, + Groups: map[string]bool{}, GroupsKnown: true}, + ownerFacts{ownerClassService, ownerNo, ownerUnknown}}, + {"a virtual service account", + winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box}, + ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}}, + {"local administrator, unelevated, at the desktop", + winOwner{SID: user, Domain: box, Computer: box, + Groups: map[string]bool{sidAdministrators: true, sidInteractive: true}, + GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true}, + ownerFacts{ownerClassLocal, ownerYes, ownerYes}}, + {"domain user, not an administrator, no session now", + winOwner{SID: user, Domain: "CORPDEMO", Computer: box, + Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true, + SessionSIDs: map[string]bool{}, SessionsKnown: true}, + ownerFacts{ownerClassDomain, ownerNo, ownerNo}}, + {"the local-account group decides even with no domain name", + winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true}, + ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}}, + {"no session list, but this process is an interactive logon", + winOwner{SID: user, Domain: box, Computer: box, + Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true}, + ownerFacts{ownerClassLocal, ownerNo, ownerYes}}, + {"no session list and a batch token: unknown, never no", + winOwner{SID: user, Domain: box, Computer: box, + Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true}, + ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}}, + {"token unreadable", + winOwner{}, + ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}}, + } + for _, c := range cases { + if got := classifyWinOwner(c.in); got != c.want { + t.Errorf("%s: got %+v, want %+v", c.name, got, c.want) + } + } +} + +func TestOwnerFileParsers(t *testing.T) { + status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n" + r, e, gids, ok := parseStatusIDs(status) + if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 { + t.Fatalf("status: %d %d %v %v", r, e, gids, ok) + } + if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok { + t.Fatal("a status with no Uid line must not read as uid 0") + } + names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n") + if names[27] != "sudo" || names[0] != "root" || len(names) != 2 { + t.Fatalf("groups: %v", names) + } + uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n") + if !uids[1001] || !uids[0] || uids[5] { + t.Fatalf("passwd: %v", uids) + } + for body, want := range map[string]int{ + "": linuxSystemUIDMax, + "UID_MIN 500\n": 499, + "UID_MIN 1000\nSYS_UID_MAX 899\n": 899, + "# SYS_UID_MAX 10\n": linuxSystemUIDMax, + } { + if got := parseLoginDefsSysMax(body); got != want { + t.Errorf("login.defs %q: %d, want %d", body, got, want) + } + } + if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") || + nsswitchHasDirectory("passwd: files systemd\n") { + t.Fatal("nsswitch directory detection") + } + for body, want := range map[string][2]bool{ + "UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true}, + "STATE=lingering\n": {false, true}, + "STATE=something-new\n": {false, false}, + "UID=1001\n": {false, false}, + } { + got, ok := parseLogindUserState(body) + if got != want[0] || ok != want[1] { + t.Errorf("logind %q: %v %v", body, got, ok) + } + } + if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 { + t.Fatalf("who: %v", u) + } + uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n") + if !ok || uid != 1204567 || !mobile { + t.Fatalf("dscl: %d %v %v", uid, mobile, ok) + } + if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" { + t.Fatalf("id: %v", g) + } +} + +func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) { + rec := func(typ uint16, user string) []byte { + b := make([]byte, utmpRecordSize) + binary.LittleEndian.PutUint16(b, typ) + copy(b[utmpUserOffset:], user) + return b + } + var blob []byte + blob = append(blob, rec(utmpUserProcess, "pat")...) + blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended + blob = append(blob, rec(utmpUserProcess, "lee")...) + blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread + got := parseUtmpUsers(blob) + if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 { + t.Fatalf("utmp: %v", got) + } +} diff --git a/client-go/agents.go b/client-go/agents.go index 361ede3..50e97ee 100644 --- a/client-go/agents.go +++ b/client-go/agents.go @@ -213,9 +213,17 @@ type agentObs struct { // what makes a service RESTART the same entry rather than a new one // (baseline.go). Empty where the platform does not supply one: darwin and // windows never populate procFacts.Unit, so those fall back to the origin. - Unit string `json:"unit,omitempty"` - Evidence []evidence `json:"evidence,omitempty"` - raw string // the UNTRUNCATED command line, for classification only; never reported + Unit string `json:"unit,omitempty"` + // The account behind User (agentowner.go): domain_user, local_user, + // service, system or unknown; whether it administers this machine; and + // whether it has a login session now. yes, no or unknown each — unknown + // is what an unreadable token or group list says, never no. Empty only on + // an observation made outside scanAgents. + OwnerClass string `json:"owner_class,omitempty"` + OwnerAdmin string `json:"owner_admin,omitempty"` + OwnerInteractive string `json:"owner_interactive,omitempty"` + Evidence []evidence `json:"evidence,omitempty"` + raw string // the UNTRUNCATED command line, for classification only; never reported // automation: the signature came from the automation tables, so the // classifier gives this kind `automation` unless model traffic promotes it. automation bool @@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) { } extra := originExtras() facts := procFactsReader() + owners := ownerFactsReader() out := make([]agentObs, 0, len(byPid)) for pid, a := range byPid { if faIsOwnChild(pid) { @@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) { if f, ok := facts(pid); ok { a.Unit = f.Unit } + // Who the owning account is, beside who it is called. Read here + // rather than in each platform's walk so a process the network + // signal alone caught (mergeConnections) gets the same answer. + o := owners(pid, a.User) + a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...) if a.DelegationToken != "" { a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"}) @@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) { return out, nil } +// scanMCPProcessesFn is the seam a test uses to hand the correlation a +// process list; production never reassigns it. +var scanMCPProcessesFn = scanMCPProcesses + +// mcpServersReport reads the declared tool servers and correlates them with +// the process list. It lives here, not in mcpconfig.go, because reading the +// process list runs `ps` on macOS: the config reader itself starts nothing, +// and the claims test holds it to that. +func mcpServersReport() ([]mcpServerObs, mcpConfigScan) { + declared, scan, read := cachedMCPConfigs() + if !scan.Supported { + scan.ProcessScan = "unsupported" + return []mcpServerObs{}, scan + } + procs, err := scanMCPProcessesFn(mcpWantedBases(declared)) + switch { + case err == nil: + scan.ProcessScan = "ok" + case agentScanSupported: + scan.ProcessScan = "failed" + default: + scan.ProcessScan = "unsupported" + } + if !read { + // No read of the files has finished yet (timed_out says so). With + // nothing known to be declared, every tool-server-looking process + // would read as running_not_declared, which nobody has checked. + return []mcpServerObs{}, scan + } + out := correlateMCP(declared, procs, err == nil) + if out == nil { + out = []mcpServerObs{} + } + if len(out) >= mcpMaxServers { + scan.Truncated = true + } + return out, scan +} + +// mcpCache holds the last complete read of the config files. The heartbeat +// is every 60 s and the read is up to a thousand files across every account; +// like the scheduler inventory (schedCacheFor) it is redone every ten +// minutes, while the process correlation above runs on every beat. One read +// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL, +// counted from when it started: a home on a network mount that stopped +// answering holds that one goroutine, delays one beat by at most that much, +// never the beats after it, and never starts a second read. +var mcpCache struct { + sync.Mutex + at time.Time + have bool + declared []mcpServerObs + scan mcpConfigScan + inflight chan struct{} + started time.Time // when the read in flight began + gen int +} + +const mcpCacheFor = 10 * time.Minute + +// mcpReadTimeout is how long one beat waits for a read in progress. +var mcpReadTimeout = 20 * time.Second + +// cachedMCPConfigs is the last complete read. read is false when no read has +// finished yet, so the list says nothing about what is declared. +func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) { + mcpCache.Lock() + if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor { + d, s := mcpCache.declared, mcpCache.scan + mcpCache.Unlock() + return d, s, true + } + done := mcpCache.inflight + if done == nil { + done = make(chan struct{}) + mcpCache.inflight, mcpCache.started = done, time.Now() + gen := mcpCache.gen + go func() { + d, s := readMCPConfigs() + mcpCache.Lock() + if mcpCache.gen == gen { + mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true + mcpCache.inflight = nil + } + mcpCache.Unlock() + close(done) + }() + } + wait := mcpReadTimeout - time.Since(mcpCache.started) + mcpCache.Unlock() + timedOut := false + if wait <= 0 { + // An earlier beat already waited the whole time for this read. + select { + case <-done: + default: + timedOut = true + } + } else { + t := time.NewTimer(wait) + select { + case <-done: + case <-t.C: + timedOut = true + } + t.Stop() + } + mcpCache.Lock() + defer mcpCache.Unlock() + if !mcpCache.have { + // Nothing read yet: say so, rather than "no servers". + return nil, mcpConfigScan{Supported: true, TimedOut: true}, false + } + s := mcpCache.scan + s.TimedOut = timedOut + return mcpCache.declared, s, true +} + +// resetMCPConfigCache forgets the last read; a read still running is +// ignored when it finishes. Tests use it to start clean. +func resetMCPConfigCache() { + mcpCache.Lock() + defer mcpCache.Unlock() + mcpCache.gen++ + mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil +} + var ( llmIPCache map[string]string llmIPCacheAt time.Time @@ -554,11 +695,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t // done for nothing, on the user's own hardware. var schedEntries []schedEntry var schedScan schedScanReport + // Declared tool servers (mcpconfig.go): the agent clients' own config + // files, correlated with the process list. Same gate and same + // quiet-vs-blind rule as the schedulers: read only for a pairing entitled + // to observe, and the capability travels only from a build that can. + var mcpServers []mcpServerObs + var mcpScan mcpConfigScan if observing { schedEntries, schedScan = scanSchedulers() if schedScan.Supported { caps = append(caps, "scheduler-observe") } + mcpServers, mcpScan = mcpServersReport() + if mcpScan.Supported { + caps = append(caps, "tool-server-observe") + } } // Context preparation (contextprep.go): the tools on this box that // turn a user's bytes into their content — a PDF's text, an image's OCR. @@ -674,6 +825,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t // that read 0 crontabs is visibly different from one that read 40. body["schedulers"] = schedEntries body["scheduler_scan"] = schedScan + // The tool servers each agent client declares, and which are running + // (or running with no declaration). Paths, server names and command + // lines, so inside this gate with the agent list; env values and + // header values never leave mcpconfig.go, only their names and + // whether one looked like a secret. + body["tool_servers"] = mcpServers + body["tool_server_scan"] = mcpScan // Where this machine keeps its own record — the findings journal and // the signed accounting (machinestore.go, baselinesign.go). The host // drafts a machine store at this root for the user to approve, once; diff --git a/client-go/agents_darwin.go b/client-go/agents_darwin.go index 1de53b3..0ce7f2b 100644 --- a/client-go/agents_darwin.go +++ b/client-go/agents_darwin.go @@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) { return out, nil } +// ownerFactsReader answers the three owner facts agentowner.go decides on. +// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the +// system's own tools, each a fixed argv under runTimed's timeout: `dscl .` +// (the LOCAL node only) for the uid and whether the account is a mobile copy +// of a directory account, `id -Gn` for its groups (nested groups resolved by +// the system), and `who` once per scan for who is logged in. Answers are kept +// per account for the scan, so ten agents of one person cost three commands. +func ownerFactsReader() func(pid int, user string) ownerFacts { + var sessions map[string]bool + sessionsOK, sessionsRead := false, false + cache := map[string]ownerFacts{} + return func(pid int, user string) ownerFacts { + if user == "" || strings.ContainsAny(user, "/ \t\n") { + return unknownOwner + } + if f, ok := cache[user]; ok { + return f + } + if !sessionsRead { + sessionsRead = true + if out, err := runTimed("who"); err == nil { + sessions, sessionsOK = parseWhoUsers(string(out)), true + } + } + o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown, + SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups, + Sessions: sessions, SessionsKnown: sessionsOK} + out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName") + if err == nil { + if uid, mobile, ok := parseDsclUser(string(out)); ok { + o.UID = uid + o.LocalRecord = ownerYes + if mobile { + o.LocalRecord, o.DirectorySource = ownerNo, true + } + } + } else if dsclRecordMissing(err) { + // dscl ran and has no such record in the local node: the account + // comes from a directory. The uid then has to come from id. + o.LocalRecord, o.DirectorySource = ownerNo, true + if out, err := runTimed("id", "-u", "--", user); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + o.UID = n + } + } + } + if out, err := runTimed("id", "-Gn", "--", user); err == nil { + o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true + } + f := classifyUnixOwner(o) + cache[user] = f + return f + } +} + +// scanMCPProcesses is the process list mcpconfig.go correlates declared tool +// servers against: one `ps` sweep, every process with its parent and +// arguments. An error is a sweep that did not happen. +func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) { + _, argvs, parents, err := psSweep() + if err != nil { + return nil, err + } + self := os.Getpid() + out := make([]mcpProc, 0, len(argvs)) + for pid, argv := range argvs { + if pid != self { + out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv}) + } + } + return out, nil +} + // procParent answers from the sweep scanProcesses just took. func procParent(pid int) int { psCache.Lock() diff --git a/client-go/agents_linux.go b/client-go/agents_linux.go index 72cce3c..30b8e38 100644 --- a/client-go/agents_linux.go +++ b/client-go/agents_linux.go @@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) { return out, nil } +// scanMCPProcesses is the process list mcpconfig.go correlates declared tool +// servers against: every process, with its parent and command line, from one +// /proc walk. The want set only matters on Windows, where reading a command +// line costs a handle per process. An error is a walk that did not happen, +// and then no running state is guessed. +func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil, fmt.Errorf("read %s: %w", procRoot, err) + } + self := os.Getpid() + var out []mcpProc + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || pid == self || !e.IsDir() { + continue + } + p := mcpProc{PID: pid} + if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil { + p.PPID = parseProcStatPPID(string(raw)) + } + if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil { + p.Argv = splitNul(raw) + } + out = append(out, p) + } + return out, nil +} + // procParent reads one process's parent pid from /proc//stat. func procParent(pid int) int { raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") @@ -126,6 +155,109 @@ func procUser(pid int) string { return "" } +// ownerFactsReader answers, per process, the three facts about its owner that +// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group, +// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once +// per scan, on first use, and every process looks itself up in them. +// +// The uid and groups come from /proc//status, so they are what the +// PROCESS holds, the same source procUser names it from. +func ownerFactsReader() func(pid int, user string) ownerFacts { + var ( + loaded bool + groupNames map[int]string + groupsOK bool + passwd map[int]bool + passwdOK bool + sysMax int + directory bool + sudoers map[string]bool + sudoKnown bool + sessions map[string]bool + sessionsOK bool + logindUsers = identityPath("/run/systemd/users") + ) + load := func() { + loaded = true + if b, err := os.ReadFile(identityPath("/etc/group")); err == nil { + groupNames, groupsOK = parseGroupNames(string(b)), true + } + if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil { + passwd, passwdOK = parsePasswdUIDs(string(b)), true + } + sysMax = linuxSystemUIDMax + if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil { + sysMax = parseLoginDefsSysMax(string(b)) + } + if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil { + directory = nsswitchHasDirectory(string(b)) + } + var partial string + sudoers, partial = sudoPrincipals() + sudoKnown = partial == "" + // Who has a session: logind's own record when it runs, else utmp. + // Neither readable leaves the answer unknown rather than "nobody". + if _, err := os.Stat(logindUsers); err != nil { + if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil { + sessions, sessionsOK = parseUtmpUsers(b), true + } + } + } + return func(pid int, user string) ownerFacts { + if !loaded { + load() + } + raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status") + if err != nil { + return unknownOwner + } + realUID, effUID, gids, ok := parseStatusIDs(string(raw)) + if !ok { + return unknownOwner + } + o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory, + SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true, + SudoNamed: sudoers[user], SudoKnown: sudoKnown} + if passwdOK { + o.LocalRecord = yesNo(passwd[realUID]) + } + if groupsOK { + o.GroupsKnown = true + for _, g := range gids { + if n, ok := groupNames[g]; ok { + o.Groups = append(o.Groups, n) + } + } + } + switch { + case sessionsOK: + o.Sessions, o.SessionsKnown = sessions, true + default: + b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID)) + switch { + case err == nil: + if live, ok := parseLogindUserState(string(b)); ok { + o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true + } + case os.IsNotExist(err): + // logind keeps a file for every user it tracks; none means + // this account has no session and no lingering services. + if _, derr := os.Stat(logindUsers); derr == nil { + o.Sessions, o.SessionsKnown = map[string]bool{}, true + } + } + } + f := classifyUnixOwner(o) + if effUID == 0 { + // A process running with effective uid 0 holds root whatever + // account started it: that is the administrator answer that + // matters, while the class stays the account's own. + f.Admin = ownerYes + } + return f + } +} + func procCmdline(pid int) string { raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") if err != nil { diff --git a/client-go/agents_other.go b/client-go/agents_other.go index a4bd7e8..1be1562 100644 --- a/client-go/agents_other.go +++ b/client-go/agents_other.go @@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" } func procExe(pid int) string { return "" } func procParent(pid int) int { return 0 } func processParents() map[int]int { return map[int]int{} } + +// ownerFactsReader: no reader here, so every owner fact is unknown. +func ownerFactsReader() func(pid int, user string) ownerFacts { + return func(int, string) ownerFacts { return unknownOwner } +} + +// scanMCPProcesses: no enumerator, so running state is never measured here. +func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator } diff --git a/client-go/agents_windows.go b/client-go/agents_windows.go index 7e759c9..972c95b 100644 --- a/client-go/agents_windows.go +++ b/client-go/agents_windows.go @@ -306,6 +306,7 @@ func processParents() map[int]int { // scanProcesses: an error is a snapshot that did not happen (Toolhelp can // fail transiently), never an empty box; the heartbeat withholds `agents`. func scanProcesses() (map[int]agentObs, error) { + winBeginSIDScan() out := map[int]agentObs{} snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { @@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) { return out, nil } +// scanMCPProcesses is the process list mcpconfig.go correlates declared tool +// servers against. Every process comes from one Toolhelp snapshot with its +// parent; the command line, which costs a handle and a memory read, is read +// only for a program in want (interpreters, package runners, and every +// program a declared server names) or whose own name carries the word mcp. +// The rest keep their parent link with no command line, which is all the +// correlation needs from them. An error is a snapshot that did not happen. +func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("process snapshot: %w", err) + } + defer windows.CloseHandle(snap) + var e windows.ProcessEntry32 + e.Size = uint32(unsafe.Sizeof(e)) + self := os.Getpid() + var out []mcpProc + for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) { + pid := int(e.ProcessID) + if pid <= 0 || pid == self { + continue + } + p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)} + exe := windows.UTF16ToString(e.ExeFile[:]) + if base := binBase(exe); want[base] || mcpWord(base) { + cmdline := "" + if h, herr := winOpenForRead(pid); herr == nil { + cmdline = winCmdline(h) + windows.CloseHandle(h) + } + if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 { + p.Argv = argv + } else { + p.Argv = []string{exe} + } + } + out = append(out, p) + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, fmt.Errorf("process walk: %w", err) + } + return out, nil +} + // ── connections: GetExtendedTcpTable ──────────────────────────────────────── var ( @@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string { if err != nil || tu.User.Sid == nil { return "" } - account, domain, _, err := tu.User.Sid.LookupAccount("") + return winAccountLabel(tu.User.Sid.String(), winScanSIDNames()) +} + +// winSIDScan is the current scan's account-name cache (sidnames.go), shared +// by winTokenUser and ownerFactsReader so a SID is looked up once per scan +// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a +// new one; the readers that run after it in scanAgents use that one. +var winSIDScan struct { + sync.Mutex + names *sidNames +} + +func winBeginSIDScan() *sidNames { + n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots) + winSIDScan.Lock() + winSIDScan.names = n + winSIDScan.Unlock() + return n +} + +func winScanSIDNames() *sidNames { + winSIDScan.Lock() + n := winSIDScan.names + winSIDScan.Unlock() + if n == nil { + return winBeginSIDScan() + } + return n +} + +// winLookupSID is LookupAccountSid on this machine for one SID string. +func winLookupSID(s string) (string, string, error) { + a, err := winLookupSIDAccount(s) + return a.Account, a.Domain, err +} + +// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded: +// callers go through sidNames or winAccounts. +func winLookupSIDAccount(s string) (sidAccount, error) { + sid, err := windows.StringToSid(s) if err != nil { - return tu.User.Sid.String() + return sidAccount{}, err } - if domain != "" { - return domain + `\` + account + account, domain, typ, err := sid.LookupAccount("") + return sidAccount{Account: account, Domain: domain, Type: typ}, err +} + +// winAccounts is the process-wide account cache for lookups made outside a +// scan: the approvals pipe and the folder agent's drop (sidnames.go). +var winAccounts = newSIDDirectory(winLookupSIDAccount) + +// ownerFactsReader answers the three owner facts agentowner.go decides on, +// from each process's own token: its user SID and that SID's domain, and its +// group SIDs (deny-only ones included, which is how an administrator running +// without elevation still reads as an administrator account). The session +// list is read once per scan and needs LocalSystem, which the system service +// is; elsewhere it is left unknown and the token's own logon type answers +// what it can (classifyWinOwner). The domain comes from the scan's shared +// account-name cache (winScanSIDNames), so a SID winTokenUser already looked +// up is not asked again, and an unreachable domain controller leaves the +// class unknown instead of holding the scan. +func ownerFactsReader() func(pid int, user string) ownerFacts { + computer, _ := windows.ComputerName() + names := winScanSIDNames() + var sessions map[string]bool + sessionsOK, sessionsRead := false, false + return func(pid int, _ string) ownerFacts { + if !sessionsRead { + sessionsRead = true + sessions, sessionsOK = winSessionSIDs() + } + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return unknownOwner + } + defer windows.CloseHandle(h) + var tok windows.Token + if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil { + return unknownOwner + } + defer tok.Close() + o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK} + if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil { + o.SID = tu.User.Sid.String() + o.Domain = winAccountDomain(o.SID, names) + } + if tg, err := tok.GetTokenGroups(); err == nil { + o.Groups, o.GroupsKnown = map[string]bool{}, true + for _, g := range tg.AllGroups() { + if g.Sid != nil { + o.Groups[g.Sid.String()] = true + } + } + } + return classifyWinOwner(o) } - return account +} + +// winSessionSIDs is the set of user SIDs with an active or disconnected +// Remote Desktop Services session (the console is one). ok is false when the +// list could not be read: WTSQueryUserToken needs LocalSystem's privilege. +func winSessionSIDs() (map[string]bool, bool) { + var info *windows.WTS_SESSION_INFO + var count uint32 + if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil { + return nil, false + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info))) + list := unsafe.Slice(info, count) + out := map[string]bool{} + for _, s := range list { + if s.State != windows.WTSActive && s.State != windows.WTSDisconnected { + continue + } + var tok windows.Token + if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil { + if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil, false + } + continue // a session with no user (the services session, a login screen) + } + if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil { + out[tu.User.Sid.String()] = true + } + tok.Close() + } + return out, true } func procUser(pid int) string { diff --git a/client-go/anchor.go b/client-go/anchor.go index 4083dcd..e137b67 100644 --- a/client-go/anchor.go +++ b/client-go/anchor.go @@ -978,6 +978,20 @@ func cmdRepinAnchor() error { return nil } +// anchorAlreadyHolds: the anchor names an active pairing here, and host +// (claim-anchor --host, "" for none) names that same pairing. +func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool { + c := pairingFor(cfgs, prev.AnchorHost) + if c == nil || !c.active() { + return false + } + if host == "" { + return true + } + named, err := pickPairing(cfgs, host) + return err == nil && named.HostName == c.HostName +} + // pickPairing resolves --host against this box's pairings. It accepts the full // host_name the box reports, or just the handle in front of it, because the // name the user is looking at depends on which surface they read it from — @@ -1021,6 +1035,17 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error { return err } prev := currentAnchor() + // Already anchored to the host this asks for (no token, no other host + // named), and that host's pairing is active: done, not an error. `pair` + // anchors a networked box to its first host by itself, and an installer + // that runs claim-anchor after pairing must find that a success. An anchor + // whose pairing is pending or revoked is not done, and is refused below as + // before. --host takes the short handle as well as the full name + // (pickPairing), so the same host named either way is the same request. + if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) { + fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner) + return nil + } if prev != nil && !rotate { return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate ` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24)) } diff --git a/client-go/anchor_test.go b/client-go/anchor_test.go index 16758ff..bb2f002 100644 --- a/client-go/anchor_test.go +++ b/client-go/anchor_test.go @@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) { } }) - t.Run("already anchored — still refused without --rotate", func(t *testing.T) { + t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) { + // `pair` anchors a networked box by itself, and an installer that runs + // claim-anchor afterwards must find that done, not an error. freshBox(t, true) writePairing(t, tMember) if err := writeAnchor("Acme", tMember); err != nil { t.Fatal(err) } - if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") { - t.Fatalf("dropping the token must not let a second claim through: %v", err) + if err := cmdClaimAnchor("", false, "", ""); err != nil { + t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err) + } + if err := cmdClaimAnchor("", false, "", tMember); err != nil { + t.Fatalf("naming the anchor host itself must succeed: %v", err) + } + if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil { + t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err) + } + if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" { + t.Fatalf("the anchor must be untouched: %+v", a) + } + }) + + t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) { + // A success here would tell an installer the anchor holds while its + // host has no active lanes on this box. + for _, st := range []string{pairingPending, pairingRevoked} { + freshBox(t, true) + writePairingState(t, tMember, st) + if err := writeAnchor("Acme", tMember); err != nil { + t.Fatal(err) + } + if err := cmdClaimAnchor("", false, "", ""); err == nil { + t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st) + } + if got := loadState(t, tMember); got != st { + t.Fatalf("anchor pairing %s: state changed to %q", st, got) + } + } + }) + + t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) { + freshBox(t, true) + writePairing(t, tMember) + writePairing(t, tAnchor) + if err := writeAnchor("Acme", tMember); err != nil { + t.Fatal(err) + } + if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") { + t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err) + } + if a := currentAnchor(); a == nil || a.AnchorHost != tMember { + t.Fatalf("the anchor must be untouched: %+v", a) } }) diff --git a/client-go/approvalpipe.go b/client-go/approvalpipe.go index 2789c81..4bf9f42 100644 --- a/client-go/approvalpipe.go +++ b/client-go/approvalpipe.go @@ -138,6 +138,30 @@ type pipeClient struct { // "" when nobody is. SessionUserSID string SessionActive bool // WTSActive: the session someone is in front of + // NameTimedOut: the account directory did not answer for UserSID within + // pipeSIDLane's budget, so UserName is empty and nobody can say whose + // account approved. pipeApprove refuses; list and deny go on, by SID. + NameTimedOut bool +} + +// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray +// waits 2*pipeIOTimeout for its answer, and the name is looked up before the +// request is read, so the lookup gets well under that: a directory that does +// not answer costs one connection 2 s, and holds at most one goroutine for +// the pipe however many connections come. +var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second} + +// pipeClientName fills c.UserName from the account directory, or sets +// c.NameTimedOut when the directory did not answer in time. A SID with no +// account leaves UserName empty: the log shows the SID, as before. +func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) { + acct, outcome := dir.resolve(c.UserSID, lane) + switch outcome { + case sidResolved: + c.UserName = sidAccountLabel(acct) + case sidTimedOut: + c.NameTimedOut = true + } } // purposeDigest is the sha256 of a purpose as the tray carries it back. @@ -330,6 +354,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) { var pipeApproveMu sync.Mutex func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse { + // FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an + // approval's audit line names the account that gave it, and when the + // directory could not say whose account this is, nothing is approved. + // Before the throttle and the code, so this counts as no wrong code. + if c.NameTimedOut { + return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID + + " in time; try again in a minute, or approve from an Administrator terminal"} + } pipeApproveMu.Lock() defer pipeApproveMu.Unlock() code := strings.TrimSpace(req.Code) diff --git a/client-go/approvalpipe_windows.go b/client-go/approvalpipe_windows.go index e4eddac..c7c49b2 100644 --- a/client-go/approvalpipe_windows.go +++ b/client-go/approvalpipe_windows.go @@ -311,12 +311,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) { return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err) } c.UserSID = u.User.Sid.String() - if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil { - c.UserName = name - if dom != "" { - c.UserName = dom + `\` + name - } - } + // Bounded and cached (sidnames.go): a directory that does not answer + // leaves NameTimedOut set, and pipeApprove refuses. + pipeClientName(&c, winAccounts, pipeSIDLane) var sess, n uint32 if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil { return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err) diff --git a/client-go/auditlog.go b/client-go/auditlog.go new file mode 100644 index 0000000..faa83bd --- /dev/null +++ b/client-go/auditlog.go @@ -0,0 +1,179 @@ +package main + +// Audit rows to the system log, as well as to their files. +// +// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a +// pairing's audit.jsonl for terminals, root commands, store ops and op runs) +// lives here, on this machine. The host can fetch them over the tunnel but +// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host +// as the only central view of a fleet, and the host is the agent the audit is +// about. So each row is also written to the machine's own system log, where +// whatever already collects that log (journald or syslog forwarding, a log +// shipper, a SIEM) takes it to a store the host cannot write: +// +// Linux, macOS syslog, facility auth, tag privatae-bastion-audit +// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit) +// Windows the Application event log, source PrivataeBastionAudit, event id 1 +// +// The system-log line is the file's row with two fields added in front of it: +// `audit`, which file the row went to, and `pairing`, the pairing it belongs to +// ("" for a box-level file). The file stays the record: a system log that is +// missing or refuses a write costs that copy and nothing else, and is never a +// reason to refuse or slow the op being audited. +// +// A root command's text is the one thing the file has that the system-log line +// does not: it is whatever the host typed, a password on a command line +// included, and the system log is readable by more accounts than the 0600 file +// and forwarded off the machine by design. The line keeps command_sha256 and +// command_bytes, so a collector can still match it to the file and the +// transcript and see if either was changed. +// +// The write itself never runs on the audited op's goroutine. The syslog socket +// is a blocking write: a journald or syslog daemon that is stuck or slow would +// otherwise hold every audited op, and every other audit writer behind the +// lock, for as long as it stays stuck. Lines go into a bounded queue that one +// goroutine drains; a full queue drops the line and counts it, and the next +// line queued after the gap carries the count (auditDroppedField). + +import ( + "bytes" + "encoding/json" + "path/filepath" + "sync" + "sync/atomic" + "time" +) + +const ( + auditSyslogTag = "privatae-bastion-audit" + auditEventSource = "PrivataeBastionAudit" + auditEventID = 1 + auditFilePairing = "pairing_audit.jsonl" + auditFilePairingD = "audit.jsonl" +) + +// auditSink takes one line for the system log. A variable so a test can watch +// the lines; the default queues it for the platform's writer and never blocks. +var auditSink = auditEnqueue + +const ( + // auditQueueMax lines wait for the system log; beyond that they are + // dropped and counted. A burst of audited ops far larger than any lane + // produces fits; a system log stuck for good holds no more than this. + auditQueueMax = 4096 + // auditDroppedField is added to the first line queued after a drop: how + // many lines the system log lost just before it, so the gap shows where. + auditDroppedField = "system_log_dropped" + // auditFlushOnExit bounds how long a finished command waits for its rows + // to reach a slow system log. + auditFlushOnExit = 2 * time.Second +) + +// auditQueue is the bounded queue in front of a system-log writer, drained by +// one goroutine so lines keep their order. +type auditQueue struct { + ch chan []byte + write func([]byte) + once sync.Once + dropped atomic.Int64 + pending atomic.Int64 // queued or being written +} + +func newAuditQueue(n int, write func([]byte)) *auditQueue { + return &auditQueue{ch: make(chan []byte, n), write: write} +} + +// The box's one queue, in front of systemAuditWrite (auditlog_*.go). +var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) }) + +func auditEnqueue(line []byte) { auditQ.enqueue(line) } + +// auditFlush waits, at most d, for queued lines to reach the system log. A +// command that audits and then exits (pair, claim-anchor, set-mode) calls it +// on the way out (main.go), so its rows are not lost with the process; a +// stuck system log costs that wait and nothing more. +func auditFlush(d time.Duration) { auditQ.flush(d) } + +// enqueue hands one line to the drain goroutine, or drops it and counts it +// when the queue is full. It never waits on the writer. +func (q *auditQueue) enqueue(line []byte) { + q.once.Do(func() { go q.drain() }) + n := q.dropped.Swap(0) + if n > 0 { + line = auditWithDropped(line, n) + } + q.pending.Add(1) + select { + case q.ch <- line: + default: + q.pending.Add(-1) + q.dropped.Add(n + 1) + } +} + +func (q *auditQueue) drain() { + for line := range q.ch { + q.write(line) + q.pending.Add(-1) + } +} + +func (q *auditQueue) flush(d time.Duration) { + deadline := time.Now().Add(d) + for q.pending.Load() > 0 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } +} + +// auditWithDropped adds the drop count to a line (every line here is the +// JSON object auditToSystemLog made). +func auditWithDropped(line []byte, n int64) []byte { + fields := map[string]any{} + dec := json.NewDecoder(bytes.NewReader(line)) + dec.UseNumber() + if err := dec.Decode(&fields); err != nil { + return line + } + fields[auditDroppedField] = n + out, err := json.Marshal(fields) + if err != nil { + return line + } + return out +} + +// auditToSystemLog sends one audit row, as it was written to its file, to the +// system log, with the file's name and the pairing in front. +func auditToSystemLog(file, pairing string, rec []byte) { + fields := map[string]any{} + dec := json.NewDecoder(bytes.NewReader(rec)) + dec.UseNumber() // numbers go out exactly as the file has them + if err := dec.Decode(&fields); err != nil { + return + } + if cmd, ok := fields["command"].(string); ok { + // The text stays in the file; see the top of this file. + delete(fields, "command") + if _, ok := fields["command_bytes"]; !ok { + fields["command_bytes"] = len(cmd) + } + } + fields["audit"] = file + fields["pairing"] = pairing + line, err := json.Marshal(fields) + if err != nil { + return + } + auditSink(line) +} + +// auditPairingOfDir names the pairing of a per-pairing audit file from its +// directory: the last path element, which is the pairing's host name +// (Config.dcnDir: dcn/), except for a legacy pairing that kept the +// old shared directory, where it is that directory's name. +func auditPairingOfDir(dir string) string { + if dir == "" { + return "" + } + return filepath.Base(filepath.Clean(dir)) +} diff --git a/client-go/auditlog_notwindows.go b/client-go/auditlog_notwindows.go new file mode 100644 index 0000000..4fadee6 --- /dev/null +++ b/client-go/auditlog_notwindows.go @@ -0,0 +1,40 @@ +//go:build !windows + +package main + +import ( + "log/syslog" + "sync" +) + +var ( + auditSyslogMu sync.Mutex + auditSyslog *syslog.Writer +) + +// systemAuditWrite sends one line to syslog (journald on a systemd machine), +// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on +// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped +// after a failed write, so the next row tries again; a machine with no syslog +// socket costs the copy, never the row in the file. +func systemAuditWrite(line []byte) { + auditSyslogMu.Lock() + defer auditSyslogMu.Unlock() + if auditSyslog == nil { + w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag) + if err != nil { + return + } + auditSyslog = w + } + if err := auditSyslog.Info(string(line)); err != nil { + _ = auditSyslog.Close() + auditSyslog = nil + } +} + +// registerAuditEventSource has nothing to register outside Windows: syslog +// takes any tag. +func registerAuditEventSource() error { return nil } + +func removeAuditEventSource() {} diff --git a/client-go/auditlog_queue_test.go b/client-go/auditlog_queue_test.go new file mode 100644 index 0000000..ca27259 --- /dev/null +++ b/client-go/auditlog_queue_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +// The sink the binary ships with, read before auditlog_test.go's init swaps +// it out (package variables are set before any init runs). +var auditSinkShipped = auditSink + +// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the +// platform's writer called on the audited op's own goroutine. +func TestAuditSinkIsTheQueue(t *testing.T) { + if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() { + t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck") + } +} + +// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns, +// far more rows than the queue holds are all taken at once; the ones past the +// bound are dropped and counted, and the count rides on the next line that +// gets into the queue. +func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) { + release := make(chan struct{}) + var mu sync.Mutex + var got []string + q := newAuditQueue(8, func(l []byte) { + <-release + mu.Lock() + got = append(got, string(l)) + mu.Unlock() + }) + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + q.enqueue([]byte(`{"n":1}`)) + } + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("audit rows waited on a stuck system log") + } + close(release) + q.flush(5 * time.Second) + // Nothing lost without a count: every one of the 1001 rows is either a + // line written or counted on a line written after the gap. + q.enqueue([]byte(`{"n":2}`)) + q.flush(5 * time.Second) + mu.Lock() + defer mu.Unlock() + var counted float64 + for _, l := range got { + m := map[string]any{} + if err := json.Unmarshal([]byte(l), &m); err != nil { + t.Fatal(err) + } + if n, ok := m[auditDroppedField].(float64); ok { + counted += n + } + } + if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 { + t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted) + } +} + +// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in +// the 0600 file; the system-log line carries its digest and length. +func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) { + var line string + prev := auditSink + auditSink = func(l []byte) { line = string(l) } + t.Cleanup(func() { auditSink = prev }) + auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`)) + m := map[string]any{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatal(err) + } + if strings.Contains(line, "hunter2") || m["command"] != nil { + t.Fatalf("the command text reached the system log: %s", line) + } + if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) { + t.Fatalf("the line must keep the digest and the length: %s", line) + } + auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`)) + if !strings.Contains(line, `"command_bytes":70000`) { + t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line) + } +} diff --git a/client-go/auditlog_syslog_test.go b/client-go/auditlog_syslog_test.go new file mode 100644 index 0000000..afcdd26 --- /dev/null +++ b/client-go/auditlog_syslog_test.go @@ -0,0 +1,69 @@ +//go:build !windows + +package main + +import ( + "log/syslog" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a +// datagram socket nobody reads blocks once the socket's queue is full, as +// /dev/log does while journald is stuck. Audit rows behind the queue all +// return at once; the same writer called inline (as 0.54.0 first did) does not. +func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) { + dir, err := os.MkdirTemp("", "al") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + sock := filepath.Join(dir, "s") + l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"}) + if err != nil { + t.Fatal(err) + } + // Closing the reading end is what lets the stuck writes below return + // (refused) so their goroutines end; the writer itself is never closed, + // since its Close would wait behind a write that is stuck. + t.Cleanup(func() { l.Close() }) + w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag) + if err != nil { + t.Fatal(err) + } + write := func(line []byte) { _ = w.Info(string(line)) } + + // The inline writer does block: without this the test proves nothing. + inline := make(chan struct{}) + go func() { + for i := 0; i < 5000; i++ { + write([]byte(`{"op":"x"}`)) + } + close(inline) + }() + select { + case <-inline: + t.Skip("this kernel never blocked a datagram writer; nothing to bound") + case <-time.After(2 * time.Second): + } + + q := newAuditQueue(64, write) + done := make(chan struct{}) + go func() { + for i := 0; i < 5000; i++ { + q.enqueue([]byte(`{"op":"y"}`)) + } + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("audit rows waited on a stuck syslog socket") + } + if q.dropped.Load() == 0 { + t.Fatal("rows past the bound must be counted as dropped") + } +} diff --git a/client-go/auditlog_test.go b/client-go/auditlog_test.go new file mode 100644 index 0000000..39a8d92 --- /dev/null +++ b/client-go/auditlog_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// The test binary never writes to the real system log: the suite's audit +// rows would otherwise land in the journal of whatever machine runs it. +func init() { auditSink = func([]byte) {} } + +// auditSinkCapture swaps the system-log sink for one that keeps the lines. +func auditSinkCapture(t *testing.T) func() []map[string]any { + t.Helper() + var mu sync.Mutex + var lines [][]byte + prev := auditSink + auditSink = func(line []byte) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, append([]byte(nil), line...)) + } + t.Cleanup(func() { auditSink = prev }) + return func() []map[string]any { + mu.Lock() + defer mu.Unlock() + var out []map[string]any + for _, l := range lines { + m := map[string]any{} + if err := json.Unmarshal(l, &m); err != nil { + t.Fatalf("system-log line is not JSON: %s", l) + } + out = append(out, m) + } + return out + } +} + +func auditFileRows(t *testing.T, path string) []map[string]any { + t.Helper() + fh, err := os.Open(path) + if err != nil { + t.Fatalf("audit file %s: %v", path, err) + } + defer fh.Close() + var out []map[string]any + sc := bufio.NewScanner(fh) + for sc.Scan() { + m := map[string]any{} + if err := json.Unmarshal(sc.Bytes(), &m); err != nil { + t.Fatalf("audit row is not JSON: %s", sc.Text()) + } + out = append(out, m) + } + return out +} + +// auditSameRow: the system-log line is the file's row, every field equal, +// with `audit` and `pairing` added. +func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) { + t.Helper() + if logged["audit"] != wantFile || logged["pairing"] != wantPairing { + t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing) + } + for k, v := range file { + if lv, ok := logged[k]; !ok || !jsonEq(lv, v) { + t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k]) + } + } + if len(logged) != len(file)+2 { + t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file)) + } +} + +func jsonEq(a, b any) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row +// it writes to its file to the system log as well, unchanged, naming the file +// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes +// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs, +// terminals and root commands. +func TestAuditRowsReachTheSystemLog(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + got := auditSinkCapture(t) + cfgDir, err := configDir() + if err != nil { + t.Fatal(err) + } + pairDir := filepath.Join(t.TempDir(), "acme.host.example") + if err := os.MkdirAll(pairDir, 0o700); err != nil { + t.Fatal(err) + } + + mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "") + pairingAudit("pairing_add", "other.host.example", "ok") + dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed") + sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened") + rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0}) + + lines := got() + if len(lines) != 5 { + t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines) + } + mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile)) + pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing)) + per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)) + if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 { + t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per)) + } + auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example") + auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "") + auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example") + auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example") + auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example") +} + +// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing +// costs the copy only; the row is still written to its file. +func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) { + prev := auditSink + auditSink = func([]byte) {} + t.Cleanup(func() { auditSink = prev }) + pairDir := filepath.Join(t.TempDir(), "acme.host.example") + if err := os.MkdirAll(pairDir, 0o700); err != nil { + t.Fatal(err) + } + dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed") + if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 { + t.Fatalf("file rows = %d, want 1", len(rows)) + } +} + +// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as +// the digits the file has, not as a float. +func TestAuditSystemLogKeepsNumbersExact(t *testing.T) { + var line string + prev := auditSink + auditSink = func(l []byte) { line = string(l) } + t.Cleanup(func() { auditSink = prev }) + auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`)) + if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) { + t.Fatalf("system-log line %s does not carry the id exactly", line) + } +} diff --git a/client-go/auditlog_windows.go b/client-go/auditlog_windows.go new file mode 100644 index 0000000..a74fb87 --- /dev/null +++ b/client-go/auditlog_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package main + +import ( + "strings" + "sync" + + "golang.org/x/sys/windows/svc/eventlog" +) + +var ( + auditEventMu sync.Mutex + auditEventLog *eventlog.Log +) + +// systemAuditWrite sends one line to the Application event log under the +// source PrivataeBastionAudit, event id 1, at information level. The source +// is registered when the system service is installed +// (registerAuditEventSource); before that, or if the log refuses, the copy is +// lost and the row in the file is not. Called only from auditQ's drain +// (auditlog.go): the event log service can be slow to answer. +func systemAuditWrite(line []byte) { + auditEventMu.Lock() + defer auditEventMu.Unlock() + if auditEventLog == nil { + l, err := eventlog.Open(auditEventSource) + if err != nil { + return + } + auditEventLog = l + } + if err := auditEventLog.Info(auditEventID, string(line)); err != nil { + _ = auditEventLog.Close() + auditEventLog = nil + } +} + +// registerAuditEventSource registers PrivataeBastionAudit as an Application +// event log source, so the rows show with their own source name. Already +// registered is success. +func registerAuditEventSource() error { + err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error) + if err != nil && strings.Contains(err.Error(), "already exists") { + return nil + } + return err +} + +// removeAuditEventSource unregisters the source when the service is removed. +// Events already written stay in the log. +func removeAuditEventSource() { + _ = eventlog.Remove(auditEventSource) +} diff --git a/client-go/claims_static_test.go b/client-go/claims_static_test.go index 829f09a..6257b42 100644 --- a/client-go/claims_static_test.go +++ b/client-go/claims_static_test.go @@ -11,6 +11,7 @@ package main // runs) is driven in claims_unix_test.go and beside each lane's own tests. import ( + "fmt" "go/ast" "go/parser" "go/token" @@ -49,23 +50,7 @@ func parseSources(t *testing.T) *srcPkg { if err != nil { t.Fatalf("parse %s: %v", n, err) } - p.files[n] = f - for _, d := range f.Decls { - fd, ok := d.(*ast.FuncDecl) - if !ok { - continue - } - key := funcKey(fd) - // The same name in two platform files (processIsRoot, …) is kept - // once per file so neither body is lost to the other. - if _, dup := p.funcs[key]; dup { - key = key + "@" + n - } - p.funcs[key] = fd - if fd.Recv != nil { - p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key) - } - } + p.addFile(n, f) } if len(p.files) < 50 { t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files)) @@ -73,6 +58,46 @@ func parseSources(t *testing.T) *srcPkg { return p } +// addFile registers one parsed file and its functions. A test that proves a +// check can fail adds a synthetic file through here, so it is indexed exactly +// as a real one would be. +func (p *srcPkg) addFile(n string, f *ast.File) { + p.files[n] = f + for _, d := range f.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + key := funcKey(fd) + // The same name in two platform files (processIsRoot, …) is kept + // once per file so neither body is lost to the other. + if _, dup := p.funcs[key]; dup { + key = key + "@" + n + } + p.funcs[key] = fd + if fd.Recv != nil { + p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key) + } + } +} + +// funcKeysIn is the p.funcs key of every function declared in file n. +func (p *srcPkg) funcKeysIn(n string) []string { + var out []string + for _, d := range p.files[n].Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + key := funcKey(fd) + if p.funcs[key] != fd { + key = key + "@" + n + } + out = append(out, key) + } + return out +} + // funcKey is "name" for a function and "Type.name" for a method. func funcKey(fd *ast.FuncDecl) string { if fd.Recv == nil || len(fd.Recv.List) == 0 { @@ -488,10 +513,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) { } } -// No MCP endpoint. Every HTTP route the binary registers, on any listener, -// is in this table; none of them, and no op name on the management lane, -// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC, -// the protocol MCP is carried on; and no dependency is an MCP library. +// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route +// the binary registers, on any listener, is in this table; none of them, and +// no op name on the management lane, names MCP; no string in the code is a +// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is +// carried on; and no dependency is an MCP library. +// +// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any +// string containing "/mcp" at all. The connection-config reader +// (mcpconfig.go) has to name the files agent clients declare their tool +// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers +// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the +// protocol instead of the word (mcpReaderViolations): +// +// - no literal anywhere is a URL or route with an mcp path segment; +// - the config basenames and keys may appear in mcpconfig.go and nowhere +// else, so a second file that starts reading (or writing) them is a +// change a reviewer sees here; +// - nothing declared in a config-reader file (mcpconfig*.go) imports, or +// can reach through this package's call graph, net.Dial or net.Listen, +// net/http, os/exec, TLS dialling or the websocket client. The reader is +// handed its process list by agents.go; it starts and dials nothing. +// +// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can +// fail. func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) { p := parseSources(t) // A route served by more than one surface names each file, "a.go|b.go": @@ -547,14 +592,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) { } } + for _, v := range mcpReaderViolations(p) { + t.Error(v) + } for name, f := range p.files { - for _, s := range stringLits(f) { - l := strings.ToLower(s) - if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") || - strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") { - t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s) - } - } for _, imp := range f.Imports { if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") { t.Errorf("%s imports %s", name, imp.Path.Value) @@ -575,6 +616,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) { } } +// mcpConfigNames are the file basenames and keys agent clients declare tool +// servers under. Only mcpconfig.go may name them. settings.json and +// config.toml are left out on purpose: many programs use those names (the +// coder lane reads the NVIDIA runtime's config.toml), so they say nothing +// about tool servers; the keys beside them do. +var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json", + "mcpservers", "context_servers", "mcp_servers", + // The clients' own directories, which name a settings.json or + // config.toml as surely as the basename would. + ".cursor", ".codex", ".zed", ".codeium", ".vscode"} + +// mcpURLOrRoute reports whether a string literal is a URL or route that +// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x, +// /mcp-proxy) except a client's config FILE name: /mcp.json and +// /mcp_config.json. +func mcpURLOrRoute(l string) bool { + l = strings.ToLower(l) + if i := strings.Index(l, "://"); i >= 0 { + rest := l[i+3:] + if j := strings.IndexByte(rest, '/'); j >= 0 { + return strings.Contains(rest[j:], "/mcp") + } + return false + } + for i := 0; ; { + j := strings.Index(l[i:], "/mcp") + if j < 0 { + return false + } + rest := l[i+j+len("/mcp"):] + if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") { + return true + } + i += j + len("/mcp") + } +} + +// mcpReaderSink: a package-level function or type that opens a connection, +// resolves a name, or starts a process — named as a call or as a value. +func mcpReaderSink(recv, sel string) bool { + switch { + case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") || + strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"): + return true + case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"): + return true + case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc": + return true + case recv == "tls" && strings.HasPrefix(sel, "Dial"): + return true + case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") && + (sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") || + strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"): + return true + case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" || + sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") || + sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")): + return true + case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" || + sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"): + // A DLL loaded by name reaches any function in it, past every check here. + return true + case recv == "registry" && sel == "OpenRemoteKey": + return true + } + return false +} + +// mcpReaderMethodSink: a METHOD call that connects, resolves or starts, +// whatever the receiver is called — d.Dial on a net.Dialer variable, +// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on +// purpose: it only applies to code the reader can reach. +var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true, + "DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true, + "LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true, + "PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true, + "CombinedOutput": true, "Connect": true} + +// mcpReaderAllowedImports is every package a config-reader file may import: +// parsing, strings, and opening files. Anything else is a change a reviewer +// has to see here first. x/sys/windows and its registry package are the +// Windows open (CreateFile and the handle's own type, attributes and owner) +// and the profile list it checks owners against; each can also start a +// process, open a socket, load a DLL or open a remote registry, and those +// calls are sinks in mcpReaderSink like their unix equivalents. +var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true, + "io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true, + "runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true, + "unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true, + "golang.org/x/sys/windows/registry": true} + +// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial). +// The http, exec and websocket packages also export constants and types +// (http.StatusOK, websocket.BinaryMessage), which name no connection; only +// their functions count here. +func mcpValueSink(recv, sel string) bool { + switch recv { + case "http", "exec", "websocket", "rpc", "jsonrpc": + switch sel { + case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext", + "DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient": + return true + } + return false + } + return mcpReaderSink(recv, sel) +} + +// mcpSinksIn lists the network and process calls inside one declaration. +func mcpSinksIn(n ast.Node) []string { + var out []string + called := map[*ast.SelectorExpr]bool{} + ast.Inspect(n, func(x ast.Node) bool { + if c, ok := x.(*ast.CallExpr); ok { + if sel, ok := c.Fun.(*ast.SelectorExpr); ok { + called[sel] = true + } + } + return true + }) + ast.Inspect(n, func(x ast.Node) bool { + switch v := x.(type) { + case *ast.SelectorExpr: + recv := exprText(v.X) + if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) { + out = append(out, recv+"."+v.Sel.Name) + } + case *ast.CallExpr: + if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] { + out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()") + } + } + return true + }) + return out +} + +// mcpReaderViolations is every way the package breaks the guarantee above. +func mcpReaderViolations(p *srcPkg) []string { + var out []string + names := make([]string, 0, len(p.files)) + for n := range p.files { + names = append(names, n) + } + sort.Strings(names) + var readerFiles []string + for _, name := range names { + if strings.HasPrefix(name, "mcpconfig") { + readerFiles = append(readerFiles, name) + } + for _, s := range stringLits(p.files[name]) { + l := strings.ToLower(s) + if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") { + out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)) + } + // The protocol's namespace. The reader may name the npm scope + // its reference servers are published under, to recognise one + // running; nothing else may name it at all. + if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") { + out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s)) + } + if name == "mcpconfig.go" { + continue + } + for _, b := range mcpConfigNames { + if strings.Contains(l, b) { + out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b)) + } + } + } + } + if len(readerFiles) == 0 { + return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it") + } + var roots []string + for _, name := range readerFiles { + for _, imp := range p.files[name].Imports { + path, _ := strconv.Unquote(imp.Path.Value) + if !mcpReaderAllowedImports[path] { + out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path)) + } + } + roots = append(roots, p.funcKeysIn(name)...) + } + // Package-level variables are nodes too ("var:name"): a function held in + // one (var f = net.Dial, var g = func() { ... }) is reached by whoever + // reads the variable, and a reader file's own variables are roots, since + // their initialisers run with the package. + g := p.callGraph() + vars := map[string][]ast.Expr{} + for _, name := range names { + for _, d := range p.files[name].Decls { + gd, ok := d.(*ast.GenDecl) + if !ok || gd.Tok != token.VAR { + continue + } + for _, sp := range gd.Specs { + vs := sp.(*ast.ValueSpec) + for _, id := range vs.Names { + key := "var:" + id.Name + vars[key] = append(vars[key], vs.Values...) + if strings.HasPrefix(name, "mcpconfig") { + roots = append(roots, key) + } + } + } + } + } + refs := func(n ast.Node) []string { + var out []string + ast.Inspect(n, func(x ast.Node) bool { + if id, ok := x.(*ast.Ident); ok { + if _, isVar := vars["var:"+id.Name]; isVar { + out = append(out, "var:"+id.Name) + } + if _, isFn := p.funcs[id.Name]; isFn { + out = append(out, id.Name) + } + } + return true + }) + return out + } + sinks := map[string][]string{} + for key, fd := range p.funcs { + g[key] = append(g[key], refs(fd)...) + if s := mcpSinksIn(fd); len(s) > 0 { + sinks[key] = s + } + } + for key, vals := range vars { + for _, v := range vals { + g[key] = append(g[key], refs(v)...) + sinks[key] = append(sinks[key], mcpSinksIn(v)...) + } + } + seen := reach(g, roots, nil) + for fn, path := range seen { + if what := sinks[fn]; len(what) > 0 { + out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s", + fn, strings.Join(what, ", "), path)) + } + } + sort.Strings(out) + return out +} + +// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a +// network package, reaches a dialler through another function, or a second +// file that names a client's config file or an /mcp URL: each one is added +// as a synthetic source and must be reported. The real tree must report +// nothing, and the scan must have found sinks and roots to compare, so a +// broken extraction cannot pass by finding nothing. +func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) { + clean := parseSources(t) + if v := mcpReaderViolations(clean); len(v) != 0 { + t.Fatalf("the real tree must be clean: %v", v) + } + if len(clean.funcKeysIn("mcpconfig.go")) < 10 { + t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken") + } + add := func(p *srcPkg, name, src string) { + t.Helper() + f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + p.addFile(name, f) + } + cases := []struct { + name, want string + files map[string]string + }{ + {"the reader execs", "can reach mcpSneakRun", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}}, + {"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n", + "sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}}, + {"the reader imports net/http", "imports net/http", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}}, + {"another file names a config file", "only mcpconfig.go reads", map[string]string{ + "sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}}, + {"another file names the servers key", "only mcpconfig.go reads", map[string]string{ + "sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}}, + {"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{ + "sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}}, + {"a route", "addresses an MCP endpoint", map[string]string{ + "sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}}, + {"a route with a suffix", "addresses an MCP endpoint", map[string]string{ + "sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}}, + {"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}}, + {"the reader dials through a function value", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}}, + {"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}}, + {"the reader opens a raw socket", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}}, + {"the reader resolves a name", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}}, + {"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}}, + {"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n", + "sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}}, + {"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{ + "sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}}, + {"the Windows reader starts a process", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}}, + {"the Windows reader opens a socket", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}}, + {"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}}, + {"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{ + "mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}}, + } + for _, c := range cases { + p := parseSources(t) + for n, src := range c.files { + add(p, n, src) + } + found := false + for _, v := range mcpReaderViolations(p) { + if strings.Contains(v, c.want) { + found = true + } + } + if !found { + t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p)) + } + } + // A config file name is not a URL path, and a file in the reader may + // name one. + for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} { + if mcpURLOrRoute(ok) { + t.Errorf("%q read as an MCP URL or route", ok) + } + } +} + // callGraph maps each function to what it calls or refers to, by name. // A method call through a value is resolved to every method of that name in // the package, so the graph over-approximates: a path it cannot rule out is @@ -582,6 +963,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) { // followed; none of the handlers below does that. func (p *srcPkg) callGraph() map[string][]string { g := map[string][]string{} + // The per-file duplicates ("name@file"), by name, built once: looking + // them up by prefix for every identifier made this graph cost seconds. + dups := map[string][]string{} + for k := range p.funcs { + if i := strings.IndexByte(k, '@'); i >= 0 { + dups[k[:i]] = append(dups[k[:i]], k) + } + } for key, fd := range p.funcs { if fd.Body == nil { continue @@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string { if _, ok := p.funcs[v.Name]; ok { out = append(out, v.Name) } - for k := range p.funcs { - if strings.HasPrefix(k, v.Name+"@") { - out = append(out, k) - } - } + out = append(out, dups[v.Name]...) case *ast.CallExpr: if sel, ok := v.Fun.(*ast.SelectorExpr); ok { out = append(out, p.methods[sel.Sel.Name]...) diff --git a/client-go/contextsock_other.go b/client-go/contextsock_other.go new file mode 100644 index 0000000..c100694 --- /dev/null +++ b/client-go/contextsock_other.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin && !windows + +package main + +// The release builds linux, darwin and windows (deploy/build.sh). Any other +// unix (freebsd, openbsd) still builds, with the local context socket unable +// to tell who is on the other end: contextPeerUser then fails, and +// contextSockServe answers every connection with that reason and forwards +// nothing. An ask that cannot be attributed is refused, never let through. +func contextPeerUID(uintptr) (uint32, error) { return 0, contextSockUnsupported } diff --git a/client-go/dcn.go b/client-go/dcn.go index fb46087..7276b23 100644 --- a/client-go/dcn.go +++ b/client-go/dcn.go @@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) { "ts": time.Now().UTC().Format(time.RFC3339), "op": op, "store_id": storeID, "path": path, "decision": decision, }) + auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { diff --git a/client-go/freeagent_drop_windows.go b/client-go/freeagent_drop_windows.go index de430e5..6dd20ac 100644 --- a/client-go/freeagent_drop_windows.go +++ b/client-go/freeagent_drop_windows.go @@ -98,15 +98,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) { if err != nil || sid == nil { return nil, fmt.Errorf("cannot read the folder's owner: %v", err) } - name, domain, typ, err := sid.LookupAccount("") + // Bounded and cached (sidnames.go); no answer in time refuses the start. + acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane) if err != nil { - return nil, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid.String()) + return nil, err } - full := name - if domain != "" { - full = domain + `\` + name - } - if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" { + name, full := acct.Account, sidAccountLabel(acct) + if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" { return nil, errors.New(why) } home := faWinProfileDir(sid.String()) diff --git a/client-go/freeagent_drop_winrules.go b/client-go/freeagent_drop_winrules.go index d75a42a..0fba0c7 100644 --- a/client-go/freeagent_drop_winrules.go +++ b/client-go/freeagent_drop_winrules.go @@ -5,12 +5,38 @@ package main // that gather their inputs are in the tagged file. import ( + "fmt" "sort" "strconv" "strings" + "time" "unicode/utf16" ) +// faDropSIDLane bounds the lookup of the granted folder's owner (sidnames.go). +// The start runs on the store's own goroutine and nothing waits on it, so the +// budget is generous enough for a slow directory that does answer. It is a +// bound all the same: the store is marked as ensuring for as long as the start +// runs, and every reconcile skips it while it is, so a lookup that never +// returned would hold the store as "installing" for good. Timed out, the store +// is failed with the reason and the reconcile tries again +// (faReconcileEvery), by when a late answer is in the cache. +var faDropSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 10 * time.Second} + +// faWinDropOwnerAccount is the folder owner's account from the directory. It +// fails closed: no account, or no answer in time, and no agent is started. +func faWinDropOwnerAccount(sid string, dir *sidDirectory, lane sidLane) (sidAccount, error) { + acct, outcome := dir.resolve(sid, lane) + switch outcome { + case sidTimedOut: + return sidAccount{}, fmt.Errorf("refusing to run the agent for now: this machine's account directory did not answer "+ + "for the granted folder's owner (%s) within %v; it is tried again at the next check", sid, lane.budget) + case sidUnmapped: + return sidAccount{}, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid) + } + return acct, nil +} + // winSidTypeUser is SidTypeUser: the SID names a person's account rather than // a group, an alias or a well-known identity. const winSidTypeUser = 1 diff --git a/client-go/goosbuild_test.go b/client-go/goosbuild_test.go new file mode 100644 index 0000000..d1340e4 --- /dev/null +++ b/client-go/goosbuild_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "go/ast" + "go/build" + "go/parser" + "go/token" + "os" + "sort" + "strings" + "testing" +) + +// EVERY PLATFORM FUNCTION EXISTS ON EVERY TARGET THAT BUILDS THIS PACKAGE. +// +// A function with one file per platform (contextPeerUID in contextsock_linux.go +// and contextsock_darwin.go) and a caller in a file every unix builds +// (contextsock_unix.go) leaves any other unix with an undefined name, and +// nothing in CI cross-builds one: `GOOS=freebsd go build` failed that way. +// The release targets are linux, darwin and windows (deploy/build.sh); +// freebsd and openbsd are not released, but they build, with what has no +// implementation there refusing (contextsock_other.go and the other +// *_other.go files). +// +// This asks the toolchain which files each target compiles (go/build's +// MatchFile) and checks that every package function called from those files +// is declared in them. It is not a type check, and it does not compile; it +// catches the missing-platform-file mistake, which is the one that happened, +// on the platform the tests already run on. +func TestEveryCalledPlatformFunctionIsDeclaredOnEveryTarget(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + type parsed struct { + name string + file *ast.File + } + var files []parsed + declaredAnywhere := map[string]bool{} + fset := token.NewFileSet() + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, n, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("%s: %v", n, err) + } + files = append(files, parsed{n, f}) + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil { + declaredAnywhere[fd.Name.Name] = true + } + } + } + if len(files) < 50 { + t.Fatalf("parsed only %d files; is the test running in the package directory?", len(files)) + } + for _, goos := range []string{"linux", "darwin", "windows", "freebsd", "openbsd"} { + ctx := build.Default + ctx.GOOS = goos + ctx.CgoEnabled = false + declared := map[string]bool{} + var matched []parsed + for _, p := range files { + ok, err := ctx.MatchFile(".", p.name) + if err != nil { + t.Fatalf("%s: %v", p.name, err) + } + if !ok { + continue + } + matched = append(matched, p) + for _, d := range p.file.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil { + declared[fd.Name.Name] = true + } + } + } + missing := map[string]string{} + for _, p := range matched { + ast.Inspect(p.file, func(x ast.Node) bool { + c, ok := x.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := c.Fun.(*ast.Ident); ok && declaredAnywhere[id.Name] && !declared[id.Name] { + missing[id.Name] = p.name + } + return true + }) + } + var out []string + for fn, where := range missing { + out = append(out, fn+" (called in "+where+")") + } + sort.Strings(out) + if len(out) > 0 { + t.Errorf("GOOS=%s: called but not declared for this target: %s", goos, strings.Join(out, ", ")) + } + } +} diff --git a/client-go/install_sh_test.go b/client-go/install_sh_test.go new file mode 100644 index 0000000..a1f3b46 --- /dev/null +++ b/client-go/install_sh_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" +) + +// installShFunc returns one shell function's text from deploy/install.sh. +func installShFunc(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh")) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}\n`).Find(raw) + if m == nil { + t.Fatalf("install.sh defines no %s()", name) + } + return string(m) +} + +// TestInstallShKeepsAPairedLocalMachineLocal: a root reinstall defaults to +// networked, except on a machine already paired in local mode, which keeps +// its mode (paired_local). Switched, its pairing would sit on an unanchored +// networked machine, which refuses every op until claim-anchor. +func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) { + fn := installShFunc(t, "paired_local") + run := func(dirs ...string) bool { + t.Helper() + args := append([]string{"-c", fn + `paired_local "$@"`, "sh"}, dirs...) + err := exec.Command("sh", args...).Run() + if _, ok := err.(*exec.ExitError); err != nil && !ok { + t.Fatal(err) + } + return err == nil + } + mk := func(t *testing.T, files map[string]string) string { + t.Helper() + d := t.TempDir() + for name, body := range files { + p := filepath.Join(d, name) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return d + } + missing := filepath.Join(t.TempDir(), "none") + + if !run(mk(t, map[string]string{"pairings/a.host.example.json": "{}"}), missing) { + t.Fatal("a paired machine with no mode file is local and must stay local") + } + if !run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": `{"mode":"local"}`})) { + t.Fatal("a paired machine in local mode must stay local") + } + if !run(mk(t, map[string]string{"config.json": "{}"})) { + t.Fatal("a pre-0.10 single pairing (config.json) is a pairing") + } + if !run(missing, mk(t, map[string]string{"pairings/a.json": "{}"})) { + t.Fatal("a pairing in the second folder counts") + } + if run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": "{\n \"mode\": \"networked\"\n}"})) { + t.Fatal("a networked machine is not kept local") + } + if run(mk(t, map[string]string{"pairings/.keep": ""}), missing) { + t.Fatal("a machine with no pairing gets the networked default") + } +} diff --git a/client-go/main.go b/client-go/main.go index 0067e8f..546f24c 100644 --- a/client-go/main.go +++ b/client-go/main.go @@ -229,6 +229,7 @@ func main() { // Started by the Windows Service Control Manager: the service runs `run` // itself and never reaches the argument parsing below. if maybeRunAsWindowsService() { + auditFlush(auditFlushOnExit) return } if len(os.Args) < 2 { @@ -618,6 +619,9 @@ func main() { usage() os.Exit(2) } + // The command's audit rows reach the system log before the process ends + // (auditlog.go queues them off the op's own path). + auditFlush(auditFlushOnExit) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/client-go/mcpconfig.go b/client-go/mcpconfig.go new file mode 100644 index 0000000..821c24f --- /dev/null +++ b/client-go/mcpconfig.go @@ -0,0 +1,1732 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" +) + +// THE TOOL SERVERS THIS MACHINE'S AGENT CLIENTS ARE SET UP TO START. +// +// Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Codex and Zed each +// keep a file that lists the tool servers (Model Context Protocol servers) the +// client may start or connect to. An assessment needs that list: which servers +// a person's agents can reach, how each is launched, whether a secret sits in +// the file in plain text, and whether the server the file names is actually +// running — or a server is running that no file names. +// +// THIS FILE ONLY READS FILES. It opens no socket, starts no process and never +// talks to a server; the bastion does not speak that protocol, and +// TestNoRouteOpOrDependencySpeaksMCP (claims_static_test.go) holds it to that +// by walking the call graph out of every function declared here. The process +// list it correlates against is handed IN by agents.go, which reads it with +// the platform's own enumerator (scanMCPProcesses, agents_.go). +// +// WHAT LEAVES THE MACHINE, AND WHAT NEVER DOES. Per declared server: the +// client, the config scope, the file path, the server's name, transport, +// whether it is a local process or a URL (and the URL's host), the launch +// command truncated the way agent command lines are, the package and whether +// it is pinned, the NAMES of the environment variables it is given, whether a +// value matched a secret's shape, whether a remote server has authentication +// configured, and whether it is running. NEVER a value of an environment +// variable or a header: a value is looked at once, for its shape, and dropped +// in the same function. A command-line argument that looks like a secret is +// replaced by a marker before the command is kept at all. Paths and names +// travel only inside the heartbeat's `observing` gate, beside the agent list. +// +// WHERE EACH CLIENT KEEPS ITS FILE is mcpConfigSources below, with the page +// each path was checked against on 2026-10-20. Paths a page did not state are +// marked there as not confirmed; they are still read, because reading a file +// that is not there costs a stat and finds nothing. + +// mcpServerObs is one declared (or running, undeclared) tool server. +type mcpServerObs struct { + Client string `json:"client,omitempty"` // claude-desktop, claude-code, cursor, vscode, windsurf, codex, zed + Scope string `json:"scope,omitempty"` // user, project, system + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + // Transport: stdio, http, sse, websocket, unknown. + Transport string `json:"transport"` + // Locality: local_process (the client starts it here), loopback, lan, + // remote (a URL host off this network), unknown. + Locality string `json:"locality"` + URLHost string `json:"url_host,omitempty"` + // Command is the launch command and its arguments, joined and truncated + // to 200 characters like an agent's cmdline, with any argument that + // looked like a secret replaced by mcpRedacted first. + Command string `json:"command,omitempty"` + // PackageSource: npx, uvx, pip, docker, binary, unknown. + PackageSource string `json:"package_source,omitempty"` + PackageName string `json:"package_name,omitempty"` + // PackagePinned: yes (an exact version or an image digest), no (a + // range, a tag, or nothing), unknown (a path or binary). + PackagePinned string `json:"package_pinned,omitempty"` + EnvNames []string `json:"env_names,omitempty"` + // SecretInPlaintext: a value in this entry (an env value, a header, an + // argument, the URL) matched a secret's shape. The value is not kept. + SecretInPlaintext bool `json:"secret_in_plaintext"` + // AuthConfigured: yes when the file gives a remote server a credential + // (a header, a bearer-token variable, credentials in the URL); + // not_applicable for a local process; unknown otherwise, because a client + // may negotiate OAuth when it connects and that leaves nothing in a file. + AuthConfigured string `json:"auth_configured"` + // BindClass: what the server listens on. not_applicable for stdio; + // unknown for a URL, which says where the client connects, not what the + // server binds. + BindClass string `json:"bind_class"` + Disabled bool `json:"disabled,omitempty"` + // RunningState: declared_running, declared_not_running, + // running_not_declared; not_local for a URL off this machine; unknown + // when the process list could not be read or a loopback URL cannot be + // matched to a process from a file. + RunningState string `json:"running_state"` + PIDs []int `json:"pids,omitempty"` + + // The correlation key, never sent: a package name or a script path to + // find in a command line, or a program's basename to find as argv[0]. + matchSub string + matchBase string +} + +// mcpConfigScan says how much was looked at, so "no servers" from a box that +// read nothing is visibly different from one that read forty files. +type mcpConfigScan struct { + Supported bool `json:"supported"` + Accounts int `json:"accounts"` + Projects int `json:"projects"` + FilesRead int `json:"files_read"` + Unreadable int `json:"files_unreadable"` + Unparsed int `json:"files_unparsed"` + Truncated bool `json:"truncated,omitempty"` + // ForeignOwner: files skipped because neither the account they were + // read for nor root (SYSTEM or Administrators on Windows) owns them + // (mcpReadFileAs). + ForeignOwner int `json:"files_foreign_owner,omitempty"` + // TimedOut: the file read did not finish within mcpReadTimeout; the + // list is the last complete read, or empty when there was none. + TimedOut bool `json:"timed_out,omitempty"` + // ProcessScan: ok, failed or unsupported — whether running_state could + // be measured at all. + ProcessScan string `json:"process_scan"` +} + +const ( + mcpRedacted = "" + mcpUnknown = "unknown" + mcpMaxFileBytes = 1 << 20 + mcpMaxAccounts = 64 + mcpMaxProjects = 200 + mcpMaxServers = 500 + mcpMaxPIDs = 8 + mcpCommandMax = 200 + mcpNameMax = 128 // a server name, an env name, a package name + mcpHostMax = 253 // a DNS name's own limit + mcpPathMax = 1024 + mcpMaxEnvNames = 32 + mcpMaxNesting = 64 // TOML arrays and inline tables + mcpNoOwnerCheck = "" // the owner a file is read for when no owner check applies + mcpOwnerUnknown = "?" // an owner that could not be read: matches nothing + mcpStateRunning = "declared_running" + mcpStateIdle = "declared_not_running" + mcpStateShadow = "running_not_declared" + mcpStateNotLocal = "not_local" + mcpStateUnknown = "unknown" + mcpLocalProcess = "local_process" + mcpNotApplicable = "not_applicable" + mcpFormatServers = "mcpServers" // a top-level mcpServers object + mcpFormatClaude = "claude-json" // ~/.claude.json: user and per-project servers + mcpFormatVSCode = "vscode" // a top-level servers object + mcpFormatZed = "context_servers" // Zed settings, JSON with comments + mcpFormatCodex = "codex-toml" // [mcp_servers.] tables + mcpScopeUser = "user" + mcpScopeProject = "project" + mcpScopeSystem = "system" + mcpClientDesktop = "claude-desktop" + mcpClientCode = "claude-code" + mcpClientCursor = "cursor" + mcpClientVSCode = "vscode" + mcpClientWindsurf = "windsurf" + mcpClientCodex = "codex" + mcpClientZed = "zed" +) + +// mcpHome is one account's home directory and the per-user application +// configuration directory under it (XDG on Linux, Application Support on +// macOS, AppData\Roaming on Windows). +type mcpHome struct { + Dir string + ConfigDir string +} + +// mcpSource is one file to read. +type mcpSource struct { + Client, Scope, Path, Format string +} + +// mcpConfigSources is every file the seven clients keep for one account, for +// the platform goos. Checked against each vendor's page on 2026-10-20: +// +// claude-desktop https://modelcontextprotocol.io/docs/develop/connect-local-servers +// macOS and Windows stated; the Linux path is NOT CONFIRMED +// (the page says Linux is supported and gives no path). +// claude-code https://code.claude.com/docs/en/mcp — user and local scope in +// ~/.claude.json (local nested under projects.), project +// scope in .mcp.json; https://code.claude.com/docs/en/managed-mcp +// for the system file on each platform. +// cursor https://cursor.com/docs/context/mcp — ~/.cursor/mcp.json and +// .cursor/mcp.json; given only in the ~ form, so the Windows +// location (the user profile) is NOT CONFIRMED. +// vscode https://code.visualstudio.com/docs/copilot/reference/mcp-configuration +// — .vscode/mcp.json, and an mcp.json "in your user profile"; +// the profile folder is from +// https://code.visualstudio.com/docs/configure/profiles. The +// exact user-file path is NOT CONFIRMED on any platform. +// windsurf https://docs.devin.ai/windsurf/plugins/cascade/mcp — the +// config-directory file (devin/mcp_config.json) and the +// editor's own ~/.codeium/windsurf[-next]/mcp_config.json; the +// Windows form of the second is NOT CONFIRMED. +// codex https://learn.chatgpt.com/docs/config-file/config-basic and +// https://learn.chatgpt.com/docs/extend/mcp — ~/.codex/config.toml, +// /etc/codex/config.toml, and .codex/config.toml in a trusted +// project; the Windows path is NOT CONFIRMED. +// zed https://zed.dev/docs/configuring-zed — ~/.config/zed/settings.json +// (macOS and Linux), %APPDATA%\Zed\settings.json, and +// .zed/settings.json in a project; "JSON with support for // +// comments". +func mcpConfigSources(goos string, h mcpHome) []mcpSource { + j := filepath.Join + out := []mcpSource{ + {mcpClientDesktop, mcpScopeUser, j(h.ConfigDir, "Claude", "claude_desktop_config.json"), mcpFormatServers}, + {mcpClientCode, mcpScopeUser, j(h.Dir, ".claude.json"), mcpFormatClaude}, + {mcpClientCursor, mcpScopeUser, j(h.Dir, ".cursor", "mcp.json"), mcpFormatServers}, + {mcpClientVSCode, mcpScopeUser, j(h.ConfigDir, "Code", "User", "mcp.json"), mcpFormatVSCode}, + {mcpClientWindsurf, mcpScopeUser, j(h.ConfigDir, "devin", "mcp_config.json"), mcpFormatServers}, + {mcpClientWindsurf, mcpScopeUser, j(h.Dir, ".codeium", "windsurf", "mcp_config.json"), mcpFormatServers}, + {mcpClientWindsurf, mcpScopeUser, j(h.Dir, ".codeium", "windsurf-next", "mcp_config.json"), mcpFormatServers}, + {mcpClientCodex, mcpScopeUser, j(h.Dir, ".codex", "config.toml"), mcpFormatCodex}, + } + if goos == "windows" { + out = append(out, mcpSource{mcpClientZed, mcpScopeUser, j(h.ConfigDir, "Zed", "settings.json"), mcpFormatZed}) + } else { + // macOS and Linux both: Zed keeps ~/.config/zed on macOS too, not + // Application Support. + out = append(out, mcpSource{mcpClientZed, mcpScopeUser, j(h.Dir, ".config", "zed", "settings.json"), mcpFormatZed}) + } + return out +} + +// mcpSystemSources is the machine-wide files: Claude Code's managed file and +// Codex's system config. programFiles is %ProgramFiles% on Windows. +func mcpSystemSources(goos, programFiles string) []mcpSource { + switch goos { + case "darwin": + return []mcpSource{ + {mcpClientCode, mcpScopeSystem, filepath.Join("/Library", "Application Support", "ClaudeCode", "managed-mcp.json"), mcpFormatServers}, + {mcpClientCodex, mcpScopeSystem, filepath.Join("/etc", "codex", "config.toml"), mcpFormatCodex}, + } + case "windows": + if programFiles == "" { + return nil + } + return []mcpSource{ + {mcpClientCode, mcpScopeSystem, filepath.Join(programFiles, "ClaudeCode", "managed-mcp.json"), mcpFormatServers}, + } + default: + return []mcpSource{ + {mcpClientCode, mcpScopeSystem, filepath.Join("/etc", "claude-code", "managed-mcp.json"), mcpFormatServers}, + {mcpClientCodex, mcpScopeSystem, filepath.Join("/etc", "codex", "config.toml"), mcpFormatCodex}, + } + } +} + +// mcpProjectSources is the per-project files under one project directory. +// Project directories come from Claude Code's own list of projects in +// ~/.claude.json; the disk is never walked looking for projects, so a +// project only another client has opened is not seen. That is a stated +// limit, not a finding of "none". +func mcpProjectSources(dir string) []mcpSource { + j := filepath.Join + return []mcpSource{ + {mcpClientCode, mcpScopeProject, j(dir, ".mcp.json"), mcpFormatServers}, + {mcpClientCursor, mcpScopeProject, j(dir, ".cursor", "mcp.json"), mcpFormatServers}, + {mcpClientVSCode, mcpScopeProject, j(dir, ".vscode", "mcp.json"), mcpFormatVSCode}, + {mcpClientZed, mcpScopeProject, j(dir, ".zed", "settings.json"), mcpFormatZed}, + {mcpClientCodex, mcpScopeProject, j(dir, ".codex", "config.toml"), mcpFormatCodex}, + } +} + +// mcpGOOS is the platform whose paths are read; a test reads another's. +var mcpGOOS = runtime.GOOS + +// mcpHomesFn and mcpSystemFn are the platform's account list and system files +// (mcpconfig_.go); a test points them at a fixture. +var ( + mcpHomesFn = mcpHomes + mcpSystemFn = mcpSystemFiles +) + +// readMCPConfigs reads every source for every account and returns the +// declared servers (running state not yet known) and what was looked at. +func readMCPConfigs() ([]mcpServerObs, mcpConfigScan) { + homes, supported := mcpHomesFn() + scan := mcpConfigScan{Supported: supported} + if !supported { + return nil, scan + } + if len(homes) > mcpMaxAccounts { + homes, scan.Truncated = homes[:mcpMaxAccounts], true + } + scan.Accounts = len(homes) + var out []mcpServerObs + // A project is read for the account whose list named it, and its files + // are held to that account's ownership: one account naming another's + // directory as a project reads nothing of the other's. + type project struct { + dir string + owner string + } + seenProject := map[project]bool{} + var projects []project + read := func(src mcpSource, owner string) { + raw, ok := mcpReadFileAs(src.Path, owner, &scan) + if !ok { + return + } + entries, projs, err := mcpParse(src.Format, raw) + if err != nil { + scan.Unparsed++ + return + } + for _, d := range projs { + p := project{d, owner} + if !seenProject[p] { + seenProject[p] = true + projects = append(projects, p) + } + } + for _, e := range entries { + o := mcpAnalyze(e) + o.Client, o.Path = src.Client, truncate(src.Path, mcpPathMax) + o.Scope = src.Scope + if e.projectScoped { + o.Scope = mcpScopeProject + } + out = append(out, o) + } + } + // The system files sit where only an administrator can write. + for _, src := range mcpSystemFn() { + read(src, mcpNoOwnerCheck) + } + for _, h := range homes { + owner := mcpPathOwner(h.Dir) + for _, src := range mcpConfigSources(mcpGOOS, h) { + read(src, owner) + } + } + sort.Slice(projects, func(i, j int) bool { + if projects[i].dir != projects[j].dir { + return projects[i].dir < projects[j].dir + } + return projects[i].owner < projects[j].owner + }) + if len(projects) > mcpMaxProjects { + projects, scan.Truncated = projects[:mcpMaxProjects], true + } + scan.Projects = len(projects) + for _, p := range projects { + for _, src := range mcpProjectSources(p.dir) { + read(src, p.owner) + } + } + if len(out) > mcpMaxServers { + out, scan.Truncated = out[:mcpMaxServers], true + } + return out, scan +} + +// mcpReadFile reads one config file, bounded. A file that is not there is +// not counted; one that is there and cannot be read is, so a bastion without +// the right to look says so instead of reporting "no servers". +// +// On linux and darwin a symlink is followed, as the client itself would, but +// what it points at is OPENED only once it is known to be a regular file +// (mcpOpenRegular, mcpconfig_open_.go). On Windows a link at the path +// itself is refused (mcpconfig_winopen.go). These paths sit in other people's home directories +// and this service reads them with every right it has: opening a FIFO would +// hold the heartbeat, and opening a device runs its driver (a watchdog arms, +// a serial line toggles), so a crafted link must never get that far. +func mcpReadFile(path string, scan *mcpConfigScan) ([]byte, bool) { + return mcpReadFileAs(path, mcpNoOwnerCheck, scan) +} + +// mcpReadFileAs is mcpReadFile for one account: the file is read only when +// that account or an administrative owner owns it (root; on Windows SYSTEM +// or the Administrators group), so an account cannot point its own config +// path at another's file and have this service read it for it. owner is a +// uid on unix and a SID on Windows; mcpNoOwnerCheck reads whatever is there +// (the system files; a platform with no owner model). +func mcpReadFileAs(path string, owner string, scan *mcpConfigScan) ([]byte, bool) { + f, fileOwner, err := mcpOpenRegularFn(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + scan.Unreadable++ + } + return nil, false + } + defer f.Close() + if !mcpOwnerAllowed(fileOwner, owner, mcpAdminOwners) { + scan.ForeignOwner++ + return nil, false + } + b, err := io.ReadAll(io.LimitReader(f, mcpMaxFileBytes+1)) + if err != nil { + scan.Unreadable++ + return nil, false + } + if len(b) > mcpMaxFileBytes { + scan.Unparsed++ + return nil, false + } + scan.FilesRead++ + return b, true +} + +// errMCPNotRegular: the path names something other than a regular file. +var errMCPNotRegular = errors.New("not a regular file") + +// mcpOpenRegularFn is the seam a test uses to stand a platform's open in; +// production never reassigns it. +var mcpOpenRegularFn = mcpOpenRegular + +// mcpOwnerAllowed: a file owned by file may be read for want. An owner that +// could not be read is never allowed where a check applies. +func mcpOwnerAllowed(file, want string, admins map[string]bool) bool { + if want == mcpNoOwnerCheck { + return true + } + if file == "" || file == mcpOwnerUnknown { + return false + } + return file == want || admins[file] +} + +// mcpEntry is one server as a file declares it, before analysis. Values live +// here only between parsing and mcpAnalyze. +type mcpEntry struct { + name string + typ string + command string + args []string + env map[string]string + envNames []string // names passed through without a value (Codex env_vars) + url string + headers map[string]string + authHint bool // a credential configured by reference: bearer_token_env_var, env_http_headers, a headers helper + disabled bool + projectScoped bool +} + +// mcpParse turns one file into entries, plus any project directories the +// file lists (Claude Code's ~/.claude.json). +func mcpParse(format string, raw []byte) ([]mcpEntry, []string, error) { + if format == mcpFormatCodex { + t, err := parseTOMLSubset(string(raw)) + if err != nil { + return nil, nil, err + } + return mcpCodexEntries(t), nil, nil + } + var doc map[string]any + if err := json.Unmarshal(stripJSONC(raw), &doc); err != nil { + return nil, nil, err + } + switch format { + case mcpFormatVSCode: + return mcpJSONEntries(doc["servers"], false), nil, nil + case mcpFormatZed: + return mcpJSONEntries(doc["context_servers"], false), nil, nil + case mcpFormatClaude: + out := mcpJSONEntries(doc["mcpServers"], false) + var projects []string + if ps, ok := doc["projects"].(map[string]any); ok { + for dir, v := range ps { + projects = append(projects, filepath.FromSlash(dir)) + if p, ok := v.(map[string]any); ok { + out = append(out, mcpJSONEntries(p["mcpServers"], true)...) + } + } + } + sort.Strings(projects) + return out, projects, nil + default: + return mcpJSONEntries(doc["mcpServers"], false), nil, nil + } +} + +// mcpJSONEntries reads one servers object. The clients agree on command, +// args, env, url and headers; type is spelled `type` (and `transport` by some +// older files); Windsurf also spells the URL serverUrl; Zed's older form nests +// the command as {path, args, env}. +func mcpJSONEntries(v any, projectScoped bool) []mcpEntry { + m, ok := v.(map[string]any) + if !ok { + return nil + } + names := make([]string, 0, len(m)) + for k := range m { + names = append(names, k) + } + sort.Strings(names) + var out []mcpEntry + for _, name := range names { + s, ok := m[name].(map[string]any) + if !ok { + continue + } + e := mcpEntry{name: name, projectScoped: projectScoped, + typ: jsonStr(s["type"]), url: jsonStr(s["url"])} + if e.typ == "" { + e.typ = jsonStr(s["transport"]) + } + if e.url == "" { + e.url = jsonStr(s["serverUrl"]) + } + e.args = jsonStrList(s["args"]) + e.env = jsonStrMap(s["env"]) + switch c := s["command"].(type) { + case string: + e.command = c + case map[string]any: + e.command = jsonStr(c["path"]) + e.args = append(e.args, jsonStrList(c["args"])...) + for k, v := range jsonStrMap(c["env"]) { + if e.env == nil { + e.env = map[string]string{} + } + e.env[k] = v + } + } + e.headers = jsonStrMap(s["headers"]) + if s["headersHelper"] != nil || s["oauth"] != nil { + e.authHint = true + } + if d, ok := s["disabled"].(bool); ok && d { + e.disabled = true + } + if en, ok := s["enabled"].(bool); ok && !en { + e.disabled = true + } + out = append(out, e) + } + return out +} + +func jsonStr(v any) string { + s, _ := v.(string) + return s +} + +func jsonStrList(v any) []string { + l, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(l)) + for _, x := range l { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +// jsonStrMap keeps string values; any other value is kept as "" so its NAME +// still counts (a number in env is still a variable passed to the server). +func jsonStrMap(v any) map[string]string { + m, ok := v.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(m)) + for k, x := range m { + s, _ := x.(string) + out[k] = s + } + return out +} + +// mcpCodexEntries reads [mcp_servers.] tables (Codex config.toml): +// command, args, env, env_vars, url, bearer_token_env_var, http_headers, +// env_http_headers, enabled. +func mcpCodexEntries(t map[string]any) []mcpEntry { + servers, ok := t["mcp_servers"].(map[string]any) + if !ok { + return nil + } + names := make([]string, 0, len(servers)) + for k := range servers { + names = append(names, k) + } + sort.Strings(names) + var out []mcpEntry + for _, name := range names { + s, ok := servers[name].(map[string]any) + if !ok { + continue + } + e := mcpEntry{name: name, command: jsonStr(s["command"]), url: jsonStr(s["url"]), + args: jsonStrList(s["args"]), env: jsonStrMap(s["env"]), headers: jsonStrMap(s["http_headers"])} + if l, ok := s["env_vars"].([]any); ok { + for _, x := range l { + switch v := x.(type) { + case string: + e.envNames = append(e.envNames, v) + case map[string]any: + if n := jsonStr(v["name"]); n != "" { + e.envNames = append(e.envNames, n) + } + } + } + } + if n := jsonStr(s["bearer_token_env_var"]); n != "" { + e.authHint = true + e.envNames = append(e.envNames, n) + } + if h, ok := s["env_http_headers"].(map[string]any); ok && len(h) > 0 { + e.authHint = true + } + if en, ok := s["enabled"].(bool); ok && !en { + e.disabled = true + } + out = append(out, e) + } + return out +} + +// mcpAnalyze turns an entry into the record that leaves the machine. Every +// value it looks at is used for a shape check and then goes out of scope with +// the entry; nothing but names and classes is copied into the result. +func mcpAnalyze(e mcpEntry) mcpServerObs { + o := mcpServerObs{Name: truncate(e.name, mcpNameMax), Disabled: e.disabled} + o.Transport = mcpTransport(e) + for k := range e.env { + o.EnvNames = append(o.EnvNames, k) + } + o.EnvNames = append(o.EnvNames, e.envNames...) + o.EnvNames = dedupeStrings(o.EnvNames) + sort.Strings(o.EnvNames) + if len(o.EnvNames) > mcpMaxEnvNames { + o.EnvNames = o.EnvNames[:mcpMaxEnvNames] + } + for i, n := range o.EnvNames { + o.EnvNames[i] = truncate(n, mcpNameMax) + } + for k, v := range e.env { + if secretShaped(k, v) { + o.SecretInPlaintext = true + } + } + headerAuth := false + for k, v := range e.headers { + if secretShaped(k, v) { + o.SecretInPlaintext = true + } + if mcpAuthHeader(k) { + headerAuth = true + } + } + args, argSecret := redactSecretArgs(e.args) + if argSecret { + o.SecretInPlaintext = true + } + if e.command != "" { + // A file may put the whole command line in command; it is redacted + // word by word like the arguments. + cmd, cmdSecret := redactSecretArgs(strings.Fields(e.command)) + if cmdSecret { + o.SecretInPlaintext = true + } + o.Command = truncate(strings.Join(append(cmd, args...), " "), mcpCommandMax) + o.PackageSource, o.PackageName, o.PackagePinned = mcpPackage(e.command, e.args) + o.matchSub, o.matchBase = mcpMatchKey(e.command, e.args, o.PackageName) + o.PackageName = truncate(o.PackageName, mcpNameMax) + } + if o.Transport == "stdio" { + o.Locality, o.AuthConfigured, o.BindClass = mcpLocalProcess, mcpNotApplicable, mcpNotApplicable + return o + } + o.BindClass = mcpUnknown + o.AuthConfigured = mcpUnknown + o.Locality = mcpUnknown + if e.url != "" { + host, urlSecret, urlAuth := mcpURLFacts(e.url) + o.URLHost = truncate(host, mcpHostMax) + o.Locality = hostLocality(host) + if urlSecret { + o.SecretInPlaintext = true + } + if urlAuth { + headerAuth = true + } + } + if headerAuth || e.authHint { + o.AuthConfigured = ownerYes + } + return o +} + +// mcpTransport is the declared type, or what the entry's shape implies. +func mcpTransport(e mcpEntry) string { + switch strings.ToLower(strings.TrimSpace(e.typ)) { + case "stdio": + return "stdio" + case "http", "streamable-http", "streamablehttp", "streamable_http": + return "http" + case "sse": + return "sse" + case "ws", "websocket": + return "websocket" + case "": + default: + return mcpUnknown + } + switch { + case e.command != "": + return "stdio" + case e.url == "": + return mcpUnknown + } + u, err := url.Parse(e.url) + if err != nil { + return mcpUnknown + } + switch strings.ToLower(u.Scheme) { + case "ws", "wss": + return "websocket" + } + if strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/sse") { + return "sse" + } + return "http" +} + +// mcpURLFacts: the URL's host, whether credentials sit in it (a password in +// the userinfo, or a secret-named query parameter with a value), and whether +// that amounts to authentication being configured. The URL itself is not +// kept: its path and query can carry a token. +func mcpURLFacts(raw string) (host string, secret bool, auth bool) { + u, err := url.Parse(raw) + if err != nil { + return "", false, false + } + host = strings.ToLower(u.Hostname()) + if u.User != nil { + if _, has := u.User.Password(); has { + secret, auth = true, true + } + } + for k, vs := range u.Query() { + for _, v := range vs { + if secretName(k) && v != "" && !isReference(v) { + secret, auth = true, true + } + } + } + return host, secret, auth +} + +// hostLocality classes a URL host without resolving it. Resolving a name is +// a network request, which this file does not make, so a name is classed +// by its form: loopback names and addresses, private and link-local +// addresses, and names no public resolver answers (one label, .local, +// .internal, .lan, .home.arpa) are local; anything else is remote. +func hostLocality(host string) string { + h := strings.TrimSuffix(strings.ToLower(host), ".") + switch { + case h == "": + return mcpUnknown + case h == "localhost" || strings.HasSuffix(h, ".localhost"): + return "loopback" + } + if ip := net.ParseIP(h); ip != nil { + switch { + case ip.IsLoopback(): + return "loopback" + case ip.IsPrivate() || ip.IsLinkLocalUnicast(): + return "lan" + case ip.IsUnspecified(): + return "loopback" + } + return "remote" + } + if !strings.Contains(h, ".") { + return "lan" + } + for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa"} { + if strings.HasSuffix(h, suffix) { + return "lan" + } + } + return "remote" +} + +// mcpAuthHeader: a header whose name carries a credential. +func mcpAuthHeader(name string) bool { + n := strings.ToLower(name) + return n == "authorization" || n == "cookie" || n == "proxy-authorization" || secretName(n) +} + +// ── secrets: the shape, never the value ───────────────────────────────────── + +// secretPrefixes are the shapes of credentials whose issuers publish a fixed +// prefix: Anthropic and OpenAI keys, GitHub tokens, Slack tokens, AWS access +// key ids, Google API keys, GitLab tokens, Hugging Face and npm tokens, JWTs, +// and PEM private keys. +var secretPrefixes = regexp.MustCompile(`(?:^|[^A-Za-z0-9])(?:` + + `sk-ant-[A-Za-z0-9_-]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|` + + `gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|` + + `xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|` + + `glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{30,}|npm_[A-Za-z0-9]{36}|` + + `eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.|-----BEGIN [A-Z ]*PRIVATE KEY-----)`) + +// secretNameRe: a variable, header or flag whose name says it holds a secret. +var secretNameRe = regexp.MustCompile(`(?i)(key|token|secret|passw|pwd|credential|auth|bearer|cookie|session)`) + +func secretName(name string) bool { return secretNameRe.MatchString(name) } + +// isReference: the value names where the secret comes from (${env:X}, +// ${input:x}, $X, %X%) rather than holding it. +func isReference(v string) bool { + v = strings.TrimSpace(v) + return strings.Contains(v, "${") || strings.HasPrefix(v, "$") || + (len(v) > 2 && strings.HasPrefix(v, "%") && strings.HasSuffix(v, "%")) +} + +// secretShaped answers whether one name/value pair holds a secret in plain +// text: the value has a known credential's shape, or the name says secret and +// the value is a literal long enough to be one. The value is not returned or +// kept; the caller gets a bool. +func secretShaped(name, value string) bool { + v := strings.TrimSpace(value) + if v == "" || isReference(v) { + return false + } + if secretPrefixes.MatchString(v) { + return true + } + if strings.HasPrefix(strings.ToLower(v), "bearer ") || strings.HasPrefix(strings.ToLower(v), "basic ") { + rest := strings.TrimSpace(v[strings.IndexByte(v, ' ')+1:]) + return len(rest) >= 8 && !isReference(rest) + } + if !secretName(name) || len(v) < 8 { + return false + } + // A path or a plain URL under a secret-sounding name (a key FILE, an + // auth SERVER) is where a secret lives, not the secret. + if filepath.IsAbs(v) || strings.HasPrefix(v, "~") || strings.HasPrefix(v, "./") { + return false + } + if u, err := url.Parse(v); err == nil && u.Scheme != "" && u.Host != "" { + return false + } + return true +} + +// isPureReference: the whole value is one ${...} reference (${VAR}, +// ${env:VAR}, ${input:id}), which the client expands and which names where +// the secret comes from. Only that form is kept on a redacted command line: +// a bare $NAME is passed through literally by clients that start a server +// without a shell, so it may well be the secret itself. +func isPureReference(v string) bool { + v = strings.TrimSpace(v) + return strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}") && strings.Count(v, "${") == 1 && + strings.IndexByte(v, '}') == len(v)-1 +} + +// redactSecretArgs replaces every argument that holds a secret with +// mcpRedacted: one with a credential's shape, the value after or inside a +// flag whose name says secret, a NAME=value whose name does, a header +// argument (Authorization: ..., X-Api-Key: ...), and the userinfo, path and +// query of a URL. found reports whether a value matched a secret's shape; +// redaction itself is wider than that, because a value that does not look +// like a secret can still be one. +func redactSecretArgs(args []string) (out []string, found bool) { + out = make([]string, len(args)) + for i := 0; i < len(args); i++ { + a := args[i] + if strings.HasPrefix(a, "-") { + flag, val, hasVal := strings.Cut(strings.TrimLeft(a, "-"), "=") + if hasVal { + v, f := redactValue(flag, val) + out[i], found = a[:len(a)-len(val)]+v, found || f + continue + } + out[i] = a + if secretName(flag) && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + v, f := redactValue(flag, args[i+1]) + out[i+1], found = v, found || f + i++ + } + continue + } + v, f := redactValue("", a) + out[i], found = v, found || f + } + return out, found +} + +// redactValue is one value with whatever secret it carries replaced. name is +// the flag or variable it was given under, "" for a positional argument. +func redactValue(name, v string) (string, bool) { + if v == "" || isPureReference(v) { + return v, false + } + if secretPrefixes.MatchString(v) { + return mcpRedacted, true + } + if secretName(name) { + return mcpRedacted, secretShaped(name, v) + } + if low := strings.ToLower(v); strings.HasPrefix(low, "bearer ") || strings.HasPrefix(low, "basic ") { + return mcpRedacted, secretShaped(name, v) + } + if i := strings.Index(v, "://"); i > 0 && !strings.ContainsAny(v[:i], " =:") { + return redactURL(v) + } + if k, val, ok := strings.Cut(v, "="); ok && k != "" && !strings.ContainsAny(k, " /\\:") { + r, f := redactValue(k, val) + return k + "=" + r, f + } + if k, val, ok := strings.Cut(v, ":"); ok && k != "" && !strings.ContainsAny(k, " /\\") && mcpAuthHeader(k) { + val = strings.TrimSpace(val) + rest := val + if low := strings.ToLower(rest); strings.HasPrefix(low, "bearer ") || strings.HasPrefix(low, "basic ") { + rest = strings.TrimSpace(rest[strings.IndexByte(rest, ' ')+1:]) + } + if rest == "" || isPureReference(rest) { + return v, false + } + return k + ": " + mcpRedacted, secretShaped(k, val) + } + return v, false +} + +// redactURL keeps a URL's scheme and host and replaces the rest: userinfo +// carries credentials, and a path or query can carry a token as easily as a +// named parameter does. found: a password, a secret-shaped user, or a +// secret-named query parameter with a value. +func redactURL(raw string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return mcpRedacted, secretPrefixes.MatchString(raw) + } + _, found, _ := mcpURLFacts(raw) + out := u.Scheme + "://" + if u.User != nil { + out += mcpRedacted + "@" + if secretPrefixes.MatchString(u.User.Username()) { + found = true + } + } + out += truncate(u.Host, mcpHostMax) + if strings.Trim(u.EscapedPath(), "/") != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" { + out += "/" + mcpRedacted + } + return out, found +} + +// ── packages: where the server's code comes from, and whether it is pinned ── + +var ( + exactSemver = regexp.MustCompile(`^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + exactPEP440 = regexp.MustCompile(`^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?$`) +) + +// npm flags that take a value, so the value is not mistaken for the package. +var npxValueFlags = map[string]bool{"-p": true, "--package": true, "--registry": true, + "--cache": true, "--prefix": true, "-c": true, "--call": true, "--userconfig": true} + +// uvx flags that take a value; --from names the package itself. +var uvxValueFlags = map[string]bool{"--from": true, "--with": true, "-w": true, "--python": true, + "-p": true, "--index": true, "--index-url": true, "--extra-index-url": true, + "--with-requirements": true, "--constraint": true, "-c": true} + +// docker run flags that take a value (the common ones a server entry uses). +var dockerValueFlags = map[string]bool{"-e": true, "--env": true, "--env-file": true, "-v": true, + "--volume": true, "--mount": true, "--name": true, "--network": true, "--net": true, + "-p": true, "--publish": true, "-w": true, "--workdir": true, "-u": true, "--user": true, + "--entrypoint": true, "-l": true, "--label": true, "--platform": true, "--pull": true, + "--memory": true, "-m": true, "--cpus": true, "--add-host": true, "--cap-add": true, + "--cap-drop": true, "--security-opt": true, "--hostname": true, "-h": true, "--init-path": true} + +// mcpPackage names where a stdio server's code comes from. +func mcpPackage(command string, args []string) (source, name, pinned string) { + base := runnerBase(command) + switch { + case base == "npx" || base == "bunx" || base == "pnpx" || + ((base == "pnpm" || base == "yarn") && len(args) > 0 && args[0] == "dlx") || + (base == "npm" && len(args) > 0 && args[0] == "exec"): + if base == "pnpm" || base == "yarn" || base == "npm" { + args = args[1:] + } + spec := firstPositional(args, npxValueFlags, map[string]bool{"-p": true, "--package": true}) + n, ver := splitNPMSpec(spec) + return "npx", n, pinnedWord(ver, exactSemver, spec) + case base == "uvx" || (base == "uv" && len(args) > 1 && args[0] == "tool" && args[1] == "run"): + if base == "uv" { + args = args[2:] + } + spec := firstPositional(args, uvxValueFlags, map[string]bool{"--from": true}) + n, ver := splitPySpec(spec) + return "uvx", n, pinnedWord(ver, exactPEP440, spec) + case base == "pipx" && len(args) > 0 && args[0] == "run": + spec := firstPositional(args[1:], map[string]bool{"--spec": true, "--python": true}, map[string]bool{"--spec": true}) + n, ver := splitPySpec(spec) + return "pip", n, pinnedWord(ver, exactPEP440, spec) + case base == "docker" || base == "podman": + i := 0 + for i < len(args) && args[i] != "run" { + i++ + } + if i == len(args) { + return "docker", "", mcpUnknown + } + image := firstPositional(args[i+1:], dockerValueFlags, nil) + n, digest := splitImage(image) + if image == "" { + return "docker", "", mcpUnknown + } + return "docker", n, yesNo(digest) + case interpreters[base]: + // node server.js, python -m pkg: the code is a script or module on + // disk, not a package the command line names. + return mcpUnknown, "", mcpUnknown + } + return "binary", "", mcpUnknown +} + +// runnerBase is binBase with the Windows script suffixes dropped too: npm +// installs npx as npx.cmd there, and a config names it either way. +func runnerBase(s string) string { + b := binBase(s) + for _, suf := range []string{".cmd", ".bat", ".ps1"} { + b = strings.TrimSuffix(b, suf) + } + return b +} + +// firstPositional is the first argument that is not a flag or a flag's value. +// A flag in pick names the package itself, and its value is returned. +func firstPositional(args []string, valueFlags, pick map[string]bool) string { + for i := 0; i < len(args); i++ { + a := args[i] + if f, v, ok := strings.Cut(a, "="); ok && strings.HasPrefix(a, "-") { + if pick[f] { + return v + } + continue + } + if pick[a] && i+1 < len(args) { + return args[i+1] + } + if valueFlags[a] { + i++ + continue + } + if strings.HasPrefix(a, "-") { + continue + } + return a + } + return "" +} + +// splitNPMSpec splits "@scope/name@1.2.3" or "name@^1" into name and version. +func splitNPMSpec(spec string) (name, version string) { + if spec == "" { + return "", "" + } + if strings.Contains(spec, "://") { + // git+https://user:token@host/..., a tarball URL: not a registry + // name, and its userinfo can carry a credential. + n, _ := redactURL(spec) + return n, "" + } + at := strings.LastIndexByte(spec, '@') + if at <= 0 { + return spec, "" + } + return spec[:at], spec[at+1:] +} + +// splitPySpec splits "name==1.2", "name@1.2" or "name>=1" into name and the +// exact version when the spec pins one ("" otherwise, with a range kept as a +// non-matching version so it reads as not pinned). +func splitPySpec(spec string) (name, version string) { + if strings.Contains(spec, "://") { + n, _ := redactURL(spec) + return n, "" + } + if i := strings.Index(spec, "=="); i > 0 { + return spec[:i], spec[i+2:] + } + if i := strings.IndexByte(spec, '@'); i > 0 { + return spec[:i], spec[i+1:] + } + if i := strings.IndexAny(spec, "<>~!="); i > 0 { + return spec[:i], spec[i:] + } + return spec, "" +} + +// splitImage splits an image reference into its name and whether it is +// pinned by digest. A tag can be moved to other content; only a digest +// cannot, so only a digest counts as pinned. +func splitImage(ref string) (name string, digest bool) { + if i := strings.Index(ref, "@sha256:"); i > 0 { + return ref[:i], true + } + slash := strings.LastIndexByte(ref, '/') + if c := strings.LastIndexByte(ref, ':'); c > slash { + return ref[:c], false + } + return ref, false +} + +// pinnedWord: yes for an exact version, no for a range, a tag or none, and +// unknown when the spec is a path, a URL or a git reference. +func pinnedWord(version string, exact *regexp.Regexp, spec string) string { + if spec == "" || strings.ContainsAny(spec, `/\`) && !strings.HasPrefix(spec, "@") || + strings.Contains(spec, ":") { + return mcpUnknown + } + return yesNo(exact.MatchString(version)) +} + +// mcpMatchKey is what finds this server in a process list: the package name +// for a packaged server, the script for an interpreter, the program's +// basename otherwise. +func mcpMatchKey(command string, args []string, pkg string) (sub, base string) { + if pkg != "" { + return strings.ToLower(pkg), "" + } + b := runnerBase(command) + if interpreters[b] { + for _, a := range args { + if !strings.HasPrefix(a, "-") { + return strings.ToLower(a), "" + } + } + return "", "" + } + return "", b +} + +// ── correlation with the process list ─────────────────────────────────────── + +// mcpProc is one process as the platform enumerator saw it. Argv is empty +// when the platform did not read this process's command line (Windows reads +// it only for candidates); such a process still carries its parent link. +type mcpProc struct { + PID int + PPID int + Argv []string +} + +// mcpWantedBases is the program basenames whose command lines the Windows +// enumerator must read: every interpreter and package runner, plus every +// program a declared server names. +func mcpWantedBases(declared []mcpServerObs) map[string]bool { + want := map[string]bool{"docker": true, "podman": true, "pipx": true, "pnpm": true, "yarn": true, + "npm": true, "bunx": true, "pnpx": true} + for b := range interpreters { + want[b] = true + } + for _, d := range declared { + if d.matchBase != "" { + want[d.matchBase] = true + } + } + return want +} + +// correlateMCP sets running_state on every declared server and adds the +// servers that are running with no declaration. procsOK false means the +// process list could not be read, and then nothing is guessed: every local +// server reads unknown and no undeclared server is reported. +func correlateMCP(declared []mcpServerObs, procs []mcpProc, procsOK bool) []mcpServerObs { + byPID := map[int]mcpProc{} + for _, p := range procs { + byPID[p.PID] = p + } + // Direct matches: a process whose command line names a declared server. + owner := map[int][]int{} // pid -> indexes into declared + for pid, p := range byPID { + if len(p.Argv) == 0 { + continue + } + low := strings.ToLower(strings.Join(p.Argv, " ")) + for i, d := range declared { + if d.Transport != "stdio" { + continue + } + if (d.matchSub != "" && strings.Contains(low, d.matchSub)) || + (d.matchBase != "" && binBase(p.Argv[0]) == d.matchBase) { + owner[pid] = append(owner[pid], i) + } + } + } + // A package runner starts the server as a child (npx -> node .../bin/x), + // and the child is the same server: it inherits its parent's match. + inherit := func(pid int) []int { + seen := map[int]bool{} + for hop := 0; hop < maxHelperHops; hop++ { + p, ok := byPID[pid] + if !ok || seen[pid] { + return nil + } + seen[pid] = true + if m := owner[p.PPID]; len(m) > 0 { + return m + } + pid = p.PPID + } + return nil + } + matched := map[int][]int{} + for pid := range byPID { + if m := owner[pid]; len(m) > 0 { + matched[pid] = m + } else if m := inherit(pid); len(m) > 0 { + matched[pid] = m + } + } + pids := make([][]int, len(declared)) + for pid, idx := range matched { + for _, i := range idx { + pids[i] = append(pids[i], pid) + } + } + out := make([]mcpServerObs, 0, len(declared)) + for i, d := range declared { + switch { + case d.Transport != "stdio" && (d.Locality == "remote" || d.Locality == "lan"): + d.RunningState = mcpStateNotLocal + case d.Transport != "stdio" || !procsOK: + d.RunningState = mcpStateUnknown + case len(pids[i]) > 0: + d.RunningState = mcpStateRunning + sort.Ints(pids[i]) + if len(pids[i]) > mcpMaxPIDs { + pids[i] = pids[i][:mcpMaxPIDs] + } + d.PIDs = pids[i] + default: + d.RunningState = mcpStateIdle + } + out = append(out, d) + } + if !procsOK { + return out + } + // Undeclared: a process that looks like a tool server, matched by no + // declaration, and not itself the child of one already reported. + shadow := map[int]bool{} + for pid, p := range byPID { + if len(matched[pid]) == 0 && looksLikeMCPServer(p.Argv) { + shadow[pid] = true + } + } + var shadowPIDs []int + for pid := range shadow { + if !shadowAncestor(byPID, shadow, pid) { + shadowPIDs = append(shadowPIDs, pid) + } + } + sort.Ints(shadowPIDs) + for _, pid := range shadowPIDs { + if len(out) >= mcpMaxServers { + break + } + p := byPID[pid] + args, secret := redactSecretArgs(p.Argv[1:]) + src, name, pinned := mcpPackage(p.Argv[0], p.Argv[1:]) + name = truncate(name, mcpNameMax) + out = append(out, mcpServerObs{Name: name, Transport: mcpUnknown, Locality: mcpLocalProcess, + Command: truncate(strings.Join(append([]string{p.Argv[0]}, args...), " "), mcpCommandMax), + PackageSource: src, PackageName: name, PackagePinned: pinned, SecretInPlaintext: secret, + AuthConfigured: mcpNotApplicable, BindClass: mcpUnknown, + RunningState: mcpStateShadow, PIDs: []int{pid}}) + } + return out +} + +func shadowAncestor(byPID map[int]mcpProc, shadow map[int]bool, pid int) bool { + seen := map[int]bool{pid: true} + for hop := 0; hop < maxHelperHops; hop++ { + p, ok := byPID[pid] + if !ok { + return false + } + if shadow[p.PPID] { + return true + } + if seen[p.PPID] { + return false + } + seen[p.PPID] = true + pid = p.PPID + } + return false +} + +// mcpNPMScope is the npm scope the protocol's reference servers are published +// under; a command line naming it is running one of them. +const mcpNPMScope = "@modelcontextprotocol/" + +// looksLikeMCPServer: a process whose command line names a tool server — a +// program or package whose name carries "mcp" as a word (github-mcp-server, +// mcp-server-git, fastmcp, mcp-remote) or the reference servers' npm scope. +// Only for a program that runs servers (an interpreter or package runner) or +// one whose own name carries the word: an agent started with --mcp-config is +// a client, and flags are never read. Soft, like every process signal here. +func looksLikeMCPServer(argv []string) bool { + if len(argv) == 0 { + return false + } + if _, isAgent := matchAgentSignature(argv); isAgent { + return false + } + base := runnerBase(argv[0]) + if mcpWord(base) { + return true + } + if !interpreters[base] && base != "docker" && base != "podman" && base != "npm" && base != "pipx" { + return false + } + for _, a := range argv[1:] { + if strings.HasPrefix(a, "-") { + continue + } + low := strings.ToLower(a) + if strings.Contains(low, mcpNPMScope) { + return true + } + for _, part := range strings.FieldsFunc(low, func(r rune) bool { + return r == '/' || r == '\\' || r == '@' || r == ':' || r == '=' + }) { + if mcpWord(part) { + return true + } + } + } + return false +} + +// mcpWord: "mcp" as a whole word inside one name, split on - _ and . +func mcpWord(s string) bool { + for _, w := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) { + if w == "mcp" || w == "fastmcp" { + return true + } + } + return false +} + +// ── JSON with comments, and the subset of TOML Codex writes ───────────────── + +// stripJSONC removes // and /* */ comments and trailing commas outside +// strings, so Zed's and VS Code's files parse as JSON. Plain JSON passes +// through unchanged. +func stripJSONC(b []byte) []byte { + out := make([]byte, 0, len(b)) + inStr, esc := false, false + for i := 0; i < len(b); i++ { + c := b[i] + if inStr { + out = append(out, c) + switch { + case esc: + esc = false + case c == '\\': + esc = true + case c == '"': + inStr = false + } + continue + } + switch { + case c == '"': + inStr = true + out = append(out, c) + case c == '/' && i+1 < len(b) && b[i+1] == '/': + for i < len(b) && b[i] != '\n' { + i++ + } + if i < len(b) { + out = append(out, '\n') + } + case c == '/' && i+1 < len(b) && b[i+1] == '*': + i += 2 + for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') { + i++ + } + i++ + case c == '}' || c == ']': + // Drop a comma that only whitespace separates from this close. + j := len(out) - 1 + for j >= 0 && (out[j] == ' ' || out[j] == '\t' || out[j] == '\n' || out[j] == '\r') { + j-- + } + if j >= 0 && out[j] == ',' { + out = append(out[:j], out[j+1:]...) + } + out = append(out, c) + default: + out = append(out, c) + } + } + return out +} + +// parseTOMLSubset reads the part of TOML a Codex config uses: [table] and +// [a."b c"] headers, key = value with dotted and quoted keys, basic and +// literal strings (and their multi-line forms), arrays (across lines), +// inline tables, and bare scalars kept as their text (true/false become +// booleans). [[array of tables]] sections are skipped: Codex keeps no +// server there. A line it cannot read is an error for the whole file, which +// counts as unparsed rather than silently half-read. +func parseTOMLSubset(src string) (map[string]any, error) { + root := map[string]any{} + cur := root + skipping := false + p := &tomlParser{s: src} + for { + p.skipSpaceAndComments(true) + if p.eof() { + return root, nil + } + if p.peek() == '[' { + if strings.HasPrefix(p.s[p.i:], "[[") { + skipping = true + p.skipLine() + continue + } + p.i++ + keys, err := p.keyPath(']') + if err != nil { + return nil, err + } + p.i++ // ] + skipping = false + cur = root + for _, k := range keys { + next, ok := cur[k].(map[string]any) + if !ok { + next = map[string]any{} + cur[k] = next + } + cur = next + } + p.skipLine() + continue + } + keys, err := p.keyPath('=') + if err != nil { + return nil, err + } + p.i++ // = + v, err := p.value() + if err != nil { + return nil, err + } + if !skipping { + setDotted(cur, keys, v) + } + p.skipLine() + } +} + +func setDotted(m map[string]any, keys []string, v any) { + for _, k := range keys[:len(keys)-1] { + next, ok := m[k].(map[string]any) + if !ok { + next = map[string]any{} + m[k] = next + } + m = next + } + m[keys[len(keys)-1]] = v +} + +type tomlParser struct { + s string + i int + depth int // arrays and inline tables open around the current value +} + +var errTOML = errors.New("toml: cannot read this line") + +func (p *tomlParser) eof() bool { return p.i >= len(p.s) } +func (p *tomlParser) peek() byte { return p.s[p.i] } + +func (p *tomlParser) skipSpaceAndComments(newlines bool) { + for !p.eof() { + c := p.peek() + switch { + case c == ' ' || c == '\t' || c == '\r' || (newlines && c == '\n'): + p.i++ + case c == '#': + for !p.eof() && p.peek() != '\n' { + p.i++ + } + default: + return + } + } +} + +// skipLine moves past the rest of the line (trailing space and a comment). +func (p *tomlParser) skipLine() { + for !p.eof() && p.peek() != '\n' { + p.i++ + } +} + +// keyPath reads a dotted key up to (not past) the stop byte. +func (p *tomlParser) keyPath(stop byte) ([]string, error) { + var keys []string + for { + p.skipSpaceAndComments(false) + if p.eof() { + return nil, errTOML + } + var k string + switch p.peek() { + case '"', '\'': + s, err := p.str() + if err != nil { + return nil, err + } + k = s + default: + st := p.i + for !p.eof() && (isBareKeyByte(p.peek())) { + p.i++ + } + if p.i == st { + return nil, errTOML + } + k = p.s[st:p.i] + } + keys = append(keys, k) + p.skipSpaceAndComments(false) + if p.eof() { + return nil, errTOML + } + switch p.peek() { + case '.': + p.i++ + case stop: + return keys, nil + default: + return nil, errTOML + } + } +} + +func isBareKeyByte(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' +} + +func (p *tomlParser) value() (any, error) { + p.skipSpaceAndComments(false) + if p.eof() { + return nil, errTOML + } + switch p.peek() { + case '"', '\'': + return p.str() + case '[', '{': + // Each level is a stack frame; a crafted file a million deep must + // not walk the stack that far. + if p.depth >= mcpMaxNesting { + return nil, errTOML + } + p.depth++ + defer func() { p.depth-- }() + } + switch p.peek() { + case '[': + p.i++ + var out []any + for { + p.skipSpaceAndComments(true) + if p.eof() { + return nil, errTOML + } + if p.peek() == ']' { + p.i++ + return out, nil + } + v, err := p.value() + if err != nil { + return nil, err + } + out = append(out, v) + p.skipSpaceAndComments(true) + if !p.eof() && p.peek() == ',' { + p.i++ + } + } + case '{': + p.i++ + out := map[string]any{} + for { + p.skipSpaceAndComments(false) + if p.eof() { + return nil, errTOML + } + if p.peek() == '}' { + p.i++ + return out, nil + } + keys, err := p.keyPath('=') + if err != nil { + return nil, err + } + p.i++ + v, err := p.value() + if err != nil { + return nil, err + } + setDotted(out, keys, v) + p.skipSpaceAndComments(false) + if !p.eof() && p.peek() == ',' { + p.i++ + } + } + } + st := p.i + for !p.eof() && !strings.ContainsRune(",]}\n#", rune(p.peek())) { + p.i++ + } + raw := strings.TrimSpace(p.s[st:p.i]) + switch raw { + case "": + return nil, errTOML + case "true": + return true, nil + case "false": + return false, nil + } + return raw, nil +} + +// str reads a basic ("...", with escapes) or literal ('...') string, either +// in its one-line or its triple-quoted multi-line form. +func (p *tomlParser) str() (string, error) { + q := p.peek() + triple := strings.Repeat(string(q), 3) + if strings.HasPrefix(p.s[p.i:], triple) { + p.i += 3 + if strings.HasPrefix(p.s[p.i:], "\n") { + p.i++ + } else if strings.HasPrefix(p.s[p.i:], "\r\n") { + p.i += 2 + } + end := strings.Index(p.s[p.i:], triple) + if end < 0 { + return "", errTOML + } + body := p.s[p.i : p.i+end] + p.i += end + 3 + if q == '"' { + return tomlUnescape(body), nil + } + return body, nil + } + p.i++ + var b strings.Builder + for !p.eof() { + c := p.peek() + switch { + case c == '\n': + return "", errTOML + case c == q: + p.i++ + if q == '"' { + return tomlUnescape(b.String()), nil + } + return b.String(), nil + case c == '\\' && q == '"' && p.i+1 < len(p.s): + b.WriteByte(c) + b.WriteByte(p.s[p.i+1]) + p.i += 2 + default: + b.WriteByte(c) + p.i++ + } + } + return "", errTOML +} + +// tomlUnescape decodes the escapes of a TOML basic string: \\ \" \b \t +// \n \f \r and \uXXXX / \UXXXXXXXX. An escape it does not know is kept +// as written. +func tomlUnescape(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c != '\\' || i+1 >= len(s) { + b.WriteByte(c) + continue + } + i++ + switch s[i] { + case '\\', '"': + b.WriteByte(s[i]) + case 'b': + b.WriteByte('\b') + case 't': + b.WriteByte('\t') + case 'n': + b.WriteByte('\n') + case 'f': + b.WriteByte('\f') + case 'r': + b.WriteByte('\r') + case 'u', 'U': + n := 4 + if s[i] == 'U' { + n = 8 + } + if i+n < len(s) { + if r, err := strconv.ParseUint(s[i+1:i+1+n], 16, 32); err == nil { + b.WriteRune(rune(r)) + i += n + continue + } + } + b.WriteByte('\\') + b.WriteByte(s[i]) + default: + b.WriteByte('\\') + b.WriteByte(s[i]) + } + } + return b.String() +} diff --git a/client-go/mcpconfig_bounds_test.go b/client-go/mcpconfig_bounds_test.go new file mode 100644 index 0000000..61a4bd5 --- /dev/null +++ b/client-go/mcpconfig_bounds_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +// Every value below is invented. Each case is a way a real config carries a +// credential on the command line; the record that leaves the machine must +// carry none of them, in any field. +func TestMCPRecordNeverCarriesASecretValue(t *testing.T) { + cases := []struct { + name string + e mcpEntry + secrets []string + }{ + {"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}}, + {"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}}, + {"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}}, + {"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}}, + {"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}}, + {"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}}, + {"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}}, + {"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}}, + {"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}}, + {"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}}, + {"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}}, + } + for _, c := range cases { + b, _ := json.Marshal(mcpAnalyze(c.e)) + for _, s := range c.secrets { + if strings.Contains(string(b), s) { + t.Errorf("%s: the record carries %q: %s", c.name, s, b) + } + } + } + // A running server no file declares goes through the same redaction. + out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js", + "postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true) + b, _ := json.Marshal(out) + if strings.Contains(string(b), "ShadowPw77x") { + t.Errorf("an undeclared server's record carries its password: %s", b) + } + // What is still kept: a reference to where the secret comes from, and + // the URL's host. + o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}}) + if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") { + t.Errorf("redaction took more than the secret: %q", o.Command) + } +} + +// One record, and the list, stay bounded however large the file's names are +// and however many processes look like servers. +func TestMCPRecordAndListStayBounded(t *testing.T) { + env := map[string]string{} + for i := 0; i < 5000; i++ { + env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = "" + } + o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx", + args: []string{"-y", strings.Repeat("p", 100000)}, env: env}) + if b, _ := json.Marshal(o); len(b) > 8192 { + t.Errorf("one stdio record is %d bytes", len(b)) + } + o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"}) + if b, _ := json.Marshal(o); len(b) > 8192 { + t.Errorf("one remote record is %d bytes", len(b)) + } + var procs []mcpProc + for i := 0; i < 3000; i++ { + procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}}) + } + if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers { + t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers) + } +} + +// A crafted config.toml nested thousands deep is refused, not walked on the +// stack; a realistic depth still parses. +func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) { + for name, src := range map[string]string{ + "arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n", + "inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n", + } { + if _, err := parseTOMLSubset(src); err == nil { + t.Errorf("%s nested 5000 deep parsed", name) + } + } + if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil { + t.Errorf("a realistic depth: %v", err) + } +} + +// macOS's nobody is uid -2 in its directory; it is a service account, not +// an unread uid. +func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) { + f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes}) + if f.Class != ownerClassService { + t.Errorf("uid -2: %+v", f) + } + if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown { + t.Errorf("an unread uid: %+v", f) + } +} + +// dscl that was killed by the timeout has not said the record is missing: +// only a real non-zero exit does. +func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("needs sh") + } + _, killed := exec.Command("sh", "-c", "kill -9 $$").Output() + _, missing := exec.Command("sh", "-c", "exit 56").Output() + if dsclRecordMissing(killed) { + t.Error("a killed dscl read as no local record") + } + if !dsclRecordMissing(missing) { + t.Error("dscl exiting 56 did not read as no local record") + } + if dsclRecordMissing(nil) { + t.Error("success read as no local record") + } +} + +// A read that stops answering (a home on a network mount) holds neither the +// beat nor a second reader, and a finished read is reused for the cache +// window instead of redone on every 60-second beat. +func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) { + oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout + block := make(chan struct{}) + t.Cleanup(func() { + close(block) + mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT + resetMCPConfigCache() + }) + resetMCPConfigCache() + var calls int32 + mcpHomesFn = func() ([]mcpHome, bool) { + atomic.AddInt32(&calls, 1) + <-block + return nil, true + } + mcpSystemFn = func() []mcpSource { return nil } + scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil } + mcpReadTimeout = 100 * time.Millisecond + + for beat := 0; beat < 3; beat++ { + done := make(chan mcpConfigScan, 1) + go func() { _, s := mcpServersReport(); done <- s }() + select { + case s := <-done: + if !s.TimedOut || !s.Supported { + t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s) + } + case <-time.After(5 * time.Second): + t.Fatalf("beat %d waited on a stuck read", beat) + } + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("%d reads started while one was stuck", n) + } + + resetMCPConfigCache() + atomic.StoreInt32(&calls, 0) + home := t.TempDir() + mcpHomesFn = func() ([]mcpHome, bool) { + atomic.AddInt32(&calls, 1) + return []mcpHome{{Dir: home, ConfigDir: home}}, true + } + for beat := 0; beat < 3; beat++ { + if _, s := mcpServersReport(); s.TimedOut { + t.Fatalf("beat %d timed out: %+v", beat, s) + } + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("the files were read %d times in one cache window", n) + } +} + +// A read stuck on a home that stopped answering delays ONE beat by at most +// mcpReadTimeout; the beats after it do not wait on the same read again. And +// until a read has finished, nothing is known to be declared, so no running +// process may be reported as running_not_declared. +func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) { + oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout + block := make(chan struct{}) + t.Cleanup(func() { + close(block) + mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT + resetMCPConfigCache() + }) + resetMCPConfigCache() + mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true } + mcpSystemFn = func() []mcpSource { return nil } + scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { + return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil + } + mcpReadTimeout = 400 * time.Millisecond + + start := time.Now() + out, s := mcpServersReport() + if took := time.Since(start); took < mcpReadTimeout/2 { + t.Fatalf("the first beat did not wait for the read at all (%v)", took) + } + if !s.TimedOut || len(out) != 0 { + t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out) + } + for beat := 1; beat < 4; beat++ { + start := time.Now() + out, s := mcpServersReport() + if took := time.Since(start); took > mcpReadTimeout/2 { + t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took) + } + if !s.TimedOut || len(out) != 0 { + t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out) + } + } +} diff --git a/client-go/mcpconfig_darwin.go b/client-go/mcpconfig_darwin.go new file mode 100644 index 0000000..dd924b8 --- /dev/null +++ b/client-go/mcpconfig_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" +) + +// The accounts whose agent clients mcpconfig.go reads on macOS: the service's +// own home and every directory under /Users except Shared. The per-user +// configuration directory is ~/Library/Application Support. +func mcpHomes() ([]mcpHome, bool) { + var homes []string + if h, err := os.UserHomeDir(); err == nil && h != "" { + homes = append(homes, h) + } + if entries, err := os.ReadDir("/Users"); err == nil { + for _, e := range entries { + if e.IsDir() && e.Name() != "Shared" && e.Name()[0] != '.' { + homes = append(homes, filepath.Join("/Users", e.Name())) + } + } + } + var out []mcpHome + for _, h := range dedupeStrings(homes) { + out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "Library", "Application Support")}) + } + return out, true +} + +func mcpSystemFiles() []mcpSource { return mcpSystemSources("darwin", "") } diff --git a/client-go/mcpconfig_linux.go b/client-go/mcpconfig_linux.go new file mode 100644 index 0000000..ae7631c --- /dev/null +++ b/client-go/mcpconfig_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package main + +import ( + "path/filepath" +) + +// The accounts whose agent clients mcpconfig.go reads on Linux: the same +// homes the app inventory reads (userHomes: the service's own home and every +// directory under /home). A home this process may not enter yields files it +// counts as unreadable, never as empty. The per-user configuration directory +// is the XDG default, ~/.config; another account's XDG_CONFIG_HOME lives in +// its own environment, which a system service does not see. +func mcpHomes() ([]mcpHome, bool) { + var out []mcpHome + for _, h := range userHomes() { + out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, ".config")}) + } + return out, true +} + +func mcpSystemFiles() []mcpSource { return mcpSystemSources("linux", "") } diff --git a/client-go/mcpconfig_linux_test.go b/client-go/mcpconfig_linux_test.go new file mode 100644 index 0000000..383372e --- /dev/null +++ b/client-go/mcpconfig_linux_test.go @@ -0,0 +1,38 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// A FIFO left where a client keeps its config must not hold the read, and +// with it the heartbeat: it is counted unreadable and the scan moves on. +func TestReadMCPConfigsDoesNotBlockOnAFIFO(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(home, ".cursor", "mcp.json"), 0o600); err != nil { + t.Skipf("cannot make a FIFO here: %v", err) + } + oldH, oldS := mcpHomesFn, mcpSystemFn + t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldH, oldS }) + mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true } + mcpSystemFn = func() []mcpSource { return nil } + + done := make(chan mcpConfigScan, 1) + go func() { _, scan := readMCPConfigs(); done <- scan }() + select { + case scan := <-done: + if scan.Unreadable != 1 { + t.Fatalf("the FIFO was not counted: %+v", scan) + } + case <-time.After(5 * time.Second): + t.Fatal("reading the config files blocked on a FIFO") + } +} diff --git a/client-go/mcpconfig_open_darwin.go b/client-go/mcpconfig_open_darwin.go new file mode 100644 index 0000000..e6b3320 --- /dev/null +++ b/client-go/mcpconfig_open_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// mcpOpenRegular opens path for reading only if what it names, after any +// symlinks, is a regular file, without ever opening anything else. macOS has +// no O_PATH, so the links are resolved first, the resolved path is required +// to be a regular file, and it is then opened with O_NOFOLLOW_ANY: a link +// put in its place since the look makes the open fail rather than lead +// somewhere new, and a device can only sit in devfs, which the look already +// refused. The opened file must be the one looked at. +func mcpOpenRegular(path string) (*os.File, string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, "", err + } + lst, err := os.Lstat(resolved) + if err != nil { + return nil, "", err + } + if !lst.Mode().IsRegular() { + return nil, "", errMCPNotRegular + } + fd, err := unix.Open(resolved, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_NOFOLLOW_ANY, 0) + if err != nil { + return nil, "", &os.PathError{Op: "open", Path: path, Err: err} + } + f := os.NewFile(uintptr(fd), path) + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, lst) { + f.Close() + return nil, "", errMCPNotRegular + } + return f, mcpFileOwner(fi), nil +} diff --git a/client-go/mcpconfig_open_linux.go b/client-go/mcpconfig_open_linux.go new file mode 100644 index 0000000..2ba8a42 --- /dev/null +++ b/client-go/mcpconfig_open_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package main + +import ( + "os" + "strconv" + + "golang.org/x/sys/unix" +) + +// mcpOpenRegular opens path for reading only if what it names, after any +// symlinks, is a regular file, without ever opening anything else. The first +// open is O_PATH: it resolves the path and pins the inode but does not open +// the file (no driver runs, a FIFO does not wait). Only once fstat says +// regular is that same inode reopened for reading through /proc/self/fd, so +// swapping the path between the look and the read reaches nothing new. +func mcpOpenRegular(path string) (*os.File, string, error) { + pfd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return nil, "", &os.PathError{Op: "open", Path: path, Err: err} + } + defer unix.Close(pfd) + var st unix.Stat_t + if err := unix.Fstat(pfd, &st); err != nil { + return nil, "", &os.PathError{Op: "fstat", Path: path, Err: err} + } + if st.Mode&unix.S_IFMT != unix.S_IFREG { + return nil, "", errMCPNotRegular + } + fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK, 0) + if err != nil { + return nil, "", &os.PathError{Op: "reopen", Path: path, Err: err} + } + f := os.NewFile(uintptr(fd), path) + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() { + f.Close() + return nil, "", errMCPNotRegular + } + return f, mcpFileOwner(fi), nil +} diff --git a/client-go/mcpconfig_open_linux_test.go b/client-go/mcpconfig_open_linux_test.go new file mode 100644 index 0000000..077dc69 --- /dev/null +++ b/client-go/mcpconfig_open_linux_test.go @@ -0,0 +1,136 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +// A config path that is, or links to, something other than a regular file +// is never OPENED for reading. Opening is not harmless: a device's driver +// runs on open (a watchdog arms, a serial line toggles DTR), so a symlink at +// ~/.cursor/mcp.json pointing at one would let any account make this root +// service act on hardware. inotify reports every real open of the target; +// the reader's look at what the path is must not be one. +func TestMCPReadFileNeverOpensANonRegularTarget(t *testing.T) { + dir := t.TempDir() + fifo := filepath.Join(dir, "target") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Skipf("cannot make a FIFO here: %v", err) + } + link := filepath.Join(dir, "mcp.json") + if err := os.Symlink(fifo, link); err != nil { + t.Fatal(err) + } + ino, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) + if err != nil { + t.Skipf("no inotify: %v", err) + } + defer unix.Close(ino) + if _, err := unix.InotifyAddWatch(ino, fifo, unix.IN_OPEN); err != nil { + t.Skipf("cannot watch: %v", err) + } + var scan mcpConfigScan + for _, p := range []string{link, fifo} { + if b, ok := mcpReadFile(p, &scan); ok || b != nil { + t.Fatalf("%s read as a config file", p) + } + } + buf := make([]byte, 4096) + if n, _ := unix.Read(ino, buf); n > 0 { + t.Fatalf("the FIFO was opened %d time(s) by the reader", n/unix.SizeofInotifyEvent) + } + if scan.Unreadable != 2 { + t.Fatalf("scan: %+v", scan) + } + // A regular file through a symlink is still read, as the client would. + real := filepath.Join(dir, "real.json") + if err := os.WriteFile(real, []byte(`{"mcpServers":{}}`), 0o600); err != nil { + t.Fatal(err) + } + link2 := filepath.Join(dir, "link2.json") + if err := os.Symlink(real, link2); err != nil { + t.Fatal(err) + } + if _, ok := mcpReadFile(link2, &scan); !ok { + t.Fatal("a symlink to a regular file was not read") + } +} + +// A file in one account's home, or in a project that account listed, is read +// only when that account or root owns it. Otherwise one account could point +// its own config path, or its list of projects, at another account's files +// and have this service read them on its behalf. +func TestReadMCPConfigsSkipsAFileAnotherAccountOwns(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("needs root to make files owned by other uids") + } + const owner, other = 42101, 42102 + root := t.TempDir() + if err := os.Chmod(root, 0o755); err != nil { + t.Fatal(err) + } + home := filepath.Join(root, "home", "demo") + victim := filepath.Join(root, "victim") + write := func(p, body string, uid int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chown(p, uid, uid); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(victim, "secret.json"), `{"mcpServers": {"victim-file": {"command": "demo-v"}}}`, other) + write(filepath.Join(victim, ".mcp.json"), `{"mcpServers": {"victim-project": {"command": "demo-w"}}}`, other) + write(filepath.Join(root, "rootowned.json"), `{"mcpServers": {"root-file": {"command": "demo-r"}}}`, 0) + projJSON, _ := json.Marshal(victim) + write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"own": {"command": "demo-o"}}, "projects": {`+string(projJSON)+`: {}}}`, owner) + if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(victim, "secret.json"), filepath.Join(home, ".cursor", "mcp.json")); err != nil { + t.Fatal(err) + } + // A dotfile manager's link to a root-owned file is the account's own. + if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "rootowned.json"), filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")); err != nil { + t.Fatal(err) + } + if err := os.Chown(home, owner, owner); err != nil { + t.Fatal(err) + } + + oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS + t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS }) + mcpGOOS = "linux" + mcpHomesFn = func() ([]mcpHome, bool) { + return []mcpHome{{Dir: home, ConfigDir: filepath.Join(home, ".config")}}, true + } + mcpSystemFn = func() []mcpSource { return nil } + + got, scan := readMCPConfigs() + names := map[string]bool{} + for _, o := range got { + names[o.Name] = true + } + if names["victim-file"] || names["victim-project"] { + t.Errorf("read another account's file on this account's behalf: %v", names) + } + if !names["own"] || !names["root-file"] { + t.Errorf("the account's own and root-owned files must still be read: %v", names) + } + if scan.ForeignOwner != 2 { + t.Errorf("scan: %+v", scan) + } +} diff --git a/client-go/mcpconfig_open_other.go b/client-go/mcpconfig_open_other.go new file mode 100644 index 0000000..ee0dd89 --- /dev/null +++ b/client-go/mcpconfig_open_other.go @@ -0,0 +1,33 @@ +//go:build !linux && !darwin && !windows + +package main + +import "os" + +// No account list on this platform (mcpconfig_other.go), so the reader never +// gets here; this keeps the package building. A path is still opened only +// when it was a regular file, and the opened file must be the one looked at. +func mcpOpenRegular(path string) (*os.File, string, error) { + st, err := os.Stat(path) + if err != nil { + return nil, "", err + } + if !st.Mode().IsRegular() { + return nil, "", errMCPNotRegular + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, st) { + f.Close() + return nil, "", errMCPNotRegular + } + return f, mcpOwnerUnknown, nil +} + +var mcpAdminOwners = map[string]bool{} + +// mcpPathOwner: no owner model here, so no owner check. +func mcpPathOwner(string) string { return mcpNoOwnerCheck } diff --git a/client-go/mcpconfig_open_windows.go b/client-go/mcpconfig_open_windows.go new file mode 100644 index 0000000..bb2f808 --- /dev/null +++ b/client-go/mcpconfig_open_windows.go @@ -0,0 +1,122 @@ +//go:build windows + +package main + +import ( + "os" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// The decisions are in mcpconfig_winopen.go; this file makes the calls. + +// The values mcpconfig_winopen.go spells out must be Windows' own: each line +// fails to compile if one differs. +var ( + _ = [1]struct{}{}[mcpWinFileTypeDisk^windows.FILE_TYPE_DISK] + _ = [1]struct{}{}[mcpWinAttrDirectory^windows.FILE_ATTRIBUTE_DIRECTORY] + _ = [1]struct{}{}[mcpWinAttrDevice^windows.FILE_ATTRIBUTE_DEVICE] + _ = [1]struct{}{}[mcpWinAttrReparse^windows.FILE_ATTRIBUTE_REPARSE_POINT] +) + +var mcpAdminOwners = mcpWinAdminOwners + +// mcpOpenRegular opens path with CreateFile and keeps it only when the +// handle is a regular file on disk (mcpWinOpenWith). It shares read, write +// and delete with everyone else: the agent client that owns the file must be +// able to save it, including by replacing it, while this reads it. +func mcpOpenRegular(path string) (*os.File, string, error) { + return mcpWinOpenWith(mcpWinSystemOps, path) +} + +var mcpWinSystemOps = mcpWinOps{ + openPath: mcpWinCreateFile, + handleInfo: mcpWinHandleFacts, + closeHandle: func(h uintptr) { windows.CloseHandle(windows.Handle(h)) }, + asFile: func(h uintptr, path string) *os.File { return os.NewFile(h, path) }, +} + +func mcpWinCreateFile(path string) (uintptr, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + h, err := windows.CreateFile(p, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, + windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + return uintptr(h), nil +} + +// mcpWinHandleFacts reads the type, the attributes and the owner from the +// handle. GENERIC_READ carries READ_CONTROL, which GetSecurityInfo needs for +// the owner; an owner that cannot be read is left "" and the file is then +// read only where no owner check applies. +func mcpWinHandleFacts(h uintptr) (mcpWinHandleInfo, error) { + wh := windows.Handle(h) + var i mcpWinHandleInfo + t, err := windows.GetFileType(wh) + if err != nil { + return i, err + } + i.FileType = t + if t != windows.FILE_TYPE_DISK { + return i, nil // refused by mcpWinRegular; nothing more to ask it + } + var bi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(wh, &bi); err != nil { + return i, err + } + i.Attributes = bi.FileAttributes + if sd, err := windows.GetSecurityInfo(wh, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION); err == nil { + if o, _, err := sd.Owner(); err == nil && o != nil { + i.Owner = o.String() + } + } + return i, nil +} + +// mcpPathOwner is the SID whose profile dir is, from the ProfileList the +// User Profile Service keeps +// (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\, +// value ProfileImagePath). A directory it does not name, or names twice, +// gives mcpOwnerUnknown, so only SYSTEM's and the Administrators group's +// files are read there. +func mcpPathOwner(dir string) string { + return mcpWinProfileSID(dir, mcpWinProfiles()) +} + +func mcpWinProfiles() map[string]string { + out := map[string]string{} + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS) + if err != nil { + return out + } + defer k.Close() + sids, err := k.ReadSubKeyNames(-1) + if err != nil { + return out + } + for _, sid := range sids { + sk, err := registry.OpenKey(k, sid, registry.QUERY_VALUE) + if err != nil { + continue + } + p, typ, err := sk.GetStringValue("ProfileImagePath") + sk.Close() + if err != nil { + continue + } + if typ == registry.EXPAND_SZ { + if x, err := registry.ExpandString(p); err == nil { + p = x + } + } + out[sid] = p + } + return out +} diff --git a/client-go/mcpconfig_other.go b/client-go/mcpconfig_other.go new file mode 100644 index 0000000..8519b7b --- /dev/null +++ b/client-go/mcpconfig_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin && !windows + +package main + +// No account list on this platform: the scan says unsupported rather than +// reporting a machine with no tool servers. +func mcpHomes() ([]mcpHome, bool) { return nil, false } + +func mcpSystemFiles() []mcpSource { return nil } diff --git a/client-go/mcpconfig_owner_unix.go b/client-go/mcpconfig_owner_unix.go new file mode 100644 index 0000000..0301d1d --- /dev/null +++ b/client-go/mcpconfig_owner_unix.go @@ -0,0 +1,36 @@ +//go:build linux || darwin + +package main + +import ( + "os" + "strconv" + "syscall" +) + +// mcpAdminOwners: root owns files in any account's home without that being +// another account's file. +var mcpAdminOwners = map[string]bool{"0": true} + +// mcpFileOwner is the uid that owns an opened file, as mcpOwnerAllowed +// compares it. +func mcpFileOwner(fi os.FileInfo) string { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + return strconv.FormatUint(uint64(st.Uid), 10) + } + return mcpOwnerUnknown // matches no account and not root: the file is skipped +} + +// mcpPathOwner is the uid that owns an account's home: the account whose +// files are read there. A home that cannot be looked at gives root, so only +// root-owned files would be read in it. +func mcpPathOwner(dir string) string { + st, err := os.Stat(dir) + if err != nil { + return "0" + } + if s, ok := st.Sys().(*syscall.Stat_t); ok { + return strconv.FormatUint(uint64(s.Uid), 10) + } + return "0" +} diff --git a/client-go/mcpconfig_test.go b/client-go/mcpconfig_test.go new file mode 100644 index 0000000..acfe63f --- /dev/null +++ b/client-go/mcpconfig_test.go @@ -0,0 +1,461 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// Every fixture here is invented: server names, packages, hosts, paths and +// the secret-shaped values, which are built from repeated letters so no real +// credential can be in this file. + +var ( + fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("Q", 40) + fakeGitHubToken = "ghp_" + strings.Repeat("Z", 36) + fakeLiteral = "plain-" + strings.Repeat("k", 12) +) + +func entriesByName(t *testing.T, format, body string) map[string]mcpEntry { + t.Helper() + es, _, err := mcpParse(format, []byte(body)) + if err != nil { + t.Fatalf("%s: %v", format, err) + } + out := map[string]mcpEntry{} + for _, e := range es { + out[e.name] = e + } + return out +} + +func TestMCPParseClaudeDesktopShape(t *testing.T) { + es := entriesByName(t, mcpFormatServers, `{"mcpServers": { + "files": {"command": "npx", "args": ["-y", "@demo-scope/server-files@1.4.2", "/srv/demo"]}, + "search": {"command": "uvx", "args": ["demo-search"], "env": {"DEMO_API_KEY": "`+fakeLiteral+`"}} + }, "otherSetting": true}`) + if len(es) != 2 || es["files"].command != "npx" || es["search"].env["DEMO_API_KEY"] == "" { + t.Fatalf("entries: %+v", es) + } +} + +func TestMCPParseClaudeCodeUserAndProjects(t *testing.T) { + body := `{"numStartups": 3, "mcpServers": {"u1": {"type": "stdio", "command": "demo-tool"}}, + "projects": { + "/home/demo/proj-a": {"mcpServers": {"p1": {"type": "http", "url": "https://tools.example.com/x"}}}, + "/home/demo/proj-b": {"allowedTools": []} + }}` + es, projects, err := mcpParse(mcpFormatClaude, []byte(body)) + if err != nil { + t.Fatal(err) + } + if len(es) != 2 || es[0].name != "u1" || es[0].projectScoped || es[1].name != "p1" || !es[1].projectScoped { + t.Fatalf("entries: %+v", es) + } + want := []string{filepath.FromSlash("/home/demo/proj-a"), filepath.FromSlash("/home/demo/proj-b")} + if !reflect.DeepEqual(projects, want) { + t.Fatalf("projects: %v", projects) + } +} + +func TestMCPParseVSCodeJSONC(t *testing.T) { + es := entriesByName(t, mcpFormatVSCode, `{ + // a comment the file is allowed to carry + "inputs": [{"id": "tok", "type": "promptString", "password": true}], + "servers": { + "remote": {"type": "sse", "url": "https://tools.example.com/sse", "headers": {"Authorization": "Bearer ${input:tok}"},}, + /* a block comment, "with a quote" */ + "local": {"command": "node", "args": ["/opt/demo/server.js"],}, + }, + }`) + if es["remote"].typ != "sse" || es["local"].command != "node" || len(es) != 2 { + t.Fatalf("entries: %+v", es) + } + // A // inside a string is data, not a comment. + es = entriesByName(t, mcpFormatVSCode, `{"servers": {"u": {"url": "https://h.example.com//x"}}}`) + if es["u"].url != "https://h.example.com//x" { + t.Fatalf("a URL lost its slashes: %q", es["u"].url) + } +} + +func TestMCPParseZedBothCommandForms(t *testing.T) { + es := entriesByName(t, mcpFormatZed, `{ + "theme": "One Dark", // Zed's settings carry everything else too + "context_servers": { + "flat": {"source": "custom", "command": "demo-mcp-server", "args": ["--stdio"], "env": {"DEMO_TOKEN": "`+fakeLiteral+`"}}, + "nested": {"command": {"path": "/usr/local/bin/demo", "args": ["serve"], "env": {"LOG_LEVEL": "info"}}}, + "remote": {"url": "https://zed-tools.example.com/mcp", "headers": {"X-Api-Key": "`+fakeLiteral+`"}} + } + }`) + if es["flat"].command != "demo-mcp-server" || es["nested"].command != "/usr/local/bin/demo" || + !reflect.DeepEqual(es["nested"].args, []string{"serve"}) || es["nested"].env["LOG_LEVEL"] != "info" || + es["remote"].headers["X-Api-Key"] == "" { + t.Fatalf("entries: %+v", es) + } +} + +func TestMCPParseWindsurfServerURL(t *testing.T) { + es := entriesByName(t, mcpFormatServers, `{"mcpServers": {"w": {"serverUrl": "https://ws-tools.example.com/mcp"}}}`) + if es["w"].url != "https://ws-tools.example.com/mcp" { + t.Fatalf("serverUrl not read: %+v", es) + } +} + +func TestMCPParseCodexTOML(t *testing.T) { + body := `# invented Codex config +model = "demo-model" + +[mcp_servers.docs] +command = "npx" +args = [ + "-y", # a comment inside an array + "demo-docs-server@2.0.1", +] +env = { "DEMO_KEY" = "` + fakeLiteral + `", PLAIN = 'x' } +env_vars = ["HOME_HINT", { name = "FORWARDED", source = "local" }] +startup_timeout_sec = 20 + +[mcp_servers."remote tools"] +url = "https://codex-tools.example.com/mcp" +bearer_token_env_var = "DEMO_BEARER" +enabled = false + +[mcp_servers.docs.tools.search] +approval_mode = "approve" + +[[profiles_list]] +command = "not a server" +` + es := entriesByName(t, mcpFormatCodex, body) + d := es["docs"] + if d.command != "npx" || !reflect.DeepEqual(d.args, []string{"-y", "demo-docs-server@2.0.1"}) || + d.env["DEMO_KEY"] == "" || d.env["PLAIN"] != "x" || + !reflect.DeepEqual(d.envNames, []string{"HOME_HINT", "FORWARDED"}) { + t.Fatalf("docs: %+v", d) + } + r := es["remote tools"] + if r.url == "" || !r.authHint || !r.disabled || !reflect.DeepEqual(r.envNames, []string{"DEMO_BEARER"}) { + t.Fatalf("remote: %+v", r) + } + if len(es) != 2 { + t.Fatalf("an [[array of tables]] leaked in, or a server was lost: %v", es) + } + if _, err := parseTOMLSubset("[mcp_servers.x]\ncommand = \"unterminated\n"); err == nil { + t.Fatal("a broken line must fail the file, not half-read it") + } + if v, _ := parseTOMLSubset(`k = "a\tb\u00e9\"q"`); v["k"] != "a\tbé\"q" { + t.Fatalf("escapes: %q", v["k"]) + } +} + +// THE VALUE NEVER LEAVES. A secret in env, a header, an argument or the URL +// sets one bool; the marshalled record carries the variable's NAME and +// nothing of the value. +func TestMCPAnalyzeKeepsNamesAndDropsValues(t *testing.T) { + cases := []struct { + name string + e mcpEntry + }{ + {"env, known prefix", mcpEntry{name: "a", command: "npx", args: []string{"demo"}, + env: map[string]string{"ANTHROPIC_API_KEY": fakeAnthropicKey}}}, + {"env, known prefix under a plain name", mcpEntry{name: "a2", command: "demo", + env: map[string]string{"DEMO_SETTING": fakeAnthropicKey}}}, + {"env, literal under a secret name", mcpEntry{name: "b", command: "demo", + env: map[string]string{"SERVICE_TOKEN": fakeLiteral}}}, + {"header", mcpEntry{name: "c", url: "https://h.example.com/mcp", + headers: map[string]string{"Authorization": "Bearer " + fakeLiteral}}}, + {"argument after a flag", mcpEntry{name: "d", command: "demo", args: []string{"--api-key", fakeLiteral}}}, + {"argument with =", mcpEntry{name: "e", command: "demo", args: []string{"--token=" + fakeLiteral}}}, + {"bare token argument", mcpEntry{name: "f", command: "demo", args: []string{fakeGitHubToken}}}, + {"docker -e NAME=value", mcpEntry{name: "g", command: "docker", + args: []string{"run", "-i", "--rm", "-e", "GITHUB_TOKEN=" + fakeLiteral, "ghcr.io/demo/server"}}}, + {"URL query", mcpEntry{name: "h", url: "https://h.example.com/mcp?api_key=" + fakeLiteral}}, + {"URL password", mcpEntry{name: "i", url: "https://u:" + fakeLiteral + "@h.example.com/mcp"}}, + } + for _, c := range cases { + o := mcpAnalyze(c.e) + if !o.SecretInPlaintext { + t.Errorf("%s: the secret was not seen", c.name) + } + b, _ := json.Marshal(o) + for _, v := range []string{fakeAnthropicKey, fakeLiteral, fakeGitHubToken} { + if strings.Contains(string(b), v) { + t.Errorf("%s: a value left in the record: %s", c.name, b) + } + } + } + o := mcpAnalyze(mcpEntry{name: "a", command: "npx", args: []string{"demo"}, + env: map[string]string{"B_KEY": "${env:B_KEY}", "A_URL": "https://h.example.com", "LOG": "debug", + "KEY_FILE": "/etc/demo/key.pem"}, envNames: []string{"FORWARDED", "LOG"}}) + if o.SecretInPlaintext { + t.Fatal("a reference, a URL, a path and a short value are not secrets in plain text") + } + if !reflect.DeepEqual(o.EnvNames, []string{"A_URL", "B_KEY", "FORWARDED", "KEY_FILE", "LOG"}) { + t.Fatalf("env names: %v", o.EnvNames) + } + if b, _ := json.Marshal(o); strings.Contains(string(b), "debug") || strings.Contains(string(b), "key.pem") { + t.Fatalf("an env value travelled: %s", b) + } +} + +func TestMCPPackageAndPinned(t *testing.T) { + cases := []struct { + cmd string + args []string + source, name, pinned string + }{ + {"npx", []string{"-y", "@demo-scope/server-files@1.4.2", "/srv"}, "npx", "@demo-scope/server-files", "yes"}, + {"npx", []string{"-y", "@demo-scope/server-files"}, "npx", "@demo-scope/server-files", "no"}, + {"npx", []string{"demo-server@latest"}, "npx", "demo-server", "no"}, + {"npx", []string{"demo-server@^1.2.0"}, "npx", "demo-server", "no"}, + {"npx", []string{"--package", "demo-pkg@3.0.0", "demo-bin"}, "npx", "demo-pkg", "yes"}, + {"C:\\Program Files\\nodejs\\npx.cmd", []string{"-y", "demo-win@0.1.0"}, "npx", "demo-win", "yes"}, + {"pnpm", []string{"dlx", "demo-dlx@1.0.0"}, "npx", "demo-dlx", "yes"}, + {"npx", []string{"github:demo/repo"}, "npx", "github:demo/repo", "unknown"}, + {"uvx", []string{"demo-py==0.3.1"}, "uvx", "demo-py", "yes"}, + {"uvx", []string{"demo-py@0.3.1"}, "uvx", "demo-py", "yes"}, + {"uvx", []string{"--from", "demo-py>=0.3", "demo-cli"}, "uvx", "demo-py", "no"}, + {"uvx", []string{"demo-py"}, "uvx", "demo-py", "no"}, + {"uv", []string{"tool", "run", "demo-py==1.0"}, "uvx", "demo-py", "yes"}, + {"pipx", []string{"run", "demo-pipx==2.1"}, "pip", "demo-pipx", "yes"}, + {"docker", []string{"run", "-i", "--rm", "-e", "X", "ghcr.io/demo/srv:1.2"}, "docker", "ghcr.io/demo/srv", "no"}, + {"docker", []string{"run", "--rm", "localhost:5000/demo/srv@sha256:" + strings.Repeat("a", 64)}, "docker", "localhost:5000/demo/srv", "yes"}, + {"docker", []string{"run", "-i", "demo/srv"}, "docker", "demo/srv", "no"}, + {"node", []string{"/opt/demo/server.js"}, "unknown", "", "unknown"}, + {"/usr/local/bin/demo-mcp-server", []string{"stdio"}, "binary", "", "unknown"}, + } + for _, c := range cases { + s, n, p := mcpPackage(c.cmd, c.args) + if s != c.source || n != c.name || p != c.pinned { + t.Errorf("%s %v: got %s %q %s, want %s %q %s", c.cmd, c.args, s, n, p, c.source, c.name, c.pinned) + } + } +} + +func TestMCPTransportLocalityAuth(t *testing.T) { + cases := []struct { + e mcpEntry + transport, locality, auth, urlHst string + }{ + {mcpEntry{command: "demo"}, "stdio", "local_process", "not_applicable", ""}, + {mcpEntry{typ: "streamable-http", url: "https://tools.example.com/mcp"}, "http", "remote", "unknown", "tools.example.com"}, + {mcpEntry{url: "https://tools.example.com/sse"}, "sse", "remote", "unknown", "tools.example.com"}, + {mcpEntry{url: "wss://tools.example.com/x"}, "websocket", "remote", "unknown", "tools.example.com"}, + {mcpEntry{typ: "ws", url: "ws://127.0.0.1:9000"}, "websocket", "loopback", "unknown", "127.0.0.1"}, + {mcpEntry{url: "http://localhost:3333/mcp"}, "http", "loopback", "unknown", "localhost"}, + {mcpEntry{url: "http://10.20.30.40:80/mcp"}, "http", "lan", "unknown", "10.20.30.40"}, + {mcpEntry{url: "http://toolbox.internal/mcp"}, "http", "lan", "unknown", "toolbox.internal"}, + {mcpEntry{url: "http://toolbox/mcp"}, "http", "lan", "unknown", "toolbox"}, + {mcpEntry{url: "https://t.example.com/mcp", headers: map[string]string{"Authorization": "Bearer ${env:T}"}}, "http", "remote", "yes", "t.example.com"}, + {mcpEntry{url: "https://t.example.com/mcp", authHint: true}, "http", "remote", "yes", "t.example.com"}, + {mcpEntry{typ: "carrier-pigeon", url: "https://t.example.com"}, "unknown", "remote", "unknown", "t.example.com"}, + } + for _, c := range cases { + o := mcpAnalyze(c.e) + if o.Transport != c.transport || o.Locality != c.locality || o.AuthConfigured != c.auth || o.URLHost != c.urlHst { + t.Errorf("%+v: got %s %s %s %q", c.e, o.Transport, o.Locality, o.AuthConfigured, o.URLHost) + } + if c.transport == "stdio" && o.BindClass != "not_applicable" || c.transport != "stdio" && o.BindClass != "unknown" { + t.Errorf("%+v: bind %s", c.e, o.BindClass) + } + } + // The command is the cmdline handling's: joined, and cut at 200. + long := mcpAnalyze(mcpEntry{command: "demo", args: []string{strings.Repeat("a", 300)}}) + if len(long.Command) != 200 { + t.Fatalf("command not truncated like a cmdline: %d", len(long.Command)) + } +} + +func TestCorrelateMCPStates(t *testing.T) { + declared := []mcpServerObs{ + mcpAnalyze(mcpEntry{name: "files", command: "npx", args: []string{"-y", "@demo-scope/server-files@1.4.2"}}), + mcpAnalyze(mcpEntry{name: "idle", command: "uvx", args: []string{"demo-idle"}}), + mcpAnalyze(mcpEntry{name: "bin", command: "/opt/demo/bin/demo-tool", args: []string{"serve"}}), + mcpAnalyze(mcpEntry{name: "saas", url: "https://tools.example.com/mcp"}), + mcpAnalyze(mcpEntry{name: "local-url", url: "http://127.0.0.1:7000/mcp"}), + } + procs := []mcpProc{ + {PID: 10, PPID: 1, Argv: []string{"demo-client"}}, + {PID: 20, PPID: 10, Argv: []string{"npm", "exec", "-y", "@demo-scope/server-files@1.4.2"}}, + // npx's child: no package name in its own line, inherits the match. + {PID: 21, PPID: 20, Argv: []string{"node", "/home/demo/.npm/_npx/abc/node_modules/.bin/mcp-server-files"}}, + {PID: 30, PPID: 10, Argv: []string{"/opt/demo/bin/demo-tool", "serve"}}, + // Shadow: a server nobody declared, and its own child (collapsed). + {PID: 40, PPID: 1, Argv: []string{"node", "/opt/x/node_modules/@modelcontextprotocol/server-memory/dist/index.js"}}, + {PID: 41, PPID: 40, Argv: []string{"node", "/opt/x/mcp-worker.js"}}, + {PID: 50, PPID: 1, Argv: []string{"/usr/bin/github-mcp-server", "stdio", "--token=" + fakeGitHubToken}}, + // Not servers: an agent run with an MCP flag, and a grep. + {PID: 60, PPID: 1, Argv: []string{"claude", "--mcp-config", "/tmp/mcp.json"}}, + {PID: 61, PPID: 1, Argv: []string{"grep", "-r", "mcp-server", "/srv"}}, + {PID: 70, PPID: 1}, // a process whose command line was not read + } + got := correlateMCP(declared, procs, true) + state := map[string]mcpServerObs{} + var shadows []mcpServerObs + for _, o := range got { + if o.RunningState == mcpStateShadow { + shadows = append(shadows, o) + continue + } + state[o.Name] = o + } + if s := state["files"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{20, 21}) { + t.Errorf("files: %s %v", s.RunningState, s.PIDs) + } + if s := state["idle"]; s.RunningState != mcpStateIdle { + t.Errorf("idle: %s", s.RunningState) + } + if s := state["bin"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{30}) { + t.Errorf("bin: %s %v", s.RunningState, s.PIDs) + } + if s := state["saas"]; s.RunningState != mcpStateNotLocal { + t.Errorf("saas: %s", s.RunningState) + } + if s := state["local-url"]; s.RunningState != mcpStateUnknown { + t.Errorf("local-url: %s", s.RunningState) + } + if len(shadows) != 2 || !reflect.DeepEqual(shadows[0].PIDs, []int{40}) || !reflect.DeepEqual(shadows[1].PIDs, []int{50}) { + t.Fatalf("shadow servers: %+v", shadows) + } + if !shadows[1].SecretInPlaintext || strings.Contains(shadows[1].Command, fakeGitHubToken) { + t.Fatalf("a running server's secret argument travelled: %+v", shadows[1]) + } + + // No process list: nothing is guessed, and no shadow is invented. + for _, o := range correlateMCP(declared, nil, false) { + if o.RunningState == mcpStateShadow || (o.Transport == "stdio" && o.RunningState != mcpStateUnknown) { + t.Errorf("with no process list %s read %s", o.Name, o.RunningState) + } + } +} + +// The whole reader over invented homes: the per-OS path table, the projects +// Claude Code lists, the system file, and an unreadable file that must be +// counted rather than read as empty. +func TestReadMCPConfigsOverFixtureHomes(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home", "demo") + proj := filepath.Join(root, "work", "proj") + write := func(p, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + h := mcpHome{Dir: home, ConfigDir: filepath.Join(home, ".config")} + projJSON, _ := json.Marshal(proj) + write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"u": {"command": "demo-u"}}, + "projects": {`+string(projJSON)+`: {}}}`) + write(filepath.Join(home, ".cursor", "mcp.json"), `{"mcpServers": {"c": {"url": "https://c.example.com/mcp"}}}`) + write(filepath.Join(home, ".codex", "config.toml"), "[mcp_servers.x]\ncommand = \"demo-x\"\n") + write(filepath.Join(proj, ".mcp.json"), `{"mcpServers": {"p": {"command": "demo-p"}}}`) + write(filepath.Join(proj, ".vscode", "mcp.json"), `{"servers": {"v": {"command": "demo-v"},},}`) + write(filepath.Join(home, ".config", "zed", "settings.json"), `{"context_servers": {"z": {"command": "demo-z"}`) // broken + // A directory where a file should be: present, and unreadable as a file. + if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), 0o755); err != nil { + t.Fatal(err) + } + sys := filepath.Join(root, "etc", "managed-mcp.json") + write(sys, `{"mcpServers": {"s": {"command": "demo-s"}}}`) + + oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS + t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS }) + mcpGOOS = "linux" + mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{h}, true } + mcpSystemFn = func() []mcpSource { return []mcpSource{{mcpClientCode, mcpScopeSystem, sys, mcpFormatServers}} } + + got, scan := readMCPConfigs() + where := map[string]string{} + for _, o := range got { + where[o.Name] = o.Client + "/" + o.Scope + } + want := map[string]string{"s": "claude-code/system", "u": "claude-code/user", "c": "cursor/user", + "x": "codex/user", "p": "claude-code/project", "v": "vscode/project"} + if !reflect.DeepEqual(where, want) { + t.Fatalf("servers: %v", where) + } + if scan.Accounts != 1 || scan.Projects != 1 || scan.Unparsed != 1 || scan.Unreadable != 1 || scan.FilesRead != 7 { + t.Fatalf("scan: %+v", scan) + } + + mcpHomesFn = func() ([]mcpHome, bool) { return nil, false } + if got, scan := readMCPConfigs(); got != nil || scan.Supported { + t.Fatal("an unsupported platform must say so, not report an empty machine") + } +} + +// The path table names each client's file on each platform. +func TestMCPConfigSourcesPerPlatform(t *testing.T) { + find := func(srcs []mcpSource, client, suffix string) bool { + for _, s := range srcs { + if s.Client == client && strings.HasSuffix(filepath.ToSlash(s.Path), suffix) { + return true + } + } + return false + } + mac := mcpConfigSources("darwin", mcpHome{Dir: "/Users/demo", ConfigDir: "/Users/demo/Library/Application Support"}) + win := mcpConfigSources("windows", mcpHome{Dir: `C:\Users\demo`, ConfigDir: `C:\Users\demo\AppData\Roaming`}) + lin := mcpConfigSources("linux", mcpHome{Dir: "/home/demo", ConfigDir: "/home/demo/.config"}) + for _, c := range []struct { + srcs []mcpSource + client, suffix string + }{ + {mac, mcpClientDesktop, "Library/Application Support/Claude/claude_desktop_config.json"}, + {win, mcpClientDesktop, "Roaming/Claude/claude_desktop_config.json"}, + {lin, mcpClientCode, "/home/demo/.claude.json"}, + {lin, mcpClientCursor, ".cursor/mcp.json"}, + {mac, mcpClientVSCode, "Application Support/Code/User/mcp.json"}, + {lin, mcpClientWindsurf, ".config/devin/mcp_config.json"}, + {lin, mcpClientWindsurf, ".codeium/windsurf/mcp_config.json"}, + {lin, mcpClientCodex, ".codex/config.toml"}, + {mac, mcpClientZed, "/Users/demo/.config/zed/settings.json"}, + {win, mcpClientZed, "Roaming/Zed/settings.json"}, + } { + if !find(c.srcs, c.client, filepath.ToSlash(c.suffix)) { + t.Errorf("%s: no source ending %s", c.client, c.suffix) + } + } + if s := mcpSystemSources("windows", `C:\Program Files`); len(s) != 1 || !strings.HasSuffix(filepath.ToSlash(s[0].Path), "ClaudeCode/managed-mcp.json") { + t.Errorf("windows system sources: %v", s) + } +} + +// The heartbeat's report: a process walk that failed says so, and every +// local server then reads unknown rather than "not running". +func TestMCPServersReportWhenTheProcessWalkFails(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, ".claude.json"), + []byte(`{"mcpServers": {"u": {"command": "demo-u"}}}`), 0o600); err != nil { + t.Fatal(err) + } + oldH, oldS, oldP := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn + t.Cleanup(func() { mcpHomesFn, mcpSystemFn, scanMCPProcessesFn = oldH, oldS, oldP }) + resetMCPConfigCache() + t.Cleanup(resetMCPConfigCache) + mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true } + mcpSystemFn = func() []mcpSource { return nil } + + scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, os.ErrPermission } + got, scan := mcpServersReport() + if len(got) != 1 || got[0].RunningState != mcpStateUnknown || scan.ProcessScan == "ok" { + t.Fatalf("failed walk: %+v %+v", got, scan) + } + + scanMCPProcessesFn = func(want map[string]bool) ([]mcpProc, error) { + if !want["demo-u"] { + t.Error("the declared program was not in the set whose command lines are read") + } + return []mcpProc{{PID: 9, PPID: 1, Argv: []string{"/usr/bin/demo-u"}}}, nil + } + got, scan = mcpServersReport() + if len(got) != 1 || got[0].RunningState != mcpStateRunning || scan.ProcessScan != "ok" { + t.Fatalf("walk: %+v %+v", got, scan) + } +} diff --git a/client-go/mcpconfig_windows.go b/client-go/mcpconfig_windows.go new file mode 100644 index 0000000..77f673b --- /dev/null +++ b/client-go/mcpconfig_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package main + +import ( + "os" + "path/filepath" + "strings" +) + +// The accounts whose agent clients mcpconfig.go reads on Windows: every +// profile directory beside the Public one (normally C:\Users\*), except the +// templates and shared profiles Windows keeps there. %USERPROFILE% is not +// used to find them: under LocalSystem it is the system profile, not a +// person's. The per-user configuration directory is AppData\Roaming, which +// is what %APPDATA% names for that account. +func mcpHomes() ([]mcpHome, bool) { + root := "" + if pub := os.Getenv("PUBLIC"); pub != "" { + root = filepath.Dir(pub) + } else if drive := os.Getenv("SystemDrive"); drive != "" { + root = drive + `\Users` + } + var homes []string + if h, err := os.UserHomeDir(); err == nil && h != "" { + homes = append(homes, h) + } + if root != "" { + if entries, err := os.ReadDir(root); err == nil { + for _, e := range entries { + switch strings.ToLower(e.Name()) { + case "public", "default", "default user", "all users", "defaultapppool": + continue + } + if e.IsDir() { + homes = append(homes, filepath.Join(root, e.Name())) + } + } + } + } + var out []mcpHome + for _, h := range dedupeStrings(homes) { + out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "AppData", "Roaming")}) + } + return out, true +} + +func mcpSystemFiles() []mcpSource { return mcpSystemSources("windows", os.Getenv("ProgramFiles")) } diff --git a/client-go/mcpconfig_winopen.go b/client-go/mcpconfig_winopen.go new file mode 100644 index 0000000..bbce18e --- /dev/null +++ b/client-go/mcpconfig_winopen.go @@ -0,0 +1,112 @@ +package main + +import ( + "os" + "sort" + "strings" +) + +// THE WINDOWS OPEN, DECIDED WHERE A LINUX TEST CAN SEE IT. +// +// mcpconfig_open_windows.go opens a config path with CreateFile and asks the +// HANDLE what it holds; every decision about the answer is here, with no +// build tag, so the Linux test run checks the Windows rules with fake handles +// (mcpconfig_winopen_test.go). Nothing here calls Windows. +// +// The open does not follow a reparse point at the last component +// (FILE_FLAG_OPEN_REPARSE_POINT): a symbolic link, junction or other +// reparse point at a config path is opened as itself, seen as a reparse +// point, and refused. What is checked is the handle that will be read, so +// there is no gap between a look and an open for a swap to fall into. A +// directory, a device, a pipe or anything else that is not a file on disk is +// refused the same way. A reparse point in a directory ABOVE the file is +// followed, as for any program opening the path; the file it leads to must +// then be owned by the account whose profile it is read for, or by SYSTEM or +// the Administrators group (mcpOwnerAllowed), so a link into another +// account's profile reads nothing of it. + +// The Windows values the decision reads, from the SDK headers (winbase.h, +// winnt.h) as golang.org/x/sys/windows carries them: FILE_TYPE_DISK, +// FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_REPARSE_POINT. +const ( + mcpWinFileTypeDisk = 0x0001 + mcpWinAttrDirectory = 0x0010 + mcpWinAttrDevice = 0x0040 + mcpWinAttrReparse = 0x0400 +) + +// mcpWinAdminOwners: the owners a file in any account's profile may have +// besides that account. SYSTEM (S-1-5-18), and the Administrators group +// (S-1-5-32-544), which is the default owner of what an elevated +// administrator creates. +var mcpWinAdminOwners = map[string]bool{"S-1-5-18": true, "S-1-5-32-544": true} + +// mcpWinHandleInfo is what the open read from the handle it holds. +type mcpWinHandleInfo struct { + FileType uint32 // GetFileType + Attributes uint32 // GetFileInformationByHandle's FileAttributes + Owner string // the owner SID from GetSecurityInfo; "" when unread +} + +// mcpWinRegular: the handle is a file on disk that is not a directory, a +// device or a reparse point. +func mcpWinRegular(i mcpWinHandleInfo) bool { + return i.FileType == mcpWinFileTypeDisk && + i.Attributes&(mcpWinAttrDirectory|mcpWinAttrDevice|mcpWinAttrReparse) == 0 +} + +// mcpWinOps are the Windows calls the open makes, as values so a test can +// stand fakes in for them. The field names are not method names anywhere in +// the package: the claims test resolves a call through a value by name. +type mcpWinOps struct { + openPath func(path string) (uintptr, error) + handleInfo func(h uintptr) (mcpWinHandleInfo, error) + closeHandle func(h uintptr) + asFile func(h uintptr, path string) *os.File +} + +// mcpWinOpenWith opens path, checks the handle, and hands back the file and +// its owner SID. A handle that is refused is closed before return and never +// becomes a file. +func mcpWinOpenWith(ops mcpWinOps, path string) (*os.File, string, error) { + h, err := ops.openPath(path) + if err != nil { + return nil, "", err + } + info, err := ops.handleInfo(h) + if err != nil { + ops.closeHandle(h) + return nil, "", &os.PathError{Op: "query", Path: path, Err: err} + } + if !mcpWinRegular(info) { + ops.closeHandle(h) + return nil, "", errMCPNotRegular + } + owner := info.Owner + if owner == "" { + owner = mcpOwnerUnknown + } + return ops.asFile(h, path), owner, nil +} + +// mcpWinProfileSID is the SID whose profile directory is dir, from the +// ProfileList (SID to ProfileImagePath, already expanded). mcpOwnerUnknown +// when no SID or more than one names it; only an administrator's or +// SYSTEM's files are then read there. +func mcpWinProfileSID(dir string, profiles map[string]string) string { + norm := func(p string) string { + return strings.ToLower(strings.TrimRight(strings.ReplaceAll(p, "/", `\`), `\`)) + } + want := norm(dir) + var hits []string + for sid, p := range profiles { + if strings.HasPrefix(sid, "S-1-") && !strings.Contains(sid, ".") && norm(p) == want { + hits = append(hits, sid) + } + } + sort.Strings(hits) + if len(hits) != 1 { + return mcpOwnerUnknown + } + return hits[0] +} diff --git a/client-go/mcpconfig_winopen_test.go b/client-go/mcpconfig_winopen_test.go new file mode 100644 index 0000000..90d0ecb --- /dev/null +++ b/client-go/mcpconfig_winopen_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// The Windows open's decisions, with fake handles: Windows itself never runs +// here, and nothing in CI runs on Windows, so this is what checks the rules. +// The SIDs are invented. + +const ( + testWinSIDOwner = "S-1-5-21-1000000001-1000000002-1000000003-1201" + testWinSIDOther = "S-1-5-21-1000000001-1000000002-1000000003-1202" +) + +type fakeWinHandles struct { + info mcpWinHandleInfo + infoErr error + openErr error + openCount int + closed []uintptr + files []uintptr + backing string // a real file the "handle" reads from +} + +func (f *fakeWinHandles) ops(t *testing.T) mcpWinOps { + return mcpWinOps{ + openPath: func(string) (uintptr, error) { + if f.openErr != nil { + return 0, f.openErr + } + f.openCount++ + return uintptr(100 + f.openCount), nil + }, + handleInfo: func(uintptr) (mcpWinHandleInfo, error) { return f.info, f.infoErr }, + closeHandle: func(h uintptr) { f.closed = append(f.closed, h) }, + asFile: func(h uintptr, path string) *os.File { + f.files = append(f.files, h) + r, err := os.Open(f.backing) + if err != nil { + t.Fatal(err) + } + return r + }, + } +} + +func TestMCPWinOpenRefusesWhatIsNotARegularFileOnDisk(t *testing.T) { + backing := filepath.Join(t.TempDir(), "mcp.json") + if err := os.WriteFile(backing, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + refused := map[string]mcpWinHandleInfo{ + "symbolic link or junction (reparse point)": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrReparse | 0x20, Owner: testWinSIDOwner}, + "directory": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDirectory, Owner: testWinSIDOwner}, + "device attribute": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDevice, Owner: testWinSIDOwner}, + "character device": {FileType: 0x0002, Owner: testWinSIDOwner}, + "pipe": {FileType: 0x0003, Owner: testWinSIDOwner}, + "unknown type": {FileType: 0x0000, Owner: testWinSIDOwner}, + } + for name, info := range refused { + fk := &fakeWinHandles{info: info, backing: backing} + f, _, err := mcpWinOpenWith(fk.ops(t), `C:\Users\demo\.cursor\mcp.json`) + if f != nil || !errors.Is(err, errMCPNotRegular) { + t.Errorf("%s: opened (err %v)", name, err) + } + if len(fk.closed) != 1 || len(fk.files) != 0 { + t.Errorf("%s: handle not closed, or made into a file: closed %v files %v", name, fk.closed, fk.files) + } + } + + fk := &fakeWinHandles{infoErr: errors.New("query failed"), backing: backing} + if f, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); f != nil || err == nil || len(fk.closed) != 1 { + t.Errorf("a handle that cannot be queried must be closed and refused: %v %v", err, fk.closed) + } + + fk = &fakeWinHandles{openErr: &os.PathError{Op: "open", Path: `C:\x`, Err: os.ErrNotExist}, backing: backing} + if _, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a missing file must stay a missing file (not counted unreadable): %v", err) + } + + // A plain file (FILE_ATTRIBUTE_ARCHIVE) is opened, and its owner comes back. + fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: testWinSIDOwner}, backing: backing} + f, owner, err := mcpWinOpenWith(fk.ops(t), `C:\x`) + if err != nil || f == nil || owner != testWinSIDOwner || len(fk.closed) != 0 { + t.Fatalf("a regular file was refused: %v %q %v", err, owner, fk.closed) + } + f.Close() + + // An owner that could not be read is never an owner that matches. + fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x80}, backing: backing} + f, owner, err = mcpWinOpenWith(fk.ops(t), `C:\x`) + if err != nil || owner != mcpOwnerUnknown { + t.Fatalf("unread owner: %v %q", err, owner) + } + f.Close() + if mcpOwnerAllowed(owner, mcpOwnerUnknown, mcpWinAdminOwners) { + t.Fatal("an unread owner matched an unknown profile owner") + } +} + +func TestMCPWinOwnerRules(t *testing.T) { + for _, c := range []struct { + file, want string + ok bool + }{ + {testWinSIDOwner, testWinSIDOwner, true}, + {"S-1-5-18", testWinSIDOwner, true}, // SYSTEM + {"S-1-5-32-544", testWinSIDOwner, true}, // Administrators + {testWinSIDOther, testWinSIDOwner, false}, + {"S-1-1-0", testWinSIDOwner, false}, // Everyone + {"", testWinSIDOwner, false}, + {mcpOwnerUnknown, testWinSIDOwner, false}, + {testWinSIDOther, mcpOwnerUnknown, false}, + {"S-1-5-18", mcpOwnerUnknown, true}, + {testWinSIDOther, mcpNoOwnerCheck, true}, + } { + if got := mcpOwnerAllowed(c.file, c.want, mcpWinAdminOwners); got != c.ok { + t.Errorf("file owner %q read for %q: allowed %v, want %v", c.file, c.want, got, c.ok) + } + } +} + +func TestMCPWinProfileSID(t *testing.T) { + profiles := map[string]string{ + "S-1-5-18": `C:\Windows\system32\config\systemprofile`, + testWinSIDOwner: `C:\Users\demo`, + testWinSIDOwner + ".bak": `C:\Users\demo`, + testWinSIDOther: `C:\Users\other`, + "S-1-5-21-1000000001-1000000002-9-1": `C:\Users\twice`, + "S-1-5-21-1000000001-1000000002-9-2": `C:\Users\twice`, + } + for dir, want := range map[string]string{ + `C:\Users\demo`: testWinSIDOwner, + `c:\users\DEMO\`: testWinSIDOwner, + `C:/Users/demo`: testWinSIDOwner, + `C:\Users\other`: testWinSIDOther, + `C:\Users\twice`: mcpOwnerUnknown, + `C:\Users\nobody`: mcpOwnerUnknown, + } { + if got := mcpWinProfileSID(dir, profiles); got != want { + t.Errorf("%s: %q, want %q", dir, got, want) + } + } +} + +// End to end through mcpReadFileAs with the Windows open's rules: a file in a +// profile that another account owns is not read and is counted, one SYSTEM +// owns is read. +func TestMCPReadFileAsWindowsOwnerRules(t *testing.T) { + backing := filepath.Join(t.TempDir(), "mcp.json") + if err := os.WriteFile(backing, []byte(`{"mcpServers":{}}`), 0o600); err != nil { + t.Fatal(err) + } + oldOpen, oldAdmins := mcpOpenRegularFn, mcpAdminOwners + t.Cleanup(func() { mcpOpenRegularFn, mcpAdminOwners = oldOpen, oldAdmins }) + mcpAdminOwners = mcpWinAdminOwners + for _, c := range []struct { + owner string + read bool + foreign int + }{ + {testWinSIDOwner, true, 0}, + {"S-1-5-18", true, 0}, + {testWinSIDOther, false, 1}, + {"", false, 1}, + } { + fk := &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: c.owner}, backing: backing} + mcpOpenRegularFn = func(p string) (*os.File, string, error) { return mcpWinOpenWith(fk.ops(t), p) } + var scan mcpConfigScan + _, ok := mcpReadFileAs(`C:\Users\demo\.cursor\mcp.json`, testWinSIDOwner, &scan) + if ok != c.read || scan.ForeignOwner != c.foreign { + t.Errorf("owner %q: read %v foreign %d, want %v %d", c.owner, ok, scan.ForeignOwner, c.read, c.foreign) + } + } +} diff --git a/client-go/mgmt_audit.go b/client-go/mgmt_audit.go index 2642aa5..0d330c0 100644 --- a/client-go/mgmt_audit.go +++ b/client-go/mgmt_audit.go @@ -148,6 +148,7 @@ func mgmtAudit(self, op, family, verdict, why string, exit int, args []string, a line["approval"] = approval } rec, _ := json.Marshal(line) + auditToSystemLog(mgmtAuditFile, self, rec) mgmtAuditMu.Lock() defer mgmtAuditMu.Unlock() diff --git a/client-go/modelscope_test.go b/client-go/modelscope_test.go index 517aadc..716abd6 100644 --- a/client-go/modelscope_test.go +++ b/client-go/modelscope_test.go @@ -818,6 +818,12 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) { priv := setupBox(t) writePairingState(t, tMember, pairingPending) t.Setenv("OLLAMA_ADDR", "127.0.0.1:1") + // The tool-server reader looks in an empty home, not this machine's. + oldHomes, oldSys := mcpHomesFn, mcpSystemFn + t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldHomes, oldSys }) + empty := t.TempDir() + mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: empty, ConfigDir: empty}}, true } + mcpSystemFn = func() []mcpSource { return nil } beat := func(self string) map[string]any { t.Helper() @@ -846,13 +852,16 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) { // The store root names the operator's OS account; it is path material // like the rest and travels only with `observe`. The holes map travels // with it: absent must mean "not entitled", and only that. - "machine_record_root", "machine_record_dropped"} { + "machine_record_root", "machine_record_dropped", + // The tool servers agent clients declare name files, servers and + // command lines: observe material like the agent list. + "tool_servers", "tool_server_scan"} { if _, present := m[k]; present { t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+ "process list going to a host approved for one model", k) } } - if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") { + if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") || hasCap(m, "tool-server-observe") { t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v", m["capabilities"]) } @@ -869,6 +878,9 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) { if _, present := m["schedulers"]; !present { t.Fatal("a pairing granted observe must still get the scheduler inventory") } + if _, present := m["tool_servers"]; !present || !hasCap(m, "tool-server-observe") { + t.Fatal("a pairing granted observe must get the declared tool servers and their capability") + } // The anchor holds every family, and an unanchored box gates nothing — the // two cases that must not change, which between them cover nearly every install. diff --git a/client-go/pair.go b/client-go/pair.go index f4880bf..cc4cea6 100644 --- a/client-go/pair.go +++ b/client-go/pair.go @@ -210,6 +210,31 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error { if err != nil { return err } + // A networked machine is a server. Left unanchored it refuses every op to + // every host (capsAllowIn: unanchoredNetworkedRefusal) until someone runs + // claim-anchor, a step people missed. So the first pairing on a networked + // box with no anchor anchors it, as `claim-anchor` with no token would, and + // from then on every terminal and write waits for the person's approval and + // every later pairing for this host's. Only with machine authority, which + // claim-anchor needs anyway. + // The pairing itself succeeded, so a failed anchor is reported, not + // returned: `pair` failing here would read as "could not pair" to a caller + // (npx cseries exits on it) when the pairing exists. claim-anchor, which + // such a caller runs next, reports the anchor's own failure. + anchored, err := anchorFirstNetworkedPairing(cfg, all) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + } + if anchored { + if fresh, lerr := loadPairings(); lerr == nil { + if c := pairingFor(fresh, cfg.HostName); c != nil { + cfg, all = c, fresh + } + } + fmt.Printf("\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName) + fmt.Println("waits for your approval, and any later host waits for this one to approve it.") + fmt.Printf("Enrol your second factor now, so you can approve here:\n %s\n\n", stepupEnrollHint()) + } if cfg.tcpTransport() && cfg.active() { fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName) fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName) @@ -242,6 +267,19 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error { return nil } +// anchorFirstNetworkedPairing anchors a networked machine that has no anchor +// to the pairing just made, when it is the machine's only one and the caller +// holds machine authority (see cmdPair). Reports whether it anchored. +func anchorFirstNetworkedPairing(cfg *Config, all []*Config) (bool, error) { + if boxMode() != modeNetworked || currentAnchor() != nil || len(all) != 1 || !machineAuthorityHeld() { + return false, nil + } + if err := cmdClaimAnchor("", false, "", cfg.HostName); err != nil { + return false, fmt.Errorf("paired with %s, but anchoring this networked machine to it failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint()) + } + return true, nil +} + // redeemURLBase is the platform's enrollment ingress. A variable so a test can // serve a redeem response on loopback; nothing outside tests assigns it. var redeemURLBase = "https://api.privatae.ai" diff --git a/client-go/pair_anchor_test.go b/client-go/pair_anchor_test.go new file mode 100644 index 0000000..c7a8432 --- /dev/null +++ b/client-go/pair_anchor_test.go @@ -0,0 +1,116 @@ +//go:build !windows + +package main + +import ( + "path/filepath" + "testing" +) + +// TestPairAnchorsANetworkedMachineToItsFirstHost: on a networked machine with +// no anchor, the first pairing anchors it (as claim-anchor with no token +// would), so a server is not left refusing everything until claim-anchor is +// run by hand. A local machine, +// an already anchored one, a second pairing, or a caller without machine +// authority are left as they were. +func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) { + held := true + old := machineAuthorityHeld + machineAuthorityHeld = func() bool { return held } + t.Cleanup(func() { machineAuthorityHeld = old }) + + box := func(t *testing.T, networked bool) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json")) + if networked { + if err := setBoxMode(modeNetworked); err != nil { + t.Fatal(err) + } + } + } + only := func(t *testing.T, host string) (*Config, []*Config) { + t.Helper() + all, err := loadPairings() + if err != nil { + t.Fatal(err) + } + return pairingFor(all, host), all + } + + t.Run("networked, no anchor, first pairing: anchored and active", func(t *testing.T) { + held = true + box(t, true) + writePairingState(t, tMember, pairingPending) + cfg, all := only(t, tMember) + anchored, err := anchorFirstNetworkedPairing(cfg, all) + if err != nil || !anchored { + t.Fatalf("the first pairing on a networked box must anchor it: anchored=%v err=%v", anchored, err) + } + if a := currentAnchor(); a == nil || a.AnchorHost != tMember { + t.Fatalf("anchor = %+v, want %s", a, tMember) + } + if got := loadState(t, tMember); got != pairingActive { + t.Fatalf("the anchor never waits on itself: state %q", got) + } + // The installer's claim-anchor after it finds the work done. + if err := cmdClaimAnchor("", false, "", ""); err != nil { + t.Fatalf("claim-anchor after pair must succeed: %v", err) + } + }) + + t.Run("local machine: not anchored", func(t *testing.T) { + held = true + box(t, false) + writePairing(t, tMember) + cfg, all := only(t, tMember) + if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil { + t.Fatalf("a local machine must not be anchored: %v %v", anchored, err) + } + if currentAnchor() != nil { + t.Fatal("a local machine has no anchor") + } + }) + + t.Run("already anchored: a new pairing stays pending", func(t *testing.T) { + held = true + box(t, true) + writePairing(t, tAnchor) + if err := writeAnchor("Acme", tAnchor); err != nil { + t.Fatal(err) + } + writePairingState(t, tMember, pairingPending) + cfg, all := only(t, tMember) + if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil { + t.Fatalf("an anchored machine must not be re-anchored: %v %v", anchored, err) + } + if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor { + t.Fatalf("anchor moved: %+v", a) + } + if got := loadState(t, tMember); got != pairingPending { + t.Fatalf("a later host waits for the anchor's approval: state %q", got) + } + }) + + t.Run("a second pairing on an unanchored machine: left for claim-anchor --host", func(t *testing.T) { + held = true + box(t, true) + writePairing(t, tAnchor) + writePairingState(t, tMember, pairingPending) + cfg, all := only(t, tMember) + if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil { + t.Fatalf("with two pairings the person picks the anchor: %v %v", anchored, err) + } + }) + + t.Run("no machine authority: not anchored", func(t *testing.T) { + held = true + box(t, true) // set-mode networked itself needs machine authority + writePairingState(t, tMember, pairingPending) + held = false + cfg, all := only(t, tMember) + if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil { + t.Fatalf("without machine authority nothing is anchored: %v %v", anchored, err) + } + }) +} diff --git a/client-go/pairing_ops.go b/client-go/pairing_ops.go index 8d436d1..97ac8f1 100644 --- a/client-go/pairing_ops.go +++ b/client-go/pairing_ops.go @@ -42,6 +42,7 @@ func pairingAudit(op, subject, decision string) { "ts": time.Now().UTC().Format(time.RFC3339), "op": op, "subject": subject, "decision": decision, }) + auditToSystemLog(auditFilePairing, "", rec) fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return diff --git a/client-go/privilege.go b/client-go/privilege.go index f5045a9..f607b15 100644 --- a/client-go/privilege.go +++ b/client-go/privilege.go @@ -138,3 +138,14 @@ func restartHintFor(goos string) string { } return "Restart the service so the change takes: systemctl restart privatae-bastion" } + +// stepupEnrollHint is the stepup-enroll line to print after a networked +// machine is anchored: approvals on it need a second factor or a device. +func stepupEnrollHint() string { return stepupEnrollHintFor(runtime.GOOS) } + +func stepupEnrollHintFor(goos string) string { + if goos == "windows" { + return "privatae-bastion stepup-enroll (from this Administrator terminal)" + } + return "sudo -H privatae-bastion stepup-enroll" +} diff --git a/client-go/rootexec.go b/client-go/rootexec.go index 71270e9..11cbd47 100644 --- a/client-go/rootexec.go +++ b/client-go/rootexec.go @@ -479,6 +479,7 @@ func rootExecAudit(dir string, rec map[string]any) { rec["ts"] = time.Now().UTC().Format(time.RFC3339) rec["op"] = sessionOpRootExec raw, _ := json.Marshal(rec) + auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), raw) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return diff --git a/client-go/session.go b/client-go/session.go index 0ac17f1..b5f94c5 100644 --- a/client-go/session.go +++ b/client-go/session.go @@ -254,6 +254,7 @@ func sessionAudit(dir, op, sessionID, user, event string, exit int, detail strin "ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID, "user": user, "event": event, "exit": exit, "decision": detail, }) + auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return diff --git a/client-go/sidlookup_test.go b/client-go/sidlookup_test.go new file mode 100644 index 0000000..2328941 --- /dev/null +++ b/client-go/sidlookup_test.go @@ -0,0 +1,230 @@ +package main + +// Account lookups made outside a scan (sidnames.go): the approvals pipe and the +// folder agent's drop. Fake lookups stand in for LookupAccountSid, so these run +// on every platform. Every SID and name here is invented. + +import ( + "errors" + "strings" + "sync/atomic" + "testing" + "time" +) + +const testSIDDana = "S-1-5-21-1000000001-1000000002-1000000003-1201" + +// hangingLookup blocks until release is closed, then answers with acct. +func hangingLookup(calls *int32, release <-chan struct{}, acct sidAccount) func(string) (sidAccount, error) { + return func(string) (sidAccount, error) { + atomic.AddInt32(calls, 1) + <-release + return acct, nil + } +} + +// A directory that does not answer: every caller is told "timed out" within +// its budget, only one lookup is ever started on the lane, and the answer that +// comes late fills the cache for the next caller. +func TestSIDDirectoryUnreachableTimesOutWithinBudget(t *testing.T) { + release := make(chan struct{}) + var calls int32 + dir := newSIDDirectory(hangingLookup(&calls, release, + sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser})) + lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond} + + for i := 0; i < 5; i++ { + start := time.Now() + if _, out := dir.resolve(testSIDDana, lane); out != sidTimedOut { + t.Fatalf("call %d: outcome %v while the directory does not answer", i, out) + } + if took := time.Since(start); took > lane.budget+500*time.Millisecond { + t.Fatalf("call %d waited %v, budget %v", i, took, lane.budget) + } + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("%d lookups started while the first was still blocked", n) + } + + close(release) + deadline := time.Now().Add(2 * time.Second) + for { + a, out := dir.resolve(testSIDDana, lane) + if out == sidResolved { + if sidAccountLabel(a) != `EXAMPLECORP\dana` { + t.Fatalf("late answer = %+v", a) + } + break + } + if time.Now().After(deadline) { + t.Fatal("the late answer never reached the cache") + } + time.Sleep(10 * time.Millisecond) + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("the late answer was asked again: %d lookups", n) + } +} + +// Answers are kept for sidDirectoryTTL; a failure is asked again every time. +func TestSIDDirectoryCachesAnswersNotFailures(t *testing.T) { + var calls int32 + dir := newSIDDirectory(func(sid string) (sidAccount, error) { + atomic.AddInt32(&calls, 1) + if sid == testSIDDana { + return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil + } + return sidAccount{}, errors.New("none mapped") + }) + now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) + dir.now = func() time.Time { return now } + lane := sidLane{slots: make(chan struct{}, 1), budget: time.Second} + + for i := 0; i < 3; i++ { + if _, out := dir.resolve(testSIDDana, lane); out != sidResolved { + t.Fatalf("outcome %v", out) + } + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("%d lookups for one account within the TTL", n) + } + now = now.Add(sidDirectoryTTL) + dir.resolve(testSIDDana, lane) + if n := atomic.LoadInt32(&calls); n != 2 { + t.Fatalf("an expired answer was not asked again: %d lookups", n) + } + for i := 0; i < 2; i++ { + if _, out := dir.resolve("S-1-5-21-9-9-9-9", lane); out != sidUnmapped { + t.Fatalf("unmapped SID outcome %v", out) + } + } + if n := atomic.LoadInt32(&calls); n != 4 { + t.Fatalf("a failure was cached: %d lookups", n) + } +} + +// A lookup hung on one lane does not hold another lane. +func TestSIDDirectoryLanesAreIndependent(t *testing.T) { + release := make(chan struct{}) + defer close(release) + dir := newSIDDirectory(func(sid string) (sidAccount, error) { + if sid == testSIDDana { + <-release + } + return sidAccount{Account: "erin", Type: winSidTypeUser}, nil + }) + stuck := sidLane{slots: make(chan struct{}, 1), budget: 50 * time.Millisecond} + other := sidLane{slots: make(chan struct{}, 1), budget: time.Second} + if _, out := dir.resolve(testSIDDana, stuck); out != sidTimedOut { + t.Fatalf("stuck lane outcome %v", out) + } + if _, out := dir.resolve("S-1-5-21-1000000001-1000000002-1000000003-1202", other); out != sidResolved { + t.Fatalf("the other lane was held by the stuck one: %v", out) + } +} + +// THE APPROVALS PIPE FAILS CLOSED. A client whose account the directory could +// not name in time is never approved, even with a right code; it is not +// counted as a wrong code; list still answers. Once the directory answers, +// the same code approves. +func TestPipeApproveRefusedWhenTheAccountLookupTimedOut(t *testing.T) { + dir, idA, purposeA, _, _ := pipeBox(t) + now := time.Now() + + release := make(chan struct{}) + var calls int32 + accounts := newSIDDirectory(hangingLookup(&calls, release, + sidAccount{Account: "pat", Domain: "EXAMPLE", Type: winSidTypeUser})) + lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond} + + c := thePerson + c.UserName = "" + start := time.Now() + pipeClientName(&c, accounts, lane) + if took := time.Since(start); took > lane.budget+500*time.Millisecond { + t.Fatalf("the client's name lookup took %v, budget %v", took, lane.budget) + } + if !c.NameTimedOut || c.UserName != "" { + t.Fatalf("a lookup that did not answer must be marked: %+v", c) + } + + code := pipeCodeNow() + approve := pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code} + if resp := pipeServe(approve, c, now); resp.OK || !strings.Contains(resp.Error, "did not answer") { + t.Fatalf("an approve on a timed-out lookup must be refused: %+v", resp) + } + if r := stateOf(t, dir, idA); r.State != "pending" { + t.Fatalf("a timed-out lookup changed the request: %s", r.State) + } + if f := pipeThrottleLoad(dir); f.Failures != 0 { + t.Fatalf("the refusal was counted as a wrong code: %d", f.Failures) + } + if resp := pipeServe(pipeRequest{Op: "list"}, c, now); !resp.OK { + t.Fatalf("list must still answer by SID: %+v", resp) + } + + close(release) + c2 := thePerson + c2.UserName = "" + deadline := time.Now().Add(2 * time.Second) + for { + c2.NameTimedOut = false + pipeClientName(&c2, accounts, lane) + if !c2.NameTimedOut { + break + } + if time.Now().After(deadline) { + t.Fatal("the directory's late answer never reached the pipe") + } + time.Sleep(10 * time.Millisecond) + } + if c2.UserName != `EXAMPLE\pat` { + t.Fatalf("name = %q", c2.UserName) + } + if resp := pipeServe(approve, c2, now); !resp.OK { + t.Fatalf("with the account named, the code approves: %+v", resp) + } +} + +// An account the directory says does not exist is still served by SID, as +// before: that is an answer, not a timeout. +func TestPipeClientNameUnmappedIsNotATimeout(t *testing.T) { + accounts := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") }) + c := thePerson + c.UserName = "" + pipeClientName(&c, accounts, sidLane{slots: make(chan struct{}, 1), budget: time.Second}) + if c.NameTimedOut || c.UserName != "" { + t.Fatalf("unmapped: %+v", c) + } +} + +// THE DROP FAILS CLOSED. The folder owner's lookup is bounded, and a +// directory that does not answer starts no agent; no account starts none. +func TestFaWinDropOwnerAccountFailsClosed(t *testing.T) { + release := make(chan struct{}) + defer close(release) + var calls int32 + slow := newSIDDirectory(hangingLookup(&calls, release, sidAccount{Account: "dana", Type: winSidTypeUser})) + lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond} + start := time.Now() + if _, err := faWinDropOwnerAccount(testSIDDana, slow, lane); err == nil || !strings.Contains(err.Error(), "did not answer") { + t.Fatalf("a directory that does not answer must refuse the start: %v", err) + } + if took := time.Since(start); took > lane.budget+500*time.Millisecond { + t.Fatalf("the drop waited %v, budget %v", took, lane.budget) + } + + none := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") }) + if _, err := faWinDropOwnerAccount(testSIDDana, none, sidLane{slots: make(chan struct{}, 1), budget: time.Second}); err == nil || + !strings.Contains(err.Error(), "has no account") { + t.Fatalf("no account must refuse the start: %v", err) + } + + ok := newSIDDirectory(func(string) (sidAccount, error) { + return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil + }) + a, err := faWinDropOwnerAccount(testSIDDana, ok, sidLane{slots: make(chan struct{}, 1), budget: time.Second}) + if err != nil || a.Type != winSidTypeUser || sidAccountLabel(a) != `EXAMPLECORP\dana` { + t.Fatalf("answered: %+v %v", a, err) + } +} diff --git a/client-go/sidnames.go b/client-go/sidnames.go new file mode 100644 index 0000000..432c547 --- /dev/null +++ b/client-go/sidnames.go @@ -0,0 +1,267 @@ +package main + +import ( + "sync" + "time" +) + +// ACCOUNT NAMES FOR WINDOWS SIDS, ASKED ONCE PER SCAN AND NEVER FOR LONG. +// +// The Windows process scan names each agent's account (winTokenUser) and +// classifies it (ownerFactsReader), and both turn a SID into an account and a +// domain with LookupAccountSid. On a domain-joined machine that call can go +// to a domain controller, and when the controller cannot be reached it waits +// for the network to give up. Asked per process, with no cache, a scan of a +// few dozen processes could then hold the heartbeat for minutes. +// +// So a scan holds one sidNames: each SID is asked at most once, both callers +// share the answer, and the scan waits on lookups for at most a fixed budget +// in total. Once a lookup has used up the budget, every SID not already +// answered is unresolved for the rest of the scan: the account shows as its +// SID string and the owner's class as unknown, which is what the classifier +// already says when the domain could not be read. +// +// LookupAccountSid cannot be cancelled. A lookup the scan stopped waiting +// for finishes in its own goroutine and is then dropped. At most one lookup +// runs at a time across the process (the slots channel), so a controller +// that stays unreachable for many scans holds one blocked thread, not one per +// scan. +// +// The type has no build tag so the Linux test run checks it with a fake +// lookup; agents_windows.go supplies the real one. + +// sidLookupBudget is the most one scan waits on account lookups in total. It +// is a bound on the harm, not a measured lookup time: a scan asks once per +// distinct account, and a domain controller that does not answer costs a +// scan this much of its 60-second beat and no more. +var sidLookupBudget = 2 * time.Second + +// sidLookupSlots: one lookup in flight at a time, process-wide. +var sidLookupSlots = make(chan struct{}, 1) + +// sidName is one SID's answer. OK is false when the lookup failed, timed +// out, or was never started because the budget was gone. +type sidName struct { + Account, Domain string + OK bool +} + +type sidNames struct { + mu sync.Mutex + lookup func(sid string) (account, domain string, err error) + slots chan struct{} + left time.Duration + names map[string]sidName +} + +func newSIDNames(lookup func(string) (string, string, error), budget time.Duration, slots chan struct{}) *sidNames { + return &sidNames{lookup: lookup, slots: slots, left: budget, names: map[string]sidName{}} +} + +// resolve answers one SID, from the scan's cache when it has been asked. +func (c *sidNames) resolve(sid string) sidName { + c.mu.Lock() + defer c.mu.Unlock() + if n, ok := c.names[sid]; ok { + return n + } + n := c.ask(sid) + c.names[sid] = n + return n +} + +// ask runs one lookup within what is left of the budget. Called with mu held. +func (c *sidNames) ask(sid string) sidName { + if c.left <= 0 { + return sidName{} + } + start := time.Now() + var n sidName + finished := boundedSIDCall(c.slots, c.left, func() { + a, d, err := c.lookup(sid) + n = sidName{Account: a, Domain: d, OK: err == nil} + }) + if !finished { + c.left = 0 + return sidName{} + } + c.left -= time.Since(start) + return n +} + +// boundedSIDCall runs call on its own goroutine while holding one of slots, +// and waits for it at most budget in all, the wait for a free slot included. +// It reports whether call finished in time; only then may the caller read +// what call wrote. A call it stopped waiting for keeps its slot until it +// returns, so a lane whose lookup never returns has one blocked goroutine, +// and every later caller on that lane waits its budget and is told no. +func boundedSIDCall(slots chan struct{}, budget time.Duration, call func()) bool { + if budget <= 0 { + return false + } + timer := time.NewTimer(budget) + defer timer.Stop() + select { + case slots <- struct{}{}: + case <-timer.C: + return false // an earlier lookup on this lane still holds the slot + } + done := make(chan struct{}) + go func() { + defer func() { <-slots }() + call() + close(done) + }() + select { + case <-done: + return true + case <-timer.C: + return false + } +} + +// ACCOUNT LOOKUPS OUTSIDE A SCAN: the approvals pipe and the folder agent's drop. +// +// Both resolve one SID when something happens (a tray connects, a store's +// agent is started), not once per scan, so they cannot share a scan's budget. +// They share one process-wide cache instead (sidDirectory): an answer is kept +// for sidDirectoryTTL, so the tray's polling asks the directory once per +// account per ten minutes, and a lookup that answers after its caller stopped +// waiting still fills the cache for the next caller. Only an answer is +// cached; a failure or a timeout is asked again next time. +// +// Each caller has its own lane (slot and budget), so a lookup hung on one +// lane never blocks the other or the scan's: at most one goroutine per lane is +// ever held by a directory that does not answer. +// +// What a timeout means is the caller's decision, and both fail closed: the +// pipe refuses an approve (pipeApprove), the drop refuses to start the agent +// (faWinDropOwnerAccount). + +// sidAccount is one SID's answer: the account, its domain and its SID type +// (SID_NAME_USE). +type sidAccount struct { + Account, Domain string + Type uint32 +} + +// sidOutcome is how a lookup outside a scan ended. +type sidOutcome int + +const ( + sidResolved sidOutcome = iota // the directory answered with an account + sidUnmapped // the directory answered: no such account + sidTimedOut // the directory did not answer within the lane's budget +) + +// sidLane is one caller's single-flight slot and per-lookup budget. +type sidLane struct { + slots chan struct{} + budget time.Duration +} + +const ( + sidDirectoryTTL = 10 * time.Minute + sidDirectoryMax = 256 // accounts kept; a machine has far fewer people +) + +type sidCached struct { + acct sidAccount + at time.Time +} + +type sidDirectory struct { + mu sync.Mutex + lookup func(sid string) (sidAccount, error) + now func() time.Time + names map[string]sidCached +} + +func newSIDDirectory(lookup func(string) (sidAccount, error)) *sidDirectory { + return &sidDirectory{lookup: lookup, now: time.Now, names: map[string]sidCached{}} +} + +// resolve answers sid from the cache, or asks the directory on lane. +func (d *sidDirectory) resolve(sid string, lane sidLane) (sidAccount, sidOutcome) { + if a, ok := d.cached(sid); ok { + return a, sidResolved + } + var ( + acct sidAccount + err error + ) + finished := boundedSIDCall(lane.slots, lane.budget, func() { + // Asked again with the slot held: the lookup this caller queued + // behind may have just answered for the same SID. + if a, ok := d.cached(sid); ok { + acct = a + return + } + acct, err = d.lookup(sid) + if err == nil { + d.store(sid, acct) // also when the caller has stopped waiting + } + }) + switch { + case !finished: + return sidAccount{}, sidTimedOut + case err != nil: + return sidAccount{}, sidUnmapped + } + return acct, sidResolved +} + +func (d *sidDirectory) cached(sid string) (sidAccount, bool) { + d.mu.Lock() + defer d.mu.Unlock() + if c, ok := d.names[sid]; ok && d.now().Sub(c.at) < sidDirectoryTTL { + return c.acct, true + } + return sidAccount{}, false +} + +func (d *sidDirectory) store(sid string, acct sidAccount) { + d.mu.Lock() + defer d.mu.Unlock() + if len(d.names) >= sidDirectoryMax { + for k, c := range d.names { + if d.now().Sub(c.at) >= sidDirectoryTTL { + delete(d.names, k) + } + } + if len(d.names) >= sidDirectoryMax { + d.names = map[string]sidCached{} + } + } + d.names[sid] = sidCached{acct: acct, at: d.now()} +} + +// sidAccountLabel is DOMAIN\account, or the bare account with no domain. +func sidAccountLabel(a sidAccount) string { + if a.Domain != "" { + return a.Domain + `\` + a.Account + } + return a.Account +} + +// winAccountLabel is how the scan names a token's account: DOMAIN\account, +// or the SID itself when it could not be resolved. +func winAccountLabel(sid string, names *sidNames) string { + n := names.resolve(sid) + switch { + case !n.OK: + return sid + case n.Domain != "": + return n.Domain + `\` + n.Account + } + return n.Account +} + +// winAccountDomain is the account's domain for the owner classifier, "" when +// the SID could not be resolved (classifyWinOwner then says unknown). +func winAccountDomain(sid string, names *sidNames) string { + if n := names.resolve(sid); n.OK { + return n.Domain + } + return "" +} diff --git a/client-go/sidnames_test.go b/client-go/sidnames_test.go new file mode 100644 index 0000000..50e117b --- /dev/null +++ b/client-go/sidnames_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "errors" + "fmt" + "sync/atomic" + "testing" + "time" +) + +// Invented SIDs: the domain part is made up and names no real domain. +const ( + testSIDAlice = "S-1-5-21-1000000001-1000000002-1000000003-1101" + testSIDBob = "S-1-5-21-1000000001-1000000002-1000000003-1102" +) + +// One scan asks each SID once, and the account label (winTokenUser) and the +// owner's domain (ownerFactsReader) share the answer. +func TestSIDNamesAsksEachSIDOncePerScan(t *testing.T) { + var calls int32 + lookup := func(sid string) (string, string, error) { + atomic.AddInt32(&calls, 1) + switch sid { + case testSIDAlice: + return "alice", "EXAMPLECORP", nil + case testSIDBob: + return "bob", "EXAMPLECORP", nil + } + return "", "", errors.New("none mapped") + } + names := newSIDNames(lookup, time.Second, make(chan struct{}, 1)) + for i := 0; i < 40; i++ { + if got := winAccountLabel(testSIDAlice, names); got != `EXAMPLECORP\alice` { + t.Fatalf("label = %q", got) + } + if got := winAccountDomain(testSIDAlice, names); got != "EXAMPLECORP" { + t.Fatalf("domain = %q", got) + } + winAccountLabel(testSIDBob, names) + } + if got := winAccountLabel("S-1-5-21-9-9-9-9", names); got != "S-1-5-21-9-9-9-9" { + t.Fatalf("an unmapped SID must be shown as itself, got %q", got) + } + winAccountDomain("S-1-5-21-9-9-9-9", names) + if n := atomic.LoadInt32(&calls); n != 3 { + t.Fatalf("%d lookups for 3 distinct SIDs in one scan", n) + } +} + +// A domain controller that does not answer: the first lookup hangs. The scan +// must stop waiting at its budget, every later SID must come back unresolved +// at once (no second lookup started), the owner's class must read unknown, +// and the next scan must not stack a second blocked lookup behind the first. +func TestSIDNamesUnreachableDirectoryDegradesWithinBudget(t *testing.T) { + release := make(chan struct{}) + defer close(release) + var calls int32 + lookup := func(sid string) (string, string, error) { + atomic.AddInt32(&calls, 1) + <-release + return "", "", errors.New("released") + } + slots := make(chan struct{}, 1) + const budget = 150 * time.Millisecond + + for scan := 0; scan < 3; scan++ { + names := newSIDNames(lookup, budget, slots) + start := time.Now() + for i := 0; i < 30; i++ { + sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 2000+i) + if got := winAccountLabel(sid, names); got != sid { + t.Fatalf("scan %d: unresolved SID shown as %q", scan, got) + } + o := classifyWinOwner(winOwner{SID: sid, Domain: winAccountDomain(sid, names), + Computer: "DESKTOP-TEST", Groups: map[string]bool{}, GroupsKnown: true}) + if o.Class != ownerUnknown { + t.Fatalf("scan %d: class %q for an account whose domain could not be read", scan, o.Class) + } + } + if took := time.Since(start); took > budget+time.Second { + t.Fatalf("scan %d spent %v on lookups, budget %v", scan, took, budget) + } + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("%d lookups started while the first was still blocked", n) + } +} + +// A slow but answering lookup is used, and only what it took counts against +// the budget. +func TestSIDNamesSlowLookupWithinBudgetIsUsed(t *testing.T) { + lookup := func(sid string) (string, string, error) { + time.Sleep(20 * time.Millisecond) + return "carol", "EXAMPLECORP", nil + } + names := newSIDNames(lookup, 2*time.Second, make(chan struct{}, 1)) + for i := 0; i < 5; i++ { + sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 3000+i) + if d := winAccountDomain(sid, names); d != "EXAMPLECORP" { + t.Fatalf("lookup %d within budget came back %q", i, d) + } + } +} diff --git a/client-go/sysservice_windows.go b/client-go/sysservice_windows.go index 1e201b6..c2ac8b3 100644 --- a/client-go/sysservice_windows.go +++ b/client-go/sysservice_windows.go @@ -249,6 +249,13 @@ func installSystemService() error { return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err) } fmt.Printf("System service installed: %s, running %s as LocalSystem.\n", winServiceName, self) + // Audit rows go to the Application event log under their own source as + // well as to their files (auditlog.go). A failure costs that copy only. + if err := registerAuditEventSource(); err != nil { + fmt.Printf(" Audit rows will not reach the event log (%v); they are still written to their files.\n", err) + } else { + fmt.Printf(" Every audit row is also written to the Application event log, source %s.\n", auditEventSource) + } fmt.Println(" It starts at boot, before anyone signs in, and restarts five seconds after any failure.") // The person's tray, at every sign-in (tray.go trayFor). A failure here // leaves the service serving; approvals still work from this terminal. @@ -294,6 +301,7 @@ func uninstallSystemService() error { return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err) } removeTrayLogonEntry() + removeAuditEventSource() fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName) fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot"))) return nil diff --git a/deploy/VERSION b/deploy/VERSION index 75fcee6..524456c 100644 --- a/deploy/VERSION +++ b/deploy/VERSION @@ -1 +1 @@ -0.53.2 +0.54.0 diff --git a/deploy/install.sh b/deploy/install.sh index b5a3331..2b13aba 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,9 +1,10 @@ #!/bin/sh -# privatae-bastion installer (macOS / Linux). Installs the binary AND — on Linux as root — -# the always-on systemd service, and with --networked on macOS a launchd daemon -# running as root, so onboarding a node is two commands: +# privatae-bastion installer (macOS / Linux). Run as root (a server), it installs the +# NETWORKED bastion as a system service (systemd on Linux, launchd on macOS), and the +# first host paired anchors it, so onboarding a server is two commands: # curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh # sudo -H privatae-bastion pair +# Run as your own user (a desktop), it installs the LOCAL bastion for that user. # set -e # dl.privatae.ai, not privatae.ai/bastion: the download host is served from each @@ -20,17 +21,57 @@ arch=$(uname -m); case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch if [ "$(id -u)" = 0 ]; then bindir=/usr/local/bin; root=1; else bindir="${HOME}/.local/bin"; root=0; mkdir -p "$bindir"; fi bin="$bindir/privatae-bastion" -# --networked: install the NETWORKED bastion — a root-owned -# service on a machine several people share, anchored to one host that approves -# every further pairing. Everything else is the LOCAL bastion: one host, and on a -# home machine it runs as the user. Usage: curl ... | sudo sh -s -- --networked +# As root the default is the NETWORKED bastion: a root-owned service, anchored to +# the host it is first paired with, so every terminal and every write waits for the +# person's approval and every later host for the anchor's. A server left in local +# mode would serve its host with no approval at all. --local keeps a root install +# local (one host, the pairing's caps are the authorization); run as your own user +# it is always local. --networked is accepted and is the root default. networked=0 +local=0 for a in "$@"; do case "$a" in --networked) networked=1 ;; + --local) local=1 ;; *) echo "unknown option: $a" >&2; exit 2 ;; esac done +if [ "$networked" = 1 ] && [ "$local" = 1 ]; then + echo "ERROR: --networked and --local contradict each other; pass one" >&2; exit 2 +fi +asked_networked=$networked + +# paired_local DIR...: 0 when one of these state folders holds a pairing and +# its mode is not networked (no mode.json reads as local, mode.go boxMode). +# The service's own folder goes first. +paired_local() { + for d in "$@"; do + [ -d "$d" ] || continue + if [ -f "$d/mode.json" ] && grep -q '"networked"' "$d/mode.json"; then return 1; fi + [ -f "$d/config.json" ] && return 0 + for f in "$d"/pairings/*.json; do [ -f "$f" ] && return 0; done + done + return 1 +} + +if [ "$root" = 1 ] && [ "$local" != 1 ]; then + case "$(uname -s)" in + Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;; + Darwin) networked=1 ;; + esac +fi +# A reinstall (an upgrade) of a machine already paired in local mode keeps +# local mode unless --networked is passed. Switched by default, its pairing +# would sit on an unanchored networked machine, which refuses every op until +# claim-anchor is run, and `pair` would accept a second host. +kept_local=0 +if [ "$networked" = 1 ] && [ "$asked_networked" != 1 ]; then + case "$(uname -s)" in + Darwin) state_dirs="/var/root/Library/Application Support/privatae-bastion|${HOME}/Library/Application Support/privatae-bastion" ;; + *) state_dirs="/root/.config/privatae-bastion|${XDG_CONFIG_HOME:-$HOME/.config}/privatae-bastion" ;; + esac + if paired_local "${state_dirs%%|*}" "${state_dirs#*|}"; then networked=0; kept_local=1; fi +fi if [ "$networked" = 1 ] && [ "$root" != 1 ]; then echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1 fi @@ -159,10 +200,19 @@ UNIT echo "Service installed + enabled (it restarts until you pair, then connects automatically)." echo "" if [ "$networked" = 1 ]; then - echo "Networked bastion. Anchor it to YOUR host first (root, one step):" + echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:" + echo " sudo -H privatae-bastion pair " + echo "Then enrol your second factor, so you can approve here:" + echo " sudo -H privatae-bastion stepup-enroll" + echo "Every terminal and write waits for your approval, and every later host for your host's." + echo "(Already paired before this install? Anchor with: sudo -H privatae-bastion claim-anchor)" + elif [ "$kept_local" = 1 ]; then + echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local." + echo "To make it a networked server, anchored to the host it serves:" + echo " sudo -H privatae-bastion set-mode networked" echo " sudo -H privatae-bastion claim-anchor" - echo " (add --host if this machine already serves several Hosts)" - echo "Every host paired after that waits, pending, until your host approves it." + echo " sudo -H privatae-bastion stepup-enroll" + echo " sudo systemctl restart privatae-bastion" else echo "Now pair: sudo -H privatae-bastion pair " fi @@ -175,18 +225,24 @@ elif [ "$darwin_service" = 1 ]; then "$bin" service install || die "could not register the system service" echo "" echo "Networked bastion, running as a system service (launchd, as root)." - echo "Anchor it to YOUR host first (root, one step):" + echo "Pair it with YOUR host; the first host paired becomes its anchor:" # The full path, not the name on PATH: /usr/local/bin may belong to the # signed-in user, who could point the link at their own program, and this # line is run as root. - echo " sudo -H '$bin' claim-anchor" - echo " (add --host if this machine already serves several Hosts)" - echo "Every host paired after that waits, pending, until your host approves it." + echo " sudo -H '$bin' pair " + echo "Then enrol your second factor, so you can approve here:" + echo " sudo -H '$bin' stepup-enroll" + echo "Every terminal and write waits for your approval, and every later host for your host's." + echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)" elif [ "$networked" = 1 ]; then die "--networked needs a system service manager: systemd on Linux, launchd on macOS" else case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac echo "" + if [ "$kept_local" = 1 ]; then + echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local." + echo "To make it a networked server instead, run the installer again with --networked." + fi echo "Now pair: privatae-bastion pair " echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)" fi