bastion 0.54.0
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s

This commit is contained in:
Privatae
2026-10-21 22:29:10 +00:00
parent e4c9b69b46
commit d62da8adb4
58 changed files with 6944 additions and 124 deletions
+29
View File
@@ -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 that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked. 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 ## 0.53.2
- **No change to what the bastion does.** Two code comments named source files - **No change to what the bastion does.** Two code comments named source files
+175 -50
View File
@@ -5,7 +5,7 @@
[![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms) [![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) [![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 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 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). ([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
```sh ```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 curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
privatae-bastion pair <token> # token from your host's "Set up a bastion" sudo -H privatae-bastion pair <token> # token from your host's "Set up a bastion"
privatae-bastion run # tunnel + lanes up sudo -H privatae-bastion stepup-enroll # your second factor, so you can approve here
privatae-bastion add-folder ~/docs # hand a folder to your network; you approve on your host
# Your own computer (macOS / Linux, as you)
curl -fsSL https://privatae.ai/bastion/install.sh | sh
privatae-bastion pair <token>
privatae-bastion run # tunnel + lanes up
``` ```
```powershell ```powershell
# Windows # Windows, your own computer
irm https://privatae.ai/bastion/install.ps1 | iex irm https://privatae.ai/bastion/install.ps1 | iex
privatae-bastion pair <token> privatae-bastion pair <token>
privatae-bastion run privatae-bastion run
``` ```
On a Linux server installed as root, the installer also enables a service that A Windows server installs from an Administrator PowerShell with `-Networked`.
runs the daemon for you, so it is `sudo -H privatae-bastion pair <token>` and See [Install](#install).
nothing else. See [Install](#install).
--- ---
@@ -46,6 +49,7 @@ nothing else. See [Install](#install).
- [Install](#install) - [Install](#install)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [Local and networked modes](#local-and-networked-modes) - [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) - [A Host on this machine](#a-host-on-this-machine)
- [What the bastion does](#what-the-bastion-does) - [What the bastion does](#what-the-bastion-does)
- [Command reference](#command-reference) - [Command reference](#command-reference)
@@ -80,41 +84,59 @@ trust, and why.
## Install ## Install
The installer downloads the binary for your OS and architecture, verifies its There are two ways to run the bastion, and the installer picks by who runs it.
SHA-256 against the release manifest, and only then moves it into place. A
failed check installs nothing.
**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 ```sh
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
sudo -H privatae-bastion pair <token> # 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` On Windows, from an Administrator PowerShell:
service (`privatae-bastion.service`), so a server is onboarded in two commands:
install, then `sudo -H privatae-bastion pair <token>`.
**Windows (PowerShell)** ```powershell
& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked
privatae-bastion pair <token>
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 <token>
privatae-bastion run
```
```powershell ```powershell
irm https://privatae.ai/bastion/install.ps1 | iex irm https://privatae.ai/bastion/install.ps1 | iex
``` ```
**Networked bastion** (a shared machine serving several hosts, see A root install that should stay local passes `--local`
[modes](#local-and-networked-modes)): (`… | 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 The installer downloads the binary for your OS and architecture, checks its
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh -s -- --networked 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 A networked install runs as the operating system's own service, whoever is
& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked 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 - **Linux:** the `systemd` unit `privatae-bastion.service`, as root.
whoever is signed in, and only the machine's administrator can stop it:
- **Linux:** the `systemd` unit above, as root.
- **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The - **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The
program goes in `/Library/Application Support/privatae/bin`, which only root program goes in `/Library/Application Support/privatae/bin`, which only root
can change, with a link on `PATH`. 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 ## 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 1. In your Privatae host, choose **Set up a bastion**. It gives you a one-time
pairing token. pairing token.
2. Pair this machine. The binary generates its own keys locally and pulls only 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 <token> privatae-bastion pair <token>
``` ```
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 ```sh
privatae-bastion run 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 Each paired host gets its own loopback control port, starting at
`127.0.0.1:7878`. One daemon serves every host you pair. `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 ```sh
privatae-bastion status privatae-bastion status
privatae-bastion ui 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 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. 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 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 | | 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 | | 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 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 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 is no authority above the box to ask. On a networked machine with no anchor,
`set-mode networked`, then `claim-anchor` with no token: it is a choice among the first `pair` run as root anchors it to that host by itself. A machine
pairings the box already holds. A token is needed only to name a host this box 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 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 another host only with the current anchor's signed consent, or after 14 days of
silence from it. silence from it.
@@ -213,6 +243,38 @@ sudo -H privatae-bastion claim-owner <name> # state
`claim-owner` is optional. The name shows on this machine's card in every host `claim-owner` is optional. The name shows on this machine's card in every host
it serves, and it grants nothing anywhere. 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 <key>` 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 <host> 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 on this machine
A Host with no platform (a customer's own server with the Host on it) pairs 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 (`interactive`, `remote`, `scheduler`, `service`, `agent`) and a typed
evidence list with a confidence tier. Tools that drive the machine with no 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` 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 - **Scheduler inventory.** Crontabs, `systemd` timers, `launchd` jobs and
Windows scheduled tasks whose command would run an agent or automation. Windows scheduled tasks whose command would run an agent or automation.
- **Baseline and delta.** `observe:baseline` records what is normal on this - **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. quiet apart from one that is blind.
### Heartbeat and self-update ### Heartbeat and self-update
Each check-in carries the machine's capabilities, posture, agent scan and Each check-in carries the machine's capabilities, posture, agent scan,
scheduler inventory. The host can direct endpoint rotation and a self-update. 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 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 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 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; services a host reaches when it starts, or replacements for them;
- a place the new host can fetch the backup from, as above. - 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 ### Local console
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1` `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 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`: `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`: `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 `<prefix>harness-<engine>@sha256:<digest>` 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. | | | `local_host_linux_test.go`: `TestTheOwnersImagePrefix`, `TestALocalImageRunsByItsIDWithoutAPull` | Run-up takes only `<prefix>harness-<engine>@sha256:<digest>` 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. | | 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. | | | `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. | | | `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. | | | `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. | | | `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. | | | 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. | | 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. | | | `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. | | | `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 coder_socket.go a coder run's socket for a Host on this machine
anchor.go networked-mode anchor anchor.go networked-mode anchor
mgmt*.go management lane, allowlist and audit 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) writes.go which management ops are writes (and need an approval)
device*.go the approval device device*.go the approval device
quarantine.go closing a pairing's write lanes after repeated refusals quarantine.go closing a pairing's write lanes after repeated refusals
+433
View File
@@ -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_<goos>.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/<pid>/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/<uid>). 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 <user>`: 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/<name> 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
}
+83
View File
@@ -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)
}
}
}
+209
View File
@@ -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)
}
}
+161 -3
View File
@@ -213,9 +213,17 @@ type agentObs struct {
// what makes a service RESTART the same entry rather than a new one // 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 // (baseline.go). Empty where the platform does not supply one: darwin and
// windows never populate procFacts.Unit, so those fall back to the origin. // windows never populate procFacts.Unit, so those fall back to the origin.
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Evidence []evidence `json:"evidence,omitempty"` // The account behind User (agentowner.go): domain_user, local_user,
raw string // the UNTRUNCATED command line, for classification only; never reported // 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 // automation: the signature came from the automation tables, so the
// classifier gives this kind `automation` unless model traffic promotes it. // classifier gives this kind `automation` unless model traffic promotes it.
automation bool automation bool
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
} }
extra := originExtras() extra := originExtras()
facts := procFactsReader() facts := procFactsReader()
owners := ownerFactsReader()
out := make([]agentObs, 0, len(byPid)) out := make([]agentObs, 0, len(byPid))
for pid, a := range byPid { for pid, a := range byPid {
if faIsOwnChild(pid) { if faIsOwnChild(pid) {
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
if f, ok := facts(pid); ok { if f, ok := facts(pid); ok {
a.Unit = f.Unit 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)...) a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
if a.DelegationToken != "" { if a.DelegationToken != "" {
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"}) a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
return out, nil 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 ( var (
llmIPCache map[string]string llmIPCache map[string]string
llmIPCacheAt time.Time 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. // done for nothing, on the user's own hardware.
var schedEntries []schedEntry var schedEntries []schedEntry
var schedScan schedScanReport 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 { if observing {
schedEntries, schedScan = scanSchedulers() schedEntries, schedScan = scanSchedulers()
if schedScan.Supported { if schedScan.Supported {
caps = append(caps, "scheduler-observe") 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 // 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. // 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. // that read 0 crontabs is visibly different from one that read 40.
body["schedulers"] = schedEntries body["schedulers"] = schedEntries
body["scheduler_scan"] = schedScan 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 // Where this machine keeps its own record — the findings journal and
// the signed accounting (machinestore.go, baselinesign.go). The host // the signed accounting (machinestore.go, baselinesign.go). The host
// drafts a machine store at this root for the user to approve, once; // drafts a machine store at this root for the user to approve, once;
+73
View File
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil 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. // procParent answers from the sweep scanProcesses just took.
func procParent(pid int) int { func procParent(pid int) int {
psCache.Lock() psCache.Lock()
+132
View File
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil 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/<pid>/stat. // procParent reads one process's parent pid from /proc/<pid>/stat.
func procParent(pid int) int { func procParent(pid int) int {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
@@ -126,6 +155,109 @@ func procUser(pid int) string {
return "" 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/<pid>/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 { func procCmdline(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil { if err != nil {
+8
View File
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
func procExe(pid int) string { return "" } func procExe(pid int) string { return "" }
func procParent(pid int) int { return 0 } func procParent(pid int) int { return 0 }
func processParents() map[int]int { return map[int]int{} } 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 }
+169 -5
View File
@@ -306,6 +306,7 @@ func processParents() map[int]int {
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can // scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`. // fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) { func scanProcesses() (map[int]agentObs, error) {
winBeginSIDScan()
out := map[int]agentObs{} out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil { if err != nil {
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil 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 ──────────────────────────────────────── // ── connections: GetExtendedTcpTable ────────────────────────────────────────
var ( var (
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
if err != nil || tu.User.Sid == nil { if err != nil || tu.User.Sid == nil {
return "" 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 { if err != nil {
return tu.User.Sid.String() return sidAccount{}, err
} }
if domain != "" { account, domain, typ, err := sid.LookupAccount("")
return domain + `\` + account 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 { func procUser(pid int) string {
+25
View File
@@ -978,6 +978,20 @@ func cmdRepinAnchor() error {
return nil 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 // 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 // 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 — // 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 return err
} }
prev := currentAnchor() 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 { if prev != nil && !rotate {
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24)) return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
} }
+47 -3
View File
@@ -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) freshBox(t, true)
writePairing(t, tMember) writePairing(t, tMember)
if err := writeAnchor("Acme", tMember); err != nil { if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") { if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("dropping the token must not let a second claim through: %v", err) 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)
} }
}) })
+32
View File
@@ -138,6 +138,30 @@ type pipeClient struct {
// "" when nobody is. // "" when nobody is.
SessionUserSID string SessionUserSID string
SessionActive bool // WTSActive: the session someone is in front of 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. // 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 var pipeApproveMu sync.Mutex
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse { 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() pipeApproveMu.Lock()
defer pipeApproveMu.Unlock() defer pipeApproveMu.Unlock()
code := strings.TrimSpace(req.Code) code := strings.TrimSpace(req.Code)
+3 -6
View File
@@ -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) return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
} }
c.UserSID = u.User.Sid.String() c.UserSID = u.User.Sid.String()
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil { // Bounded and cached (sidnames.go): a directory that does not answer
c.UserName = name // leaves NameTimedOut set, and pipeApprove refuses.
if dom != "" { pipeClientName(&c, winAccounts, pipeSIDLane)
c.UserName = dom + `\` + name
}
}
var sess, n uint32 var sess, n uint32
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil { 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) return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
+179
View File
@@ -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/<host_name>), 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))
}
+40
View File
@@ -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() {}
+95
View File
@@ -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)
}
}
+69
View File
@@ -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")
}
}
+154
View File
@@ -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)
}
}
+54
View File
@@ -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)
}
+418 -33
View File
@@ -11,6 +11,7 @@ package main
// runs) is driven in claims_unix_test.go and beside each lane's own tests. // runs) is driven in claims_unix_test.go and beside each lane's own tests.
import ( import (
"fmt"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/token" "go/token"
@@ -49,23 +50,7 @@ func parseSources(t *testing.T) *srcPkg {
if err != nil { if err != nil {
t.Fatalf("parse %s: %v", n, err) t.Fatalf("parse %s: %v", n, err)
} }
p.files[n] = f p.addFile(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)
}
}
} }
if len(p.files) < 50 { if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files)) 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 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. // funcKey is "name" for a function and "Type.name" for a method.
func funcKey(fd *ast.FuncDecl) string { func funcKey(fd *ast.FuncDecl) string {
if fd.Recv == nil || len(fd.Recv.List) == 0 { 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, // No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
// is in this table; none of them, and no op name on the management lane, // the binary registers, on any listener, is in this table; none of them, and
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC, // no op name on the management lane, names MCP; no string in the code is a
// the protocol MCP is carried on; and no dependency is an MCP library. // 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) { func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
p := parseSources(t) p := parseSources(t)
// A route served by more than one surface names each file, "a.go|b.go": // 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 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 { for _, imp := range f.Imports {
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") { if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
t.Errorf("%s imports %s", name, imp.Path.Value) 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. // 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 // 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 // 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. // followed; none of the handlers below does that.
func (p *srcPkg) callGraph() map[string][]string { func (p *srcPkg) callGraph() map[string][]string {
g := 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 { for key, fd := range p.funcs {
if fd.Body == nil { if fd.Body == nil {
continue continue
@@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string {
if _, ok := p.funcs[v.Name]; ok { if _, ok := p.funcs[v.Name]; ok {
out = append(out, v.Name) out = append(out, v.Name)
} }
for k := range p.funcs { out = append(out, dups[v.Name]...)
if strings.HasPrefix(k, v.Name+"@") {
out = append(out, k)
}
}
case *ast.CallExpr: case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok { if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
out = append(out, p.methods[sel.Sel.Name]...) out = append(out, p.methods[sel.Sel.Name]...)
+10
View File
@@ -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 }
+1
View File
@@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) {
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "ts": time.Now().UTC().Format(time.RFC3339), "op": op,
"store_id": storeID, "path": path, "decision": decision, "store_id": storeID, "path": path, "decision": decision,
}) })
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
+5 -7
View File
@@ -98,15 +98,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
if err != nil || sid == nil { if err != nil || sid == nil {
return nil, fmt.Errorf("cannot read the folder's owner: %v", err) 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 { 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 name, full := acct.Account, sidAccountLabel(acct)
if domain != "" { if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
full = domain + `\` + name
}
if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" {
return nil, errors.New(why) return nil, errors.New(why)
} }
home := faWinProfileDir(sid.String()) home := faWinProfileDir(sid.String())
+26
View File
@@ -5,12 +5,38 @@ package main
// that gather their inputs are in the tagged file. // that gather their inputs are in the tagged file.
import ( import (
"fmt"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode/utf16" "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 // winSidTypeUser is SidTypeUser: the SID names a person's account rather than
// a group, an alias or a well-known identity. // a group, an alias or a well-known identity.
const winSidTypeUser = 1 const winSidTypeUser = 1
+104
View File
@@ -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, ", "))
}
}
}
+76
View File
@@ -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")
}
}
+4
View File
@@ -229,6 +229,7 @@ func main() {
// Started by the Windows Service Control Manager: the service runs `run` // Started by the Windows Service Control Manager: the service runs `run`
// itself and never reaches the argument parsing below. // itself and never reaches the argument parsing below.
if maybeRunAsWindowsService() { if maybeRunAsWindowsService() {
auditFlush(auditFlushOnExit)
return return
} }
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -618,6 +619,9 @@ func main() {
usage() usage()
os.Exit(2) 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 { if err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) os.Exit(1)
File diff suppressed because it is too large Load Diff
+223
View File
@@ -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)
}
}
}
+32
View File
@@ -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", "") }
+23
View File
@@ -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", "") }
+38
View File
@@ -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")
}
}
+42
View File
@@ -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
}
+42
View File
@@ -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
}
+136
View File
@@ -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)
}
}
+33
View File
@@ -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 }
+122
View File
@@ -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\<SID>,
// 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
}
+9
View File
@@ -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 }
+36
View File
@@ -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"
}
+461
View File
@@ -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)
}
}
+48
View File
@@ -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")) }
+112
View File
@@ -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]
}
+179
View File
@@ -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)
}
}
}
+1
View File
@@ -148,6 +148,7 @@ func mgmtAudit(self, op, family, verdict, why string, exit int, args []string, a
line["approval"] = approval line["approval"] = approval
} }
rec, _ := json.Marshal(line) rec, _ := json.Marshal(line)
auditToSystemLog(mgmtAuditFile, self, rec)
mgmtAuditMu.Lock() mgmtAuditMu.Lock()
defer mgmtAuditMu.Unlock() defer mgmtAuditMu.Unlock()
+14 -2
View File
@@ -818,6 +818,12 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
priv := setupBox(t) priv := setupBox(t)
writePairingState(t, tMember, pairingPending) writePairingState(t, tMember, pairingPending)
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1") 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 { beat := func(self string) map[string]any {
t.Helper() t.Helper()
@@ -846,13 +852,16 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
// The store root names the operator's OS account; it is path material // 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 // like the rest and travels only with `observe`. The holes map travels
// with it: absent must mean "not entitled", and only that. // 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 { if _, present := m[k]; present {
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+ 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) "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", t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
m["capabilities"]) m["capabilities"])
} }
@@ -869,6 +878,9 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
if _, present := m["schedulers"]; !present { if _, present := m["schedulers"]; !present {
t.Fatal("a pairing granted observe must still get the scheduler inventory") 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 // 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. // two cases that must not change, which between them cover nearly every install.
+38
View File
@@ -210,6 +210,31 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
if err != nil { if err != nil {
return err 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() { if cfg.tcpTransport() && cfg.active() {
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName) 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) 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 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 // redeemURLBase is the platform's enrollment ingress. A variable so a test can
// serve a redeem response on loopback; nothing outside tests assigns it. // serve a redeem response on loopback; nothing outside tests assigns it.
var redeemURLBase = "https://api.privatae.ai" var redeemURLBase = "https://api.privatae.ai"
+116
View File
@@ -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)
}
})
}
+1
View File
@@ -42,6 +42,7 @@ func pairingAudit(op, subject, decision string) {
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "ts": time.Now().UTC().Format(time.RFC3339), "op": op,
"subject": subject, "decision": decision, "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) fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
return return
+11
View File
@@ -138,3 +138,14 @@ func restartHintFor(goos string) string {
} }
return "Restart the service so the change takes: systemctl restart privatae-bastion" 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"
}
+1
View File
@@ -479,6 +479,7 @@ func rootExecAudit(dir string, rec map[string]any) {
rec["ts"] = time.Now().UTC().Format(time.RFC3339) rec["ts"] = time.Now().UTC().Format(time.RFC3339)
rec["op"] = sessionOpRootExec rec["op"] = sessionOpRootExec
raw, _ := json.Marshal(rec) 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) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
return return
+1
View File
@@ -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, "ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID,
"user": user, "event": event, "exit": exit, "decision": detail, "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) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
return return
+230
View File
@@ -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)
}
}
+267
View File
@@ -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 ""
}
+103
View File
@@ -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)
}
}
}
+8
View File
@@ -249,6 +249,13 @@ func installSystemService() error {
return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err) 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) 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.") 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 // The person's tray, at every sign-in (tray.go trayFor). A failure here
// leaves the service serving; approvals still work from this terminal. // 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) return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err)
} }
removeTrayLogonEntry() removeTrayLogonEntry()
removeAuditEventSource()
fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName) 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"))) fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot")))
return nil return nil
+1 -1
View File
@@ -1 +1 @@
0.53.2 0.54.0
+70 -14
View File
@@ -1,9 +1,10 @@
#!/bin/sh #!/bin/sh
# privatae-bastion installer (macOS / Linux). Installs the binary AND — on Linux as root — # privatae-bastion installer (macOS / Linux). Run as root (a server), it installs the
# the always-on systemd service, and with --networked on macOS a launchd daemon # NETWORKED bastion as a system service (systemd on Linux, launchd on macOS), and the
# running as root, so onboarding a node is two commands: # first host paired anchors it, so onboarding a server is two commands:
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh # curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
# sudo -H privatae-bastion pair <token> # sudo -H privatae-bastion pair <token>
# Run as your own user (a desktop), it installs the LOCAL bastion for that user.
# #
set -e set -e
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each # 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 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" bin="$bindir/privatae-bastion"
# --networked: install the NETWORKED bastion — a root-owned # As root the default is the NETWORKED bastion: a root-owned service, anchored to
# service on a machine several people share, anchored to one host that approves # the host it is first paired with, so every terminal and every write waits for the
# every further pairing. Everything else is the LOCAL bastion: one host, and on a # person's approval and every later host for the anchor's. A server left in local
# home machine it runs as the user. Usage: curl ... | sudo sh -s -- --networked # 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 networked=0
local=0
for a in "$@"; do for a in "$@"; do
case "$a" in case "$a" in
--networked) networked=1 ;; --networked) networked=1 ;;
--local) local=1 ;;
*) echo "unknown option: $a" >&2; exit 2 ;; *) echo "unknown option: $a" >&2; exit 2 ;;
esac esac
done 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 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 echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1
fi fi
@@ -159,10 +200,19 @@ UNIT
echo "Service installed + enabled (it restarts until you pair, then connects automatically)." echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
echo "" echo ""
if [ "$networked" = 1 ]; then 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 <token>"
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 " sudo -H privatae-bastion claim-anchor"
echo " (add --host <name> if this machine already serves several Hosts)" echo " sudo -H privatae-bastion stepup-enroll"
echo "Every host paired after that waits, pending, until your host approves it." echo " sudo systemctl restart privatae-bastion"
else else
echo "Now pair: sudo -H privatae-bastion pair <token>" echo "Now pair: sudo -H privatae-bastion pair <token>"
fi fi
@@ -175,18 +225,24 @@ elif [ "$darwin_service" = 1 ]; then
"$bin" service install || die "could not register the system service" "$bin" service install || die "could not register the system service"
echo "" echo ""
echo "Networked bastion, running as a system service (launchd, as root)." 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 # 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 # signed-in user, who could point the link at their own program, and this
# line is run as root. # line is run as root.
echo " sudo -H '$bin' claim-anchor" echo " sudo -H '$bin' pair <token>"
echo " (add --host <name> if this machine already serves several Hosts)" echo "Then enrol your second factor, so you can approve here:"
echo "Every host paired after that waits, pending, until your host approves it." 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 elif [ "$networked" = 1 ]; then
die "--networked needs a system service manager: systemd on Linux, launchd on macOS" die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
else else
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
echo "" 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 <token>" echo "Now pair: privatae-bastion pair <token>"
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)" echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
fi fi