diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef1842..43260a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,32 @@ evidence for each increment is retained by the maintainers and summarised here. ## 2026-10-08 +### `slice/135`: secrets out of the data directory, and filesystem roots that fail closed + +A defect, fixed. The data directory was always a filesystem root and the receipt signing key lived +inside it, so with the default policy a file read of the key returned it without asking. Now no tool +can read or write Trinity's own keys or databases, by any path. The keys, the MCP bearer token and +the MCP client's tokens live in a secrets directory of their own (`TRINITY_SECRETS_DIR`, with a +default per operating system outside the data directory), and an existing installation's files move +there on the first boot, receipted, with every earlier receipt still verifying. The data directory +is no longer a root, and a root that is, contains or sits inside either directory refuses the boot +in every profile and is refused by the Settings page. + +Every path a tool is given is judged before the permission policy is asked, and a refusal cannot be +approved away: no symbolic link is followed, `/proc` and `/dev` are refused, a hard link is refused, +and the file's device and inode are compared against an inventory of Trinity's secrets and databases +(which catches a bind mount) both before the open and on the open file. A short list of secret file +names is the last check, not the first. Every filesystem decision, refusals included, is on the +receipt chain with the paths, the inode, the rule and the turn. A session that read a file outside +its folders asks before every web fetch after it, and a regulated deployment that allows network +access without an egress allow-list refuses to boot. + +Verified by the test suite: each route to the key (direct, `..`, a symbolic link, a hard link, +`/proc/self/fd`) shown returning the key before the fix and refused after it; a census of every +file in both directories against every tool that takes a path; and the boot refusals and the +migration in separate operating system processes. The bind-mount case in the hardened container is +listed for manual verification. + ### `slice/132`: a control mapping an assessor can check against the tree The regulated pack explained the boundary, the data flow, the cryptography and who owns what, and diff --git a/README.md b/README.md index bec50ca..15a20ae 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,10 @@ enforces it. - **Acts.** Filesystem, web and shell tools behind a permission gate with fingerprint-bound approvals; every effect passes one membrane and leaves a signed, hash-chained receipt you can verify offline (`mix trinity.receipts.verify`). A FIPS build leg in CI proves the receipt scheme - under OpenSSL's FIPS provider. + under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any + path: the secrets live in their own directory outside the data directory, a filesystem root that + overlaps either refuses the boot, and every path is checked for links, hard links, `/proc` and + protected inodes before the file is opened and again on the open file (slice 135). - **Remembers.** A persona (its SOUL), a small always-on memory with a byte budget and a consolidator instead of truncation, full-text search over every past conversation, a semantic tier filled by an observer after each turn and recalled by meaning (local embeddings, never a diff --git a/ci/ironbank/Dockerfile b/ci/ironbank/Dockerfile index b81a5e2..5347e4e 100644 --- a/ci/ironbank/Dockerfile +++ b/ci/ironbank/Dockerfile @@ -136,12 +136,16 @@ COPY --from=release --chown=0:0 /build/trinity/_build/prod/rel/headless /opt/tri COPY --chown=0:0 scripts/entrypoint.sh /usr/local/bin/trinity-entrypoint # Distribution is off: the image serves MCP and the web pages on PORT and nothing else. The data -# directory is /data (XDG_DATA_HOME), the only path the runtime user can write. +# directory is /data (XDG_DATA_HOME), the only path the runtime user can write. The secrets +# directory is /data/secrets (TRINITY_SECRETS_DIR, slice 135), beside the data directory on the +# same volume and never inside it: the receipt keys, the envelope key, the generated token. Point +# it at a systemd credential or a Kubernetes Secret mount instead (docs/packaging.md). ENV LANG=C.UTF-8 \ TRINITY_MODE=headless \ TRINITY_BIND=0.0.0.0 \ PORT=4000 \ XDG_DATA_HOME=/data \ + TRINITY_SECRETS_DIR=/data/secrets \ HOME=/data \ RELEASE_DISTRIBUTION=none \ RELEASE_TMP=/tmp diff --git a/config/test.exs b/config/test.exs index f9c1319..ea495db 100644 --- a/config/test.exs +++ b/config/test.exs @@ -258,6 +258,17 @@ config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__), forward_interval_ms: 0 +# Slice 135: the secrets directory for the suite, under the project's ignored tmp/ and per test +# partition, never the machine's own. A test that needs the release layout sets +# TRINITY_SECRETS_DIR, which wins over this. +config :trinity, + secrets_dir: + Path.expand("../tmp/test_secrets-#{System.get_env("MIX_TEST_PARTITION", "0")}", __DIR__) + +# Slice 135: the suite's application runs against the machine's own data directory, so the boot +# migration is off here; the boot-node test turns it on in a child with its own data directory. +config :trinity, Trinity.Secrets.Migration, enabled: false + # Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no # model is needed. config :trinity, :memory, embedder: :fake, observer: false diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 51676f5..fa5f5ea 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -242,7 +242,7 @@ call is timed at the one place every call passes. | receipt_hash | binary | over the canonical signed bytes | | signed_payload | map | RFC 8785 canonical JSON. Field set is a legal-review question before Slice 024 | | signature | binary | through the signer seam: Ed25519 by default, ECDSA P-384 in FIPS mode, ML-DSA-87 opt-in (slice 024 amendments 1 to 6) | -| key_id | string | inside the signed bytes; resolves in the key registry (as built: `/keys/registry.json`, not `priv/`), whose row names the algorithm; the verifier reads the algorithm from there and nowhere else | +| key_id | string | inside the signed bytes; resolves in the key registry (as built: `/keys/registry.json` since slice 135, `/keys/registry.json` before it, never `priv/`), whose row names the algorithm; the verifier reads the algorithm from there and nowhere else | | kind | string | "decision" \| "effect" \| "query" \| "boot" \| "cap" | | subject | map | refs to the session, tool call, approval or effect this receipts | Append-only. Never updated, never deleted. Signing unavailable means the effect is denied, not that an unsigned diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 3a7aa42..ac1c77a 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -84,7 +84,7 @@ BEAM is not an OS sandbox. The shell is a `:catalog` effect (`Trinity.Effects.Ca ## Filesystem (Slice 022) -- Path allowlist (project roots + data dir). Writes outside → ask. +- Path allowlist (project roots; the data directory is not one since slice 135). Writes outside → ask. - **Write-validation hook**: reject writes whose content contains truncation markers (`/* ... */`, `// ...`, `# ... rest unchanged`) unless the file is new or the tool is called with `allow_placeholders: true` after approval. - Atomic writes (temp + rename) and a per-file backup ring (last 5) under the data dir. @@ -97,6 +97,62 @@ resolution through its nearest existing ancestor; outside the roots every filesy `allow_placeholders` raises the call to `:destructive`; backups live under `/backups//`, five per file, `Trinity.Tools.FS.restore/2` puts one back through the same atomic write. +**As built at slice 135: secrets out of the data directory, roots that fail closed.** Until this slice +the data directory was always a root, and the keys directory was `/keys`, so with the +default policy (`read: :allow`) `fs_read` of the receipt signing key returned it with no approval. +The fix is in three layers, the deny-list last. + +1. **Secrets leave the data directory.** `Trinity.Paths.secrets_dir/0` (`TRINITY_SECRETS_DIR`, else + `config :trinity, :secrets_dir`, else `$XDG_CONFIG_HOME/trinity/secrets` on Linux, + `~/Library/Application Support/Trinity Secrets` on macOS, `%LOCALAPPDATA%\Trinity\Secrets` on + Windows; mode 0700) holds `keys/` (the receipt key when no keychain holds it, its registry, the MCP + state key, the device id), the MCP bearer token and the MCP client's OAuth tokens. + `Trinity.Secrets.Migration` moves an earlier release's files on boot, before the signer reads + them, by rename, so the key keeps its id and every earlier receipt still verifies; a file already + at the destination is never overwritten; the boot receipt names the move in its signed subject + (`secrets_migration`). The process still *uses* its keys through the signing API; no tool reads + them. The data directory is no longer a root, and nothing replaces it: skills are read by the + skill tools, which are jailed to a skill's directory. +2. **Roots fail closed, in every profile.** `Trinity.Tools.FS.Guard.verify_boot!/0` runs before any + child: a configured root (`TRINITY_FS_ROOTS`, or a folder chosen in Settings) that is equal to, + an ancestor of or a descendant of the data directory or the secrets directory refuses the boot, + naming the root, the directory and the relation; the Settings page refuses to save such a folder. + At every call the guard judges each host path a tool names (`Trinity.Tools.Tool.fs_paths/2`) + before the policy is asked, and a refusal is a `deny` (basis `fs_guard`) no grant, rule or + approval lifts: `/proc` and `/dev`; a symlink in any component (no link is followed, so the + lexical path is the physical one; a configured root's own symlinked spelling is rewritten to its + canonical form); a component whose `(st_dev, st_ino)` is in the inventory (every entry under the + secrets directory and the configured keys directory, the databases and their journals, and the + data and secrets directories themselves, taken fresh at each call), which is what catches a bind + mount; a path under the data or secrets directory; a special file; a regular file with + `st_nlink > 1` unless `config :trinity, :fs, allow_hard_links: true`. At open the file is opened + and its descriptor `fstat`ed (`:file.read_file_info/1` on the open device is `fstat`, measured on + OTP 28): an inventoried inode, a second link, a non-regular file or an inode other than the one + the walk saw is refused. `fs_grep` and `fs_glob` judge every file they meet the same way. +3. **The deny-list, second:** `*.key`, `*.db`, `*.db-wal`, `*.db-shm`, `*.sqlite*`, `.env*`, `id_*`, + `*.pem`, on the canonical basename, after the structural checks. + +**Receipts.** Every fs decision is on the chain with the tool, the requested and canonical paths, +`(dev, ino)`, the root matched or none, the rule matched, nlink, the decision, the core policy hash, +the principal and the turn id: the decision receipt carries them under `decision.fs`, and a refusal +made at open (a file met inside a directory `fs_grep` walked, a swap since the decision) rides on the +read's query receipt the same way. Never the file's contents. + +**What is not covered, stated.** No NIF: `file:open/2` has no `O_NOFOLLOW` or `openat2`, and the +post-open `fstat` closes the window between the walk and the open for every protected inode. A swap +inside a root, between the walk and the open, to an ordinary file outside the roots that is neither +inventoried nor hard-linked would be read; that is not one of Trinity's secrets, and it needs a +process already able to write links inside the root at that moment. A write's parent directory +swapped for a link between the check and the rename is the same residual for writes. The shell's +path argument (its working directory) is judged; the command text is not, because no path check can +read free text: the shell is `:exec`, asks every time, and the BEAM is not an OS sandbox (above). + +**The network half.** A read outside the roots tags the session (`Trinity.Tools.Taint`), and every +`web_fetch` in that session asks from then on: whether a request carries bytes read earlier cannot be +decided against any encoding of them. The taint never gates the read itself. Under `:regulated`, a +`network: :allow` default with no `TRINITY_REGULATED_EGRESS` refuses the boot (OWASP ASI02's egress +allow-list), and with one, `web_fetch` asks for a host not on it. + ## Provenance (Slice 022, M1 as built) Every tool result that came from outside the app is a `Trinity.Content.Part` tainted `untrusted` with a SHA-256 @@ -274,7 +330,7 @@ regulated effect. issuer's metadata (its `issuer` must equal the one asked for), begins the code flow with PKCE, `state` and `resource`, sends the owner to the authorization server, and finishes at `/oauth/callback` (`iss` checked against the issuer, the code exchanged with the verifier). The token is stored per resource - under `/secrets/oauth/` (mode 0600, until slice 100's keychain) and the driver presents it, + under `/oauth/` (mode 0600; `/secrets/oauth/` before slice 135) and the driver presents it, performing no flow of its own (a census over its files). The client identifies itself by the configured `client_id` (pre-registered at the enterprise server), by a Client ID Metadata Document URL, or by registering once when the server offers it and `dcr: true`. @@ -315,9 +371,9 @@ regulated effect. and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at a built shell binary restores it. An export made with the private key does not carry a keychain-held key; that is a follow-up of slice 034's backup story. -- **Not yet in the keychain:** the MCP OAuth client tokens (`/secrets/oauth/`, slice 062) - and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice - moves them. +- **Not yet in the keychain:** the MCP OAuth client tokens (`/oauth/`, slice 062) + and the MCP server's token and state key (slice 061). They stay files in the secrets directory + (slice 135; under the data directory before it) until a slice moves them. ## The desktop shell's channel (slice 100) @@ -393,7 +449,7 @@ The signature is over DSSE's PAE of a payload type and the canonical body, the t same key signs. `key_id` is the RFC 7638 thumbprint of the public key and sits inside the signed body; the registry row binds one algorithm to it and the verifier takes the algorithm from there, refusing a scheme whose family is not the row's before any signature check, and refusing schemes the caller did not allow. The key is a -0600 file under the data directory's `keys/`, read on every sign and never cached, so a key removed mid-run is a +file under the secrets directory's `keys/` (the data directory's before slice 135), read on every sign and never cached, so a key removed mid-run is a signer unavailable at the next receipt. What a file-backed key establishes: that the chain was not altered after the fact by anything lacking read access to that file, and nothing more. Selection is once, at boot: P-384 when `crypto:info_fips/0` is `enabled` (proven on the `fips` leg), Ed25519 otherwise, ML-DSA-87 by configuration diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 880b204..e7de882 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -17,9 +17,10 @@ configuration is refused at boot with a logged reason, and the tool never appear `tools/list`. The list is sorted, and it carries `ttlMs` (60 s by default) and `cacheScope` (`private`). -Two files the server keeps in the data directory: `mcp-server-token`, the bearer clients present -(generated once, mode 0600; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`, the -key that seals the state a held call carries between round trips (docs/07, "MCP server"). +Two files the server keeps in the secrets directory (`TRINITY_SECRETS_DIR`; under the data +directory before slice 135, and moved on the first boot after it): `mcp-server-token`, the bearer +clients present (generated once; `TRINITY_MCP_SERVER_TOKEN` overrides it), and `keys/mcp-state.key`, +the key that seals the state a held call carries between round trips (docs/07, "MCP server"). ## Authorization profiles (slice 062) @@ -50,7 +51,7 @@ and what you may ask for, never that a thing may happen. Trinity as a client of a protected server: add the server on the `/mcp` page as usual; when it answers `401` with resource metadata, the row shows the challenge and an "authorize" button that sends you to that server's authorization server and back to `/oauth/callback`. The token is stored under -`/secrets/oauth/` (mode 0600) and presented on every request; `TRINITY_MCP_AUTH_CLIENT_ID` is the +`/oauth/` (mode 0600) and presented on every request; `TRINITY_MCP_AUTH_CLIENT_ID` is the client id you registered there (or `_CLIENT_METADATA_URL` for a metadata document; `_DCR=1` to register on the spot when the server allows it). @@ -60,7 +61,7 @@ The URL is `http://127.0.0.1:/mcp` (the port the app prints at start, `400 profile) and the bearer is the token above: ``` -$ cat ~/.local/share/trinity/mcp-server-token # Linux; the data directory differs per OS +$ cat ~/.config/trinity/secrets/mcp-server-token # Linux; the secrets directory differs per OS ``` **Claude Code** (its `mcp add` command, or `.mcp.json` in a project): diff --git a/docs/packaging.md b/docs/packaging.md index af96d6e..5951792 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -84,6 +84,37 @@ and is writable, created if absent and otherwise only looked at; binding, exit 6 which the slice's criterion also names, is not in the bundle: slice 032 measured it incompatible with the pinned `nx` and chose the Elixir scorer, which `TRINITY_SMOKE_VEC` already exercises. +## The secrets directory (slice 135) + +Trinity keeps its own secrets in a directory of their own, never inside the data directory: the +receipt signing key when no keychain holds it and its registry (`keys/`), the MCP state key, the MCP +bearer token (`mcp-server-token`) and the MCP client's OAuth tokens (`oauth/`). The directory is mode +0700. No filesystem root may be, contain or sit inside it or the data directory: the boot refuses one +that does, naming it. + +| Where | Default | +|---|---| +| Linux | `$XDG_CONFIG_HOME/trinity/secrets`, else `~/.config/trinity/secrets` | +| macOS | `~/Library/Application Support/Trinity Secrets` | +| Windows | `%LOCALAPPDATA%\Trinity\Secrets` | +| The headless container | `/data/secrets`, beside the data directory on the `/data` volume | + +`TRINITY_SECRETS_DIR` overrides all of them. An installation made before slice 135 keeps these files +under the data directory; the first boot after it moves them (a rename, so the key keeps its id and +every receipt signed before the move still verifies), and the boot receipt names what moved. + +**A systemd service** can hold the directory in a credential store: with +`LoadCredential=` (or `LoadCredentialEncrypted=`) for each file, `$CREDENTIALS_DIRECTORY` is a +private, read-only directory only the service can read, and `TRINITY_SECRETS_DIR=%d` points Trinity +at it. **A Kubernetes pod** can mount a Secret as a volume (`tmpfs`, read-only, `defaultMode: 0400`) +and set `TRINITY_SECRETS_DIR` to the mount path. Either way the directory is read-only, so it must +already hold what Trinity would otherwise create on its first boot: run once with a writable secrets +directory, then load the resulting files (`keys/receipts-.key`, `keys/registry.json`, +`keys/mcp-state.key`, and `mcp-server-token` unless `TRINITY_MCP_SERVER_TOKEN` is set) into the +credential store. A read-only directory also means the MCP client cannot store new OAuth tokens and a +key rotation cannot write its registry rows; keep the secrets directory writable where either is +needed. + ## The desktop shell (slice 100) `src-tauri/` is the Tauri shell. It starts the sidecar (the Burrito binary, or in development a diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index 25186ea..1a9f70e 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -67,9 +67,11 @@ register does not claim is never Trinity's. | `AC-3` | An approval arriving on a chat channel may authorize at most a read, network or write effect; exec and destructive requests are refused by the channel's cap, whatever the profile | `lib/trinity/gateways/cap.ex` | `test/trinity/gateways/approvals_test.exs` "AC8: a destructive request is refused by the cap, receipted, and left for the desktop" | Trinity's | none | none | | `AC-4` | Under `TRINITY_PROFILE=regulated` the node refuses to boot when a configured model's endpoint is not on `TRINITY_REGULATED_LLM_ENDPOINTS`, or states none. The check runs once, at boot, over the configured models | `lib/trinity/profile.ex`, `lib/trinity/application.ex` | `Trinity.Profile.check_llm_endpoints/3`; `test/trinity/regulated_boot_node_test.exs` "production mcp_auth and a non-Local authority, but no allow-list, does not start"; `test/trinity/profile_test.exs` "a model off the list is refused, naming the model and the endpoint" | shared | The allow-list itself, and the egress filtering that stops anything else leaving | CRM "Enforcing that only an approved model may be called" | | `AC-4` | Under `TRINITY_PROFILE=regulated` every configured gateway adapter must be named in `TRINITY_REGULATED_GATEWAYS`, or the node refuses to boot | `lib/trinity/profile.ex` | `Trinity.Profile.check_gateways/3`; `test/trinity/regulated_boot_node_test.exs` "a configured adapter refuses without the allow-list and boots with it" | shared | Naming the channels, and authorizing each one | CRM "Which chat gateways may carry conversation text" | +| `AC-4` | Under `TRINITY_PROFILE=regulated` a network default of `:allow` needs `TRINITY_REGULATED_EGRESS`, or the node refuses to boot; with the list set, `web_fetch` asks for a host not on it. It governs the agent's fetch tool, not the process's own connections, which `SC-7` leaves to the deployment | `lib/trinity/profile.ex` | `Trinity.Profile.check_egress/3`; `test/trinity/profile_egress_test.exs` "regulated refuses network: :allow with no list, by the variable's name"; `test/trinity/profile_egress_test.exs` "regulated accepts a list, or a network default that already asks" | shared | Naming the hosts | CRM "Which hosts the agent's fetch tool may reach without asking" | | `AC-4` | Nothing in the tree decides what may leave the boundary; `data-flow.md` states what enters a prompt | `docs/regulated/data-flow.md` | none | the deployment's | The information flow policy, and the egress controls that enforce it | CRM "Deciding what may leave the boundary at all" | | `AC-5` | Under `TRINITY_PROFILE=regulated` the node refuses to boot on the local authority, where one process would decide, act and record; an external adapter that changes the arguments it decided on is denied | `lib/trinity/profile.ex`, `lib/trinity/authority.ex`, `lib/trinity/effects.ex` | `Trinity.Profile.check_authority/2`; `test/trinity/regulated_boot_node_test.exs` "everything else correct, but TRINITY_AUTHORITY unset, does not start"; `test/trinity/effects/membrane_test.exs` "an adapter that rewrites the arguments in stage/2 is denied, with the fingerprint re-derived after stage" | shared | The external authority adapter. None ships in this tree, and it is assessed with the deployment | CRM "Who decides whether an effect may happen" | | `AC-6` | The image runs as UID 10001, not root, which is checked on the built image; it writes only `/data` and `/tmp` | `ci/ironbank/Dockerfile`, `lib/mix/tasks/trinity.image.inspect.ex` | `mix trinity.image.inspect`; `test/mix/tasks/trinity_image_inspect_test.exs` "no user, root, UID 0 and a UID below 1000 are refused" | shared | A read-only root filesystem, dropped capabilities and no privilege escalation, set by the runtime that starts the container | CRM "Running the image read-only, with no capabilities and no privilege escalation" | +| `AC-6` | No file in the data directory or the secrets directory is reachable through any configured root by any tool that takes a path, by direct path, `..`, a symlink, a hard link or `/proc/self/fd`; a census over the registry's path-taking tools holds it, and a new path-taking tool fails it until it is covered | `lib/trinity/tools/fs/guard.ex`, `lib/trinity/paths.ex` | `test/trinity/tools/fs/secrets_census_test.exs` "no file under the data directory or the secrets directory is reachable" | Trinity's | none | none | | `AC-16` | Content from outside the machine is marked untrusted, with its digest, and reaches the model only inside an untrusted block | `lib/trinity/tools/untrusted.ex` | `test/trinity/tools/provenance_test.exs` "the assistant's summary of a fetched page carries taint untrusted; the user's message stays trusted" | Trinity's | none | none | | `AU-2` | The events recorded are a closed vocabulary of receipt kinds: decision, effect, query, boot and cap | `lib/trinity/receipts/receipt.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: the kinds the document names are exactly Receipt.kinds/0" | Trinity's | none | none | | `AU-3` | Every receipt signs one field set: its kind, its subject, the decision, the fingerprint, the time, the key, and its place in the chain | `lib/trinity/receipts/chain_writer.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: every signed field has a row in the mapping table, and every row names a signed field" | Trinity's | none | none | @@ -107,7 +109,7 @@ register does not claim is never Trinity's. | `SC-8` | Connections Trinity makes offer TLS 1.2 and 1.3 and nothing older; the check covers the versions offered, not peer verification or the trust store | `README.md` | `test/supply_chain_test.exs` "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" | shared | The trust store peers are verified against | CRM "TLS on connections Trinity makes"; Register "TLS floor 1.2 in FIPS mode": :unknown | | `SC-8` | The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default, every interface in the image; `force_ssl` trusts `x-forwarded-proto`, a header any client can send unless a proxy strips it | `config/runtime.exs`, `ci/ironbank/Dockerfile`, `config/prod.exs` | none | the deployment's | TLS in front of the process, and a proxy that strips inbound `x-forwarded-*` headers | CRM "TLS on connections made to Trinity" | | `SC-12` | The receipt signing key is generated once, mode 0600, registered, and read at every signature by the custody module and by nothing else | `lib/trinity/receipts/key_custody.ex`, `lib/trinity/keys.ex` | `test/trinity/receipts/signer_test.exs` "boot generates the key once (0600), appends its registry row, and a second boot reuses it"; `test/trinity/keys/custody_census_test.exs` "no module outside the custody seam reads key material from disk" | Trinity's | none | CRM "Receipt signing key generation and use" | -| `SC-12` | Key material lives in the data directory's keys directory by default, sealed when a key source is configured | `lib/trinity/paths.ex`, `lib/trinity/keys/local.ex` | `test/trinity/keys/signer_seam_test.exs` "the signing key is ciphertext on disk and the registry says so" | shared | Where the keys live, and who can read them | CRM "Where key material lives"; Register "Exclusive deployment control of encryption keys": :unknown | +| `SC-12` | Key material lives in the secrets directory by default, outside the data directory and unreachable by any filesystem tool (slice 135), sealed when a key source is configured | `lib/trinity/paths.ex`, `lib/trinity/keys/local.ex` | `test/trinity/keys/signer_seam_test.exs` "the signing key is ciphertext on disk and the registry says so" | shared | Where the keys live, and who can read them | CRM "Where key material lives"; Register "Exclusive deployment control of encryption keys": :unknown | | `SC-12` | Keys pass through one behaviour, so a KMS or HSM adapter is a module and a configuration line; none ships in this tree | `lib/trinity/keys.ex` | `test/trinity/keys/signer_seam_test.exs` "a sealed key still signs, which is the point of sealing it" | shared | A KMS or HSM adapter, where one is required | CRM "A KMS or HSM adapter" | | `SC-12` | Custody of the signing key is not addressed: a key in a file shows the records were not altered by anything without read access to it, and nothing about who held it | `docs/10-assurance-case.md` | none | the deployment's | Custody of the signing key | CRM "Custody of the signing key" | | `SC-13` | Signing picks its algorithm by the runtime's mode: Ed25519 by default, ECDSA P-384 with SHA-384 in FIPS mode, where Ed25519 reports itself unavailable. The gate and the suite run in FIPS mode on every push | `lib/trinity/receipts/signer.ex`, `docs/fips-leg.md`, `docs/regulated/crypto-inventory.md` | `test/trinity/receipts/signer_test.exs` "outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384"; `test/fips/receipts_test.exs` "the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it" | shared | A validated cryptographic module on the host, established from its certificate and the running binary | Register "FIPS 140-3 validated cryptography in FIPS mode": :unknown; Register "FIPS 140-2 certificates on the Historical list from 2026-09-22": real-world dependency | diff --git a/docs/regulated/customer-responsibility-matrix.md b/docs/regulated/customer-responsibility-matrix.md index ee959c5..abefadf 100644 --- a/docs/regulated/customer-responsibility-matrix.md +++ b/docs/regulated/customer-responsibility-matrix.md @@ -37,6 +37,7 @@ deployment, and it does not supply what the deployment has to. | Control | Trinity software | Customer | Host or AWS | |---|---|---|---| | Which chat gateways may carry conversation text | **Yes, under the regulated profile, at boot.** Every configured adapter must be named in `TRINITY_REGULATED_GATEWAYS` (`lib/trinity/profile.ex`) | **Yes.** Naming the channels, and authorizing each one | No | +| Which hosts the agent's fetch tool may reach without asking | **Yes, under the regulated profile, at boot.** With a network default of `:allow` the node refuses to start unless `TRINITY_REGULATED_EGRESS` names the hosts, and `web_fetch` then asks for a host not on it (`lib/trinity/profile.ex`, slice 135). It governs the agent's fetch tool, not the process's own connections | **Yes.** Naming the hosts | No | | Who decides whether an effect may happen | **Yes, as a refusal.** Under the regulated profile the node will not boot on the local authority (`lib/trinity/profile.ex`); no other adapter ships in this tree (`authorization-boundary.md`) | **Yes.** The external authority adapter, and its assessment | No | ## Keys @@ -44,7 +45,7 @@ deployment, and it does not supply what the deployment has to. | Control | Trinity software | Customer | Host or AWS | |---|---|---|---| | Receipt signing key generation and use | **Yes.** `lib/trinity/receipts/key_custody.ex`, seam at `lib/trinity/keys.ex` | No | No | -| Where key material lives | Default is a 0600 file in the data directory, `lib/trinity/paths.ex` (`keys_dir/0`) | **Yes.** Custody is a deployment decision | Filesystem permissions | +| Where key material lives | Default is a file in the secrets directory, outside the data directory and unreachable by any tool (`lib/trinity/paths.ex`, `secrets_dir/0` and `keys_dir/0`; `TRINITY_SECRETS_DIR`, slice 135) | **Yes.** Custody is a deployment decision | Filesystem permissions | | A KMS or HSM adapter | Seam exists (`lib/trinity/keys.ex`); **no KMS or HSM adapter is in the tree** | **Yes**, if required | **Yes** | | Custody of the signing key | **No, and this is stated rather than implied.** `docs/10-assurance-case.md` C6 | **Yes** | **Yes** | | Envelope encryption of blobs | **Yes.** AES-256-GCM, `docs/encryption-at-rest.md` | No | No | diff --git a/lib/trinity.ex b/lib/trinity.ex index b0500b9..ad70d05 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -48,6 +48,8 @@ defmodule Trinity do # check, and the desktop seam (a boundary of its own under this one, as `Gateways` is). Secrets, Secrets.Keychain, + # Slice 135: the application runs the move at boot; the boot receipt names it. + Secrets.Migration, Settings, Setup, Desktop, @@ -91,6 +93,9 @@ defmodule Trinity do # Slice 061: the MCP server reads the exported entries' definitions and runs calls # through the membrane's runner. Tools.Registry, + # Slice 135: the application asks it before any child, the Settings page before saving a + # folder. + Tools.FS.Guard, Effects.Runner, Permissions, Permissions.Approval, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index b331275..188b69b 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -29,6 +29,11 @@ defmodule Trinity.Application do # project's rules about key material, and the filter is the last line for exactly that case. install_log_redaction() + # Slice 135, in every profile: a filesystem root that is, contains or sits inside the data + # directory or the secrets directory refuses the boot, before any child (and before the + # regulated refusals, so the reason printed is this one when both apply). + Trinity.Tools.FS.Guard.verify_boot!() + # Slice regulated-boot. Before any child, because a refused boot should have started nothing: # the same reasoning the DataDir lock is placed early for. Under `:default` this returns :ok # without reading anything. @@ -50,6 +55,10 @@ defmodule Trinity.Application do # Slice 010: one node per data directory. Before the Repo, so a refused boot has # opened no database file; the reason names the holder's OS pid and mode. {Trinity.DataDir.Lock, dir: lock_dir(), mode: mode()}, + # Slice 135: secrets an earlier release kept under the data directory move to the + # secrets directory, once the lock is held and before the signer or the MCP server + # reads them. A task that finishes inside `start_link`, so the order is the boot's. + Trinity.Secrets.Migration, # Slice 024 (ADR-0010): the authority is selected once, here, before anything that # could act; a refused selection stops the boot with its reason. Trinity.Authority.Selection, @@ -147,6 +156,12 @@ defmodule Trinity.Application do profile, Trinity.Profile.raw_gateways(), Trinity.Gateways.adapters() + ), + # Slice 135: a network default of :allow is only regulated with an egress allow-list. + Trinity.Profile.check_egress( + profile, + Trinity.Permissions.default_decision(:network), + Trinity.Profile.raw_egress() ) ] |> Enum.each(fn diff --git a/lib/trinity/archive/layout.ex b/lib/trinity/archive/layout.ex index 5a675fd..516a2c6 100644 --- a/lib/trinity/archive/layout.ex +++ b/lib/trinity/archive/layout.ex @@ -46,8 +46,10 @@ defmodule Trinity.Archive.Layout do receipts_db: Application.get_env(:trinity, Trinity.Repo.Receipts)[:database] || Path.join(data_dir, "receipts.db"), + # Slice 135: the keys live in the secrets directory, not the data directory. keys_dir: - Application.get_env(:trinity, :receipts, [])[:keys_dir] || Path.join(data_dir, "keys"), + Application.get_env(:trinity, :receipts, [])[:keys_dir] || + Path.join(Trinity.Paths.secrets_dir(), "keys"), skills_dir: Path.join(data_dir, "skills"), personas_dir: Path.join(data_dir, "personas") } diff --git a/lib/trinity/core_policy.ex b/lib/trinity/core_policy.ex index 251d4c8..4890085 100644 --- a/lib/trinity/core_policy.ex +++ b/lib/trinity/core_policy.ex @@ -24,6 +24,9 @@ defmodule Trinity.CorePolicy do Trinity.Permissions.Gate, Trinity.Tools.Catalog, Trinity.Tools.Runner, + # Slice 135: the guard denies a path before the policy is asked, so it decides what the agent + # may do as much as the policy does. + Trinity.Tools.FS.Guard, Trinity.Effects, Trinity.Effects.Runner, Trinity.Authority, diff --git a/lib/trinity/effects/boot.ex b/lib/trinity/effects/boot.ex index 5feac62..cf25979 100644 --- a/lib/trinity/effects/boot.ex +++ b/lib/trinity/effects/boot.ex @@ -27,6 +27,14 @@ defmodule Trinity.Effects.Boot do "node" => Atom.to_string(node()) } + # Slice 135: a boot that moved secrets out of the data directory says so on its signed record: + # from where, to where, which files, and any left behind because the destination was taken. + subject = + case Trinity.Secrets.Migration.report() do + nil -> subject + report -> Map.put(subject, "secrets_migration", report) + end + meta = %{ "core_policy_hash" => Trinity.CorePolicy.hash(), "canonicalization_version" => Trinity.Permissions.Fingerprint.version() diff --git a/lib/trinity/effects/runner.ex b/lib/trinity/effects/runner.ex index 50cc6c9..2d12782 100644 --- a/lib/trinity/effects/runner.ex +++ b/lib/trinity/effects/runner.ex @@ -16,6 +16,7 @@ defmodule Trinity.Effects.Runner do alias Trinity.Authority.Staged alias Trinity.Receipts alias Trinity.Tools.{Context, Result, Runner} + alias Trinity.Tools.FS.Guard @doc "One call (the seam's `run/2`)." @spec run(map(), map()) :: {:ok, Result.t(), map()} | {:error, term(), map()} @@ -31,16 +32,21 @@ defmodule Trinity.Effects.Runner do @doc "The executor: decide and receipt, then read directly or cross the membrane." @spec execute(map(), map(), Context.t()) :: {:ok, Result.t()} | {:error, term()} def execute(entry, args, %Context{} = ctx) do + # Slice 135: the guard's verdicts are taken once, decide on them, and are receipted as taken. + verdicts = Runner.fs_verdicts(entry, args, ctx) + {decision, fp, reason, basis} = - case Runner.decide(entry, args, ctx) do + case Runner.decide(entry, args, ctx, verdicts) do {:allow, fp, basis} -> {:allow, fp, nil, basis} + {:deny, fp, "fs_guard"} -> {:deny, fp, fs_denied(verdicts), "fs_guard"} {:deny, fp, basis} -> {:deny, fp, :denied, basis} {:ask, why, fp, basis} -> {:ask, fp, why, basis} end scope = scope(ctx) + fs = Enum.map(verdicts, &Guard.receipt_fields(&1, entry.name, ctx)) - case decision_receipt(scope, entry, ctx, decision, fp, reason, basis) do + case decision_receipt(scope, entry, ctx, {decision, fp, reason, basis}, fs) do {:ok, _} -> dispatch(decision, entry, args, ctx, scope, fp, reason) {:error, why} -> {:error, {:decision_not_receipted, why}} end @@ -77,14 +83,15 @@ defmodule Trinity.Effects.Runner do def scope(%Context{session_id: nil}), do: Receipts.session_scope("none") def scope(%Context{session_id: sid}), do: Receipts.session_scope(sid) + # The model is told which path was refused and by which rule, never anything read. + defp fs_denied(verdicts), do: {:fs_denied, Enum.find(verdicts, &(&1.decision == :deny))} + defp decision_receipt( scope, %{name: name, effect: effect, digest: digest}, ctx, - decision, - fp, - reason, - basis + {decision, fp, reason, basis}, + fs ) do Receipts.append(scope, %{ kind: "decision", @@ -95,18 +102,29 @@ defmodule Trinity.Effects.Runner do "tool" => name, "effect" => Atom.to_string(effect) }), - decision: %{ - "outcome" => Atom.to_string(decision), - "basis" => basis, - "reason" => reason && inspect(reason) - }, + decision: + with_fs( + %{ + "outcome" => Atom.to_string(decision), + "basis" => basis, + "reason" => reason && inspect(reason) + }, + fs + ), fingerprint: fp, subject_ref: "decision:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}", meta: trace(ctx, %{"tool_definition_digest" => digest}) }) end + # Slice 135, AC6: every fs decision is on the chain, a decision receipt's denials included. A + # call that names no host path carries no `fs` key, so every other receipt reads as before. + defp with_fs(decision, []), do: decision + defp with_fs(decision, fs), do: Map.put(decision, "fs", fs) + # Every read emits a query receipt (docs/07): chained, checkpointed, never blocking the read. + # Slice 135: a refusal the guard made at open (a swap since the decision, a file met inside a + # directory a grep walked) rides on it with the same fields as the decision's. defp query_receipt(scope, %{name: name, digest: digest}, ctx, result) do outcome = case result do @@ -114,6 +132,9 @@ defmodule Trinity.Effects.Runner do {:error, reason} -> %{"ok" => false, "error" => inspect(reason)} end + fs = Enum.map(open_refusals(result), &Guard.receipt_fields(&1, name, ctx)) + outcome = with_fs(outcome, fs) + Receipts.append(scope, %{ kind: "query", subject: @@ -124,6 +145,13 @@ defmodule Trinity.Effects.Runner do }) end + defp open_refusals({:error, {:fs_denied, %{} = verdict}}), do: [verdict] + + defp open_refusals({:ok, %Result{meta: %{"fs_refused" => refused}}}) when is_list(refused), + do: refused + + defp open_refusals(_), do: [] + # Slice 061: a call that did not come from the desktop says where it came from (`"mcp"`), # and the caller's trace context rides in the meta for 090; a desktop call carries neither, # so every receipt written before this slice reads the same. diff --git a/lib/trinity/mcp/auth_host.ex b/lib/trinity/mcp/auth_host.ex index 02aa773..923b731 100644 --- a/lib/trinity/mcp/auth_host.ex +++ b/lib/trinity/mcp/auth_host.ex @@ -3,7 +3,7 @@ defmodule Trinity.MCP.AuthHost do @moduledoc """ The tree's side of the authorization boundary (slice 062): builds `Trinity.MCP.Auth.Config` - from `config :trinity, :mcp_auth` and the paths and facts only the tree knows (the data + from `config :trinity, :mcp_auth` and the paths and facts only the tree knows (the secrets directory, the keys directory, whether the local authority is in force), starts the personal profile's authorization server when that profile is chosen (and refuses to boot it under an external authority adapter, by raising with the reason), authorizes a request and receipts @@ -32,15 +32,17 @@ defmodule Trinity.MCP.AuthHost do @spec reload() :: Config.t() def reload do env = Application.get_env(:trinity, :mcp_auth, []) - data_dir = Trinity.Paths.ensure_data_dir() + # Slice 135: the bearer token and the OAuth store live in the secrets directory, never under + # the data directory (`Trinity.Secrets.Migration` moves an earlier release's). + secrets_dir = Trinity.Paths.ensure_secrets_dir() profile = Keyword.get(env, :profile, :local) opts = env |> Keyword.put(:profile, profile) - |> Keyword.put_new(:token_path, Path.join(data_dir, "mcp-server-token")) + |> Keyword.put_new(:token_path, Path.join(secrets_dir, "mcp-server-token")) |> Keyword.put_new(:key_dir, Trinity.Receipts.KeyCustody.keys_dir()) - |> Keyword.put_new(:store_dir, Path.join([data_dir, "secrets", "oauth"])) + |> Keyword.put_new(:store_dir, Path.join(secrets_dir, "oauth")) |> Keyword.put(:local_authority?, Trinity.Authority.impl() == Trinity.Authority.Local) config = Config.new!(opts) diff --git a/lib/trinity/paths.ex b/lib/trinity/paths.ex index 1c5e07d..e50b6ab 100644 --- a/lib/trinity/paths.ex +++ b/lib/trinity/paths.ex @@ -28,6 +28,10 @@ defmodule Trinity.Paths do These are the three `SLICE.md` names. The case difference is not a slip: `trinity` lower-case is the XDG convention, `Trinity` capitalised is the macOS and Windows convention, and a packaged app that ignores either looks wrong to the person whose disk it is on. + + The secrets directory (slice 135, `secrets_dir/0`) is a separate directory with its own default + per OS, never inside the data directory: the receipt keys, the MCP state key and bearer token, the + MCP client's OAuth tokens. A filesystem tool can reach neither (`Trinity.Tools.FS.Guard`). """ # Sobelow reads `@sobelow_skip` out of the source AST; the compiler never sees it used and @@ -106,16 +110,75 @@ defmodule Trinity.Paths do def receipts_database_path, do: Path.join(ensure_data_dir(), "receipts.db") @doc """ - The directory the receipt signing key and the key registry live in (slice 024): under the - data directory, not `priv/`, because `priv` is the packaged tree and a key made on this - machine is not the tree's to carry. Created with mode 0700 when absent. + The directory Trinity's own secrets live in (slice 135): the receipt key and its registry when no + keychain holds the key, the MCP server's state key and bearer token, the MCP client's OAuth tokens. + Never under the data directory, so no filesystem root that reaches the data directory reaches a + secret, and a backup or a sync of the data directory carries none. + + `TRINITY_SECRETS_DIR`, else `config :trinity, :secrets_dir`, else the OS default + (`secrets_dir/2`). A container points the variable at a systemd `LoadCredential` directory or a + Kubernetes Secret volume (docs/packaging.md). Not created here: see `ensure_secrets_dir/0`. + """ + @spec secrets_dir() :: String.t() + def secrets_dir do + case System.get_env("TRINITY_SECRETS_DIR") do + dir when is_binary(dir) and dir != "" -> + Path.expand(dir) + + _ -> + case Application.get_env(:trinity, :secrets_dir) do + dir when is_binary(dir) and dir != "" -> Path.expand(dir) + _ -> secrets_dir(:os.type(), &System.get_env/1) + end + end + end + + @doc """ + The default secrets directory for `os_type`: beside the data directory's conventions, never + inside it. Linux `$XDG_CONFIG_HOME/trinity/secrets` (else `~/.config/trinity/secrets`), macOS + `~/Library/Application Support/Trinity Secrets`, Windows `%LOCALAPPDATA%\\Trinity\\Secrets` + (local, not roaming: a key made on this machine is not the roaming profile's to carry). + """ + @spec secrets_dir(os_type(), getenv()) :: String.t() + def secrets_dir({:win32, _}, getenv) do + root = getenv.("LOCALAPPDATA") || Path.join(home(getenv), "AppData\\Local") + root <> "\\" <> @display_name <> "\\Secrets" + end + + def secrets_dir({:unix, :darwin}, getenv) do + Path.join([home(getenv), "Library", "Application Support", @display_name <> " Secrets"]) + end + + def secrets_dir({:unix, _}, getenv) do + root = getenv.("XDG_CONFIG_HOME") || Path.join(home(getenv), ".config") + Path.join([root, @unix_name, "secrets"]) + end + + @doc "`secrets_dir/0`, created with mode 0700 when absent (and set to 0700 when present). Returns the path." + # sobelow_skip reason: Traversal.FileModule, as ensure_data_dir/0: the path is the operator's + # variable, this application's configuration or the OS default, never request input. + @sobelow_skip ["Traversal.FileModule"] + @spec ensure_secrets_dir() :: String.t() + def ensure_secrets_dir do + dir = secrets_dir() + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + dir + end + + @doc """ + The directory the receipt signing key and the key registry live in (slice 024): `keys/` under + the secrets directory since slice 135 (under the data directory before it, where a filesystem + tool could read it; `Trinity.Secrets.Migration` moves an existing one). Not `priv/`, because + `priv` is the packaged tree and a key made on this machine is not the tree's to carry. Created + with mode 0700 when absent. """ - # sobelow_skip reason: Traversal.FileModule, as ensure_data_dir/0 above: the path is the data + # sobelow_skip reason: Traversal.FileModule, as ensure_data_dir/0 above: the path is the secrets # directory plus a constant, never input. @sobelow_skip ["Traversal.FileModule"] @spec keys_dir() :: String.t() def keys_dir do - dir = Path.join(ensure_data_dir(), "keys") + dir = Path.join(ensure_secrets_dir(), "keys") File.mkdir_p!(dir) File.chmod!(dir, 0o700) dir diff --git a/lib/trinity/permissions.ex b/lib/trinity/permissions.ex index d99dfb6..38e2465 100644 --- a/lib/trinity/permissions.ex +++ b/lib/trinity/permissions.ex @@ -108,6 +108,20 @@ defmodule Trinity.Permissions do {tightened, Policy.State.basis(basis, applied)} end + @doc """ + The default decision for a tier in force: `config :trinity, :permissions, default:` when set, + else the layered policy's built-in table (`read: :allow, network: :allow`, the rest `:ask`). + Slice 135: the regulated boot asks it for `:network`. + """ + @spec default_decision(tier()) :: decision() + def default_decision(tier) do + defaults = + Application.get_env(:trinity, :permissions, []) + |> Keyword.get(:default, Policy.Layered.defaults()) + + Map.get(defaults, tier, :ask) + end + @doc "The fingerprint of a call as this session would bind it." @spec fingerprint(String.t() | nil, String.t(), map(), String.t() | nil) :: String.t() def fingerprint(session_id, tool, args, cwd), diff --git a/lib/trinity/permissions/policy/layered.ex b/lib/trinity/permissions/policy/layered.ex index abbd902..d88ee4b 100644 --- a/lib/trinity/permissions/policy/layered.ex +++ b/lib/trinity/permissions/policy/layered.ex @@ -24,6 +24,10 @@ defmodule Trinity.Permissions.Policy.Layered do @default %{read: :allow, network: :allow, write: :ask, exec: :ask, destructive: :ask} + @doc "The built-in decision per tier, before `config :trinity, :permissions, default:` overrides it." + @spec defaults() :: %{atom() => Trinity.Permissions.decision()} + def defaults, do: @default + # Slice 030: the answer names the layer that decided, for the decision receipt. @impl true def decide(session_id, tool, args, opts) do diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex index 63c24ca..26860a5 100644 --- a/lib/trinity/profile.ex +++ b/lib/trinity/profile.ex @@ -32,6 +32,7 @@ defmodule Trinity.Profile do @env "TRINITY_PROFILE" @endpoints_env "TRINITY_REGULATED_LLM_ENDPOINTS" @gateways_env "TRINITY_REGULATED_GATEWAYS" + @egress_env "TRINITY_REGULATED_EGRESS" @doc """ The profile in force: `#{@env}`, else `config :trinity, :profile`, else `:default`. @@ -71,6 +72,83 @@ defmodule Trinity.Profile do @spec gateways_env() :: String.t() def gateways_env, do: @gateways_env + @doc "The raw value of `#{@egress_env}`, unparsed." + @spec raw_egress() :: String.t() | nil + def raw_egress, do: System.get_env(@egress_env) + + @doc "The name of the egress allow-list variable." + @spec egress_env() :: String.t() + def egress_env, do: @egress_env + + @doc """ + Slice 135, AC5. Under `:regulated`, a permission default of `network: :allow` needs an egress + allow-list: `#{@egress_env}`, comma separated hosts. That is OWASP Agentic Top 10 2026 ASI02's + "egress allowlists" as a boot refusal. With the list set, `web_fetch` asks for a host not on it + (`allowed_egress?/2`). A regulated node whose network default is `:ask` or `:deny` needs no list, + since every fetch already reaches a person. + """ + @spec check_egress(t(), atom(), String.t() | nil) :: :ok | {:error, term()} + def check_egress(:default, _network_default, _raw), do: :ok + def check_egress(:regulated, network_default, _raw) when network_default != :allow, do: :ok + + def check_egress(:regulated, :allow, raw) do + if allowed_egress(raw) == [], + do: {:error, {:regulated_network_allow_without_egress_allowlist, @egress_env}}, + else: :ok + end + + @doc "Parses the egress allow-list into lower-case host names; an entry may be a bare host or a URL." + @spec allowed_egress(String.t() | nil) :: [String.t()] + def allowed_egress(nil), do: [] + + def allowed_egress(raw) when is_binary(raw) do + raw + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.flat_map(&egress_host/1) + end + + defp egress_host(entry) do + url = if String.contains?(entry, "://"), do: entry, else: "https://" <> entry + + case URI.parse(url) do + %URI{host: h} when is_binary(h) and h != "" -> [String.downcase(h)] + _ -> [] + end + end + + @doc """ + Whether `host` may be fetched without asking under `profile`: always under `:default`; under + `:regulated`, when it is on the egress allow-list (exactly, or a subdomain of an entry written + with a leading dot, `.example.com`). + """ + @spec allowed_egress?(t(), String.t()) :: boolean() + def allowed_egress?(:default, _host), do: true + + def allowed_egress?(:regulated, host) when is_binary(host) do + host = String.downcase(host) + + raw_egress() + |> allowed_entries() + |> Enum.any?(fn + "." <> suffix -> String.ends_with?(host, "." <> suffix) + entry -> host == entry + end) + end + + defp allowed_entries(nil), do: [] + + defp allowed_entries(raw) do + raw + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.flat_map(fn + "." <> _ = suffix -> [String.downcase(suffix)] + entry -> allowed_egress(entry) + end) + end + @doc """ AC1. Under `:regulated` the MCP authorization profile must be `:production`. diff --git a/lib/trinity/secrets/migration.ex b/lib/trinity/secrets/migration.ex new file mode 100644 index 0000000..0c1bdf9 --- /dev/null +++ b/lib/trinity/secrets/migration.ex @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Secrets.Migration do + @moduledoc """ + Moves the secrets an earlier release kept under the data directory into the secrets directory + (slice 135, AC4), once per boot, before anything reads them. + + | Before (under the data directory) | After (under `Trinity.Paths.secrets_dir/0`) | + |---|---| + | `keys/` (the receipt key, its registry, the MCP state key, the device id, the MCP authorization server's keys) | `keys/` | + | `mcp-server-token` (the MCP bearer token) | `mcp-server-token` | + | `secrets/oauth/` (the MCP client's OAuth tokens) | `oauth/` | + + A location the operator configured explicitly (`config :trinity, :receipts, keys_dir`, or + `:mcp_auth`'s `token_path` and `store_dir`) is the operator's and is not touched. + + **Moved, not copied and not re-keyed.** Each file is renamed (copied and then removed when the two + directories are on different filesystems), so the receipt key keeps its key id and the registry + moves whole: every receipt signed before the move still verifies against it. A file already present + at the destination is never overwritten; the source is left where it is and reported as a conflict + (it stays unreachable to every tool, being under the data directory). + + **Modes.** The directories are 0700. A file whose writer never rewrites it in place (key material: + `*.key`, `*.jwk.json`, the bearer token) is 0400; the registry and the OAuth store, which their + writers replace or rewrite, stay 0600 (slice 135 NOTES, D3). + + **Receipted.** The report is kept for the boot receipt (`Trinity.Effects.Boot`), which names it in + its signed subject, and logged. It names files and directories, never their contents. + """ + + require Logger + + # Sobelow reads `@sobelow_skip` from the source; the compiler would call it unused (the + # measure `Trinity.Paths` takes). + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @key {__MODULE__, :report} + + @typedoc "What one boot moved; `nil` when nothing was there to move." + @type report :: %{String.t() => term()} | nil + + @doc false + @spec child_spec(term()) :: Supervisor.child_spec() + def child_spec(arg), + do: %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}, restart: :temporary} + + @doc """ + Runs the move inside the supervisor's start, records the report, and starts nothing. Off when + `config :trinity, Trinity.Secrets.Migration, enabled: false`, which the suite sets: the suite runs + against the machine's own data directory, and a test boot must never move the machine's files + (slice 135 NOTES records the run that did, before this switch existed). + """ + @spec start_link(term()) :: :ignore + def start_link(_arg) do + if Application.get_env(:trinity, __MODULE__, []) |> Keyword.get(:enabled, true), + do: :persistent_term.put(@key, run()) + + :ignore + end + + @doc "The report of this boot's move, or `nil` when there was nothing to move." + @spec report() :: report() + def report, do: :persistent_term.get(@key, nil) + + @doc """ + Moves what is there to move and returns the report (`nil` when nothing was). `opts` override the + directories, for a test: `data_dir:`, `secrets_dir:`. + """ + @spec run(keyword()) :: report() + def run(opts \\ []) do + data = Keyword.get_lazy(opts, :data_dir, &Trinity.Paths.data_dir/0) + secrets = Keyword.get_lazy(opts, :secrets_dir, &Trinity.Paths.secrets_dir/0) + receipts = Application.get_env(:trinity, :receipts, []) + auth = Application.get_env(:trinity, :mcp_auth, []) + + moves = + [ + {is_nil(receipts[:keys_dir]), Path.join(data, "keys"), Path.join(secrets, "keys")}, + {is_nil(auth[:token_path]), Path.join(data, "mcp-server-token"), + Path.join(secrets, "mcp-server-token")}, + {is_nil(auth[:store_dir]), Path.join([data, "secrets", "oauth"]), + Path.join(secrets, "oauth")} + ] + |> Enum.filter(fn {ours?, from, _to} -> ours? and File.exists?(from) end) + + case moves do + [] -> nil + moves -> move_all(moves, data, secrets) + end + end + + defp move_all(moves, data, secrets) do + ensure_dir!(secrets) + results = Enum.map(moves, fn {_, from, to} -> {from, to, move(from, to)} end) + _ = File.rmdir(Path.join(data, "secrets")) + + keys_from = Path.join(data, "keys") + + report = %{ + "from" => data, + "to" => Path.join(secrets, "keys"), + "files" => + for({^keys_from, _, %{moved: moved}} <- results, do: moved) + |> List.flatten() + |> Enum.sort(), + "moved" => for({from, to, %{moved: [_ | _]}} <- results, do: %{"from" => from, "to" => to}), + "conflicts" => + for({_from, _to, %{conflicts: c}} <- results, path <- c, do: path) |> Enum.sort() + } + + Logger.warning( + "secrets: moved out of the data directory into #{secrets}: " <> + inspect(Map.take(report, ["moved", "files", "conflicts"])) + ) + + report + end + + # A directory: every entry moved into the destination directory, the source removed when it is + # left empty. A file: moved when the destination is free. + # sobelow_skip reason: Traversal.FileModule: every path is the data directory or the secrets + # directory plus a constant, or an entry a listing of one of them returned; never request input. + @sobelow_skip ["Traversal.FileModule"] + defp move(from, to) do + if File.dir?(from) do + ensure_dir!(to) + + acc = + from + |> File.ls!() + |> Enum.sort() + |> Enum.reduce(%{moved: [], conflicts: []}, &move_entry(&1, from, to, &2)) + + _ = File.rmdir(from) + %{moved: Enum.reverse(acc.moved), conflicts: Enum.reverse(acc.conflicts)} + else + ensure_dir!(Path.dirname(to)) + + case move_file(from, to) do + :ok -> %{moved: [Path.basename(from)], conflicts: []} + {:conflict, path} -> %{moved: [], conflicts: [path]} + end + end + end + + defp move_entry(name, from, to, acc) do + case move_file(Path.join(from, name), Path.join(to, name)) do + :ok -> %{acc | moved: [name | acc.moved]} + {:conflict, path} -> %{acc | conflicts: [path | acc.conflicts]} + end + end + + # sobelow_skip reason: Traversal.FileModule: as move/2. + @sobelow_skip ["Traversal.FileModule"] + # A rename that fails for any reason but a cross-device move raises, which stops the boot with + # the reason: a signer that found no key where the registry says one is would make a new one, and + # a split registry is worse than a refused start. + defp move_file(from, to) do + if File.exists?(to) do + {:conflict, from} + else + case File.rename(from, to) do + :ok -> :ok + {:error, :exdev} -> copy_then_remove(from, to) + {:error, reason} -> raise File.RenameError, source: from, destination: to, reason: reason + end + + protect(to) + :ok + end + end + + # sobelow_skip reason: Traversal.FileModule: as move/2. + @sobelow_skip ["Traversal.FileModule"] + defp copy_then_remove(from, to) do + {:ok, _} = File.cp_r(from, to) + {:ok, _} = File.rm_rf(from) + :ok + end + + # sobelow_skip reason: Traversal.FileModule: as move/2. + @sobelow_skip ["Traversal.FileModule"] + defp protect(path) do + case File.lstat(path) do + {:ok, %{type: :directory}} -> + File.chmod!(path, 0o700) + path |> File.ls!() |> Enum.each(&protect(Path.join(path, &1))) + + {:ok, %{type: :regular}} -> + File.chmod!(path, if(read_only?(path), do: 0o400, else: 0o600)) + + _ -> + :ok + end + end + + # Key material whose writer only ever creates it (a rotation writes a new file and renames). + defp read_only?(path) do + name = Path.basename(path) + + String.ends_with?(name, ".key") or String.ends_with?(name, ".jwk.json") or + name == "mcp-server-token" + end + + # sobelow_skip reason: Traversal.FileModule: as move/2. + @sobelow_skip ["Traversal.FileModule"] + defp ensure_dir!(dir) do + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + end +end diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index e7c9c90..cfe5e40 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -810,7 +810,14 @@ defmodule Trinity.Sessions.Session do # Slice 033: the project root is the tools' working directory (the row is read again so # a root set between turns is this turn's). row = Store.get_session(id) || session - context = %{session_id: id, caller: id, persona: persona, cwd: row.project_root} + + context = %{ + session_id: id, + caller: id, + persona: persona, + cwd: row.project_root, + turn_id: Map.get(turn, :id) + } # Slice 020: the turn's calls run at once through the runner in force. %Task{pid: pid} = diff --git a/lib/trinity/sessions/state.ex b/lib/trinity/sessions/state.ex index 97565d5..4344d19 100644 --- a/lib/trinity/sessions/state.ex +++ b/lib/trinity/sessions/state.ex @@ -12,6 +12,7 @@ defmodule Trinity.Sessions.State do alias Trinity.Sessions.SessionRow @type turn :: %{ + id: String.t(), ref: reference() | nil, task: pid() | nil, buffer: iodata(), @@ -49,6 +50,8 @@ defmodule Trinity.Sessions.State do @spec new_turn() :: turn() def new_turn do %{ + # Slice 135: the turn's own id, which every fs decision receipt of its tool calls carries. + id: Trinity.UUID.generate(), ref: nil, task: nil, buffer: [], diff --git a/lib/trinity/skills/learn.ex b/lib/trinity/skills/learn.ex index c00e521..2c3dbae 100644 --- a/lib/trinity/skills/learn.ex +++ b/lib/trinity/skills/learn.ex @@ -14,6 +14,7 @@ defmodule Trinity.Skills.Learn do alias Trinity.LLM.Request alias Trinity.Skills.Staging alias Trinity.Tools.{Context, FS} + alias Trinity.Tools.FS.Guard Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) @@ -46,13 +47,14 @@ defmodule Trinity.Skills.Learn do # against the session's working directory and read only when inside the configured roots. @sobelow_skip ["Traversal.FileModule"] def read_source(%{"file" => path}, %Context{cwd: cwd}) when is_binary(path) do - case FS.resolve(path, cwd) do - {:ok, real, :inside} -> - with {:ok, bytes} <- File.read(real), - do: {:ok, String.slice(bytes, 0, @max_source_bytes), "file:" <> real} - - {:ok, _real, :outside} -> - {:error, {:outside_roots, path}} + # Slice 135: opened by the guard, which refuses links, protected files and inodes, and fstats + # the descriptor before a byte is read; still only inside the roots. + with {:ok, _, :inside} <- FS.resolve(path, cwd), + {:ok, bytes, %{canonical: real}} <- Guard.read(path, cwd) do + {:ok, String.slice(bytes, 0, @max_source_bytes), "file:" <> real} + else + {:ok, _real, :outside} -> {:error, {:outside_roots, path}} + {:error, _} = refused -> refused end end diff --git a/lib/trinity/skills/tools/file.ex b/lib/trinity/skills/tools/file.ex index d3cbbf6..744cbde 100644 --- a/lib/trinity/skills/tools/file.ex +++ b/lib/trinity/skills/tools/file.ex @@ -11,6 +11,7 @@ defmodule Trinity.Skills.Tools.File do @behaviour Trinity.Tools.Tool alias Trinity.Tools.{Context, Untrusted} + alias Trinity.Tools.FS.Guard Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) @@ -55,7 +56,9 @@ defmodule Trinity.Skills.Tools.File do Enum.find(Trinity.Skills.active(project_root: cwd), &(&1.name == name)) || {:error, {:no_such_skill, name}}, {:ok, path} <- resolve(skill.path, rel), - {:ok, content} <- File.read(path) do + # Slice 135: opened by the guard inside the jail: a protected inode, a hard link, a + # special file or a deny-listed name is refused, the descriptor fstat'ed before a read. + {:ok, content, _verdict} <- Guard.read(path, nil, zones: false) do {text, truncated} = if byte_size(content) > @max_bytes, do: {binary_part(content, 0, @max_bytes) <> "\n[cut at #{@max_bytes} bytes]", true}, @@ -71,7 +74,7 @@ defmodule Trinity.Skills.Tools.File do {:ok, Untrusted.result(text, tool: name(), source_ref: "skill:#{skill.name}/#{rel}", meta: meta)} else - {:error, :enoent} -> {:error, {:no_such_file, rel}} + {:error, {:file, :enoent, _}} -> {:error, {:no_such_file, rel}} {:error, reason} -> {:error, reason} end end diff --git a/lib/trinity/skills/tools/learn.ex b/lib/trinity/skills/tools/learn.ex index 2e1ff1a..a606a31 100644 --- a/lib/trinity/skills/tools/learn.ex +++ b/lib/trinity/skills/tools/learn.ex @@ -37,6 +37,11 @@ defmodule Trinity.Skills.Tools.Learn do @impl true def effect, do: :artifact + # Slice 135: the `file` source is a host path the guard judges before the gate is asked. + @impl true + def fs_paths(%{"file" => file}, _ctx) when is_binary(file), do: [{file, :read}] + def fs_paths(_args, _ctx), do: [] + @impl true def execute(args, %Context{session_id: sid, persona: persona} = ctx) do model = persona && Map.get(persona, :model) diff --git a/lib/trinity/tools.ex b/lib/trinity/tools.ex index 734915b..cce7937 100644 --- a/lib/trinity/tools.ex +++ b/lib/trinity/tools.ex @@ -30,6 +30,8 @@ defmodule Trinity.Tools do DefinitionDigest, Surface, FS, + FS.Guard, + Taint, Web.Fetch ] diff --git a/lib/trinity/tools/context.ex b/lib/trinity/tools/context.ex index f6fc9d4..eec49e4 100644 --- a/lib/trinity/tools/context.ex +++ b/lib/trinity/tools/context.ex @@ -8,7 +8,9 @@ defmodule Trinity.Tools.Context do `call_id` (slice 024) is the model's id for this call, set by the runner per call; with the session it is the effect's idempotency key. `tool` (slice 060) is the registry name the call was made under, set beside `call_id`, so a module serving many dynamic tools (the MCP - bridge) knows which one it is. + bridge) knows which one it is. `turn_id` (slice 135) is the Session's id for the turn the call + belongs to, carried onto every fs decision receipt; `nil` for a call made outside a turn (an MCP + client, a test). """ @type t :: %__MODULE__{ @@ -20,7 +22,8 @@ defmodule Trinity.Tools.Context do tool: String.t() | nil, origin: String.t() | nil, trace: map() | nil, - principal: map() | nil + principal: map() | nil, + turn_id: String.t() | nil } defstruct session_id: nil, @@ -31,5 +34,6 @@ defmodule Trinity.Tools.Context do tool: nil, origin: nil, trace: nil, - principal: nil + principal: nil, + turn_id: nil end diff --git a/lib/trinity/tools/fs.ex b/lib/trinity/tools/fs.ex index 66d7cde..6f97ab4 100644 --- a/lib/trinity/tools/fs.ex +++ b/lib/trinity/tools/fs.ex @@ -4,11 +4,12 @@ defmodule Trinity.Tools.FS do @moduledoc """ What the filesystem tools share (docs/07, filesystem). Slice 022. - **Roots.** A path is inside the allowlist when, after normalisation and symlink resolution, - it is under one of `config :trinity, :fs, roots:` (the data directory is always one) or - under the session's working directory. A read outside the roots is not a failure but an - `:ask` (022 AC1): the tool escalates and the gate decides. A write outside the roots asks - the same way. + **Roots.** A path is inside the allowlist when it is under one of `config :trinity, :fs, + roots:` or the folders chosen in Settings, or under the session's working directory. A read + outside the roots is not a failure but an `:ask` (022 AC1): the tool escalates and the gate + decides. A write outside the roots asks the same way. Since slice 135 the data directory is not a + root, and `Trinity.Tools.FS.Guard` judges every path first: a symlink, `/proc`, `/dev`, the data + or secrets directory, a protected inode, a hard link and the deny-list are refused outright. **Backups.** Before a write or an edit, the file's current bytes go to `/backups//` and the ring keeps the last five; @@ -17,6 +18,8 @@ defmodule Trinity.Tools.FS do **Atomic writes.** A temporary file beside the target, then `File.rename/2`. """ + alias Trinity.Tools.FS.Guard + @backup_ring 5 # Sobelow reads `@sobelow_skip` from the source; the compiler would call it unused (the @@ -24,78 +27,53 @@ defmodule Trinity.Tools.FS do Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) @doc """ - The configured roots plus the data directory, each expanded. Slice 100 adds the folders the - owner chose in Settings or the setup path (`Trinity.Settings`, `:fs_roots`). + The roots in force, canonical: `config :trinity, :fs, roots:` and the folders the owner chose in + Settings (slice 100, `:fs_roots`). Since slice 135 the data directory is not one, and a root that + is, contains or sits inside the data or secrets directory is not one either + (`Trinity.Tools.FS.Guard`). """ @spec roots() :: [String.t()] - def roots do - configured = Application.get_env(:trinity, :fs, []) |> Keyword.get(:roots, []) - chosen = Trinity.Settings.get(:fs_roots) - Enum.map([Trinity.Paths.data_dir() | configured ++ chosen], &Path.expand/1) |> Enum.uniq() - end + def roots, do: Guard.roots() @doc """ - The absolute, symlink-resolved path for `path` (relative to `cwd`), and whether it lies - inside the roots or `cwd`. A path that does not exist yet is resolved through its nearest - existing ancestor, so a new file's directory decides. + The canonical path for `path` (relative to `cwd`) used as `mode`, and whether it lies inside the + roots or `cwd`; or the guard's refusal (`Trinity.Tools.FS.Guard.check/4`). A path that does not + exist yet is judged through its nearest existing ancestor, so a new file's directory decides. """ - @spec resolve(String.t(), String.t() | nil) :: {:ok, String.t(), :inside | :outside} - def resolve(path, cwd) do - base = cwd || File.cwd!() - absolute = Path.expand(path, base) - real = realpath(absolute) - allowed = if cwd, do: [Path.expand(cwd) | roots()], else: roots() - inside? = Enum.any?(allowed, &under?(real, &1)) - {:ok, real, if(inside?, do: :inside, else: :outside)} - end - - @doc "True when `path` is `root` or under it." - @spec under?(String.t(), String.t()) :: boolean() - def under?(path, root), do: path == root or String.starts_with?(path, root <> "/") - - # Follows symlinks on the longest existing prefix, then appends the rest. - defp realpath(absolute) do - {existing, rest} = split_existing(absolute, []) - - resolved = - case existing do - nil -> "/" - dir -> resolve_links(dir) - end - - Path.join([resolved | rest]) - end - - defp split_existing("/", rest), do: {"/", rest} - - defp split_existing(path, rest) do - if File.exists?(path) do - {path, rest} - else - split_existing(Path.dirname(path), [Path.basename(path) | rest]) + @spec resolve(String.t(), String.t() | nil, Guard.mode()) :: + {:ok, String.t(), :inside | :outside} | {:error, {:fs_denied, Guard.verdict()}} + def resolve(path, cwd, mode \\ :read) do + case Guard.check(path, cwd, mode) do + %{decision: :deny} = v -> {:error, {:fs_denied, v}} + %{decision: :allow, canonical: c} -> {:ok, c, :inside} + %{decision: :ask, canonical: c} -> {:ok, c, :outside} end end - defp resolve_links(path) do - case :file.read_link_all(String.to_charlist(path)) do - {:ok, target} -> - target |> List.to_string() |> Path.expand(Path.dirname(path)) |> resolve_links() - - _ -> - parent = Path.dirname(path) - - if parent == path, - do: path, - else: Path.join(resolve_links(parent), Path.basename(path)) + @doc """ + The escalation a path-taking tool asks for (slice 022): none inside the roots, `:ask` outside + them. A refused path asks too, which never matters: the runner denies a refused path before the + policy is consulted (`Trinity.Tools.Runner.decide/3`), and so does the tool at execution. + """ + @spec escalation(String.t(), String.t() | nil, Guard.mode()) :: :ask | nil + def escalation(path, cwd, mode \\ :read) do + case resolve(path, cwd, mode) do + {:ok, _, :inside} -> nil + _ -> :ask end end + @doc "True when `path` is `root` or under it." + @spec under?(String.t(), String.t()) :: boolean() + defdelegate under?(path, root), to: Guard + # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] @doc "Writes `content` to `path` atomically: a temporary file beside it, then a rename." @spec atomic_write(String.t(), binary()) :: :ok | {:error, File.posix()} @@ -126,10 +104,11 @@ defmodule Trinity.Tools.FS do # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] @doc "Copies the file's current bytes into its ring, keeping the last five; nothing for a new file." @spec backup(String.t()) :: {:ok, String.t() | nil} | {:error, term()} @@ -169,10 +148,11 @@ defmodule Trinity.Tools.FS do {:ok, String.t()} | {:error, :no_backup | term()} # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] def restore(path, which \\ 0) do case Enum.at(backups(path), which) do @@ -190,10 +170,11 @@ defmodule Trinity.Tools.FS do # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] defp prune(dir) do dir diff --git a/lib/trinity/tools/fs/edit.ex b/lib/trinity/tools/fs/edit.ex index 47d1ea9..cb9fad1 100644 --- a/lib/trinity/tools/fs/edit.ex +++ b/lib/trinity/tools/fs/edit.ex @@ -8,8 +8,8 @@ defmodule Trinity.Tools.FS.Edit do """ @behaviour Trinity.Tools.Tool - alias Trinity.Tools.{Context, FS, Result} - alias Trinity.Tools.FS.Placeholders + alias Trinity.Tools.{Context, FS, Result, Taint} + alias Trinity.Tools.FS.{Guard, Placeholders} # Sobelow reads `@sobelow_skip` from the source; the compiler would call it unused (the # measure `Trinity.Paths` takes). @@ -41,26 +41,38 @@ defmodule Trinity.Tools.FS.Edit do def effect, do: :artifact @impl true - def escalate(%{"path" => path}, %Context{cwd: cwd}) do - case FS.resolve(path, cwd) do - {:ok, _, :outside} -> :ask - _ -> nil - end - end + def escalate(%{"path" => path}, %Context{cwd: cwd}), do: FS.escalation(path, cwd, :write) + + @impl true + def fs_paths(%{"path" => path}, _ctx), do: [{path, :write}] # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] @impl true - def execute(%{"path" => path, "search" => search, "replace" => replace}, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(path, cwd) + def execute( + %{"path" => path, "search" => search, "replace" => replace}, + %Context{cwd: cwd} = ctx + ) do + # Slice 135: the current bytes come through the guard's open, which fstats the descriptor; the + # diff carries them back, so an edit outside the roots (approved) taints the session as a read. + case Guard.read(path, cwd) do + {:ok, before, %{canonical: real} = verdict} -> + Taint.note_read(verdict, ctx.session_id) + edit(real, before, search, replace) + + {:error, _} = refused -> + refused + end + end - with {:ok, before} <- File.read(real), - :ok <- placeholders(replace), + defp edit(real, before, search, replace) do + with :ok <- placeholders(replace), {:ok, after_text} <- replace_once(before, search, replace), {:ok, backup} <- FS.backup(real), :ok <- FS.atomic_write(real, after_text) do diff --git a/lib/trinity/tools/fs/glob.ex b/lib/trinity/tools/fs/glob.ex index 19d9198..b8a63ae 100644 --- a/lib/trinity/tools/fs/glob.ex +++ b/lib/trinity/tools/fs/glob.ex @@ -5,6 +5,7 @@ defmodule Trinity.Tools.FS.Glob do @behaviour Trinity.Tools.Tool alias Trinity.Tools.{Context, FS, Untrusted} + alias Trinity.Tools.FS.Guard @max_matches 1_000 @@ -36,30 +37,39 @@ defmodule Trinity.Tools.FS.Glob do def effect, do: :none @impl true - def escalate(args, %Context{cwd: cwd}) do - case FS.resolve(Map.get(args, "path", "."), cwd) do - {:ok, _, :inside} -> nil - {:ok, _, :outside} -> :ask - end - end + def escalate(args, %Context{cwd: cwd}), do: FS.escalation(Map.get(args, "path", "."), cwd, :dir) + + @impl true + def fs_paths(args, _ctx), do: [{Map.get(args, "path", "."), :dir}] @impl true def execute(%{"pattern" => pattern} = args, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(Map.get(args, "path", "."), cwd) + with {:ok, real, _} <- FS.resolve(Map.get(args, "path", "."), cwd, :dir) do + glob(real, pattern, cwd) + end + end - matches = + # Slice 135: the wildcard follows links into directories, so every match is judged by the guard + # and a refused one (reached through a link, a hard link, a protected name) is left out and + # counted, never named. + defp glob(real, pattern, cwd) do + scope = Guard.scope(cwd) + + {kept, refused} = real |> Path.join(pattern) |> Path.wildcard(match_dot: true) - |> Enum.map(&Path.relative_to(&1, real)) - |> Enum.sort() + |> Enum.split_with(&(Guard.check(&1, cwd, :read, scope).decision != :deny)) + + matches = kept |> Enum.map(&Path.relative_to(&1, real)) |> Enum.sort() lines = matches |> Enum.take(@max_matches) |> Enum.join("\n") meta = %{ "path" => real, "matches" => length(matches), - "shown" => min(length(matches), @max_matches) + "shown" => min(length(matches), @max_matches), + "refused" => length(refused) } {:ok, Untrusted.result(lines, tool: "fs_glob", source_ref: real, meta: meta)} diff --git a/lib/trinity/tools/fs/grep.ex b/lib/trinity/tools/fs/grep.ex index 0010f93..a868846 100644 --- a/lib/trinity/tools/fs/grep.ex +++ b/lib/trinity/tools/fs/grep.ex @@ -4,7 +4,8 @@ defmodule Trinity.Tools.FS.Grep do @moduledoc "`fs_grep`: lines matching a regular expression under a directory, with caps. Outside the roots it asks. Slice 022." @behaviour Trinity.Tools.Tool - alias Trinity.Tools.{Context, FS, Untrusted} + alias Trinity.Tools.{Context, FS, Taint, Untrusted} + alias Trinity.Tools.FS.Guard @max_files 5_000 @max_matches 500 @@ -40,17 +41,23 @@ defmodule Trinity.Tools.FS.Grep do def effect, do: :none @impl true - def escalate(args, %Context{cwd: cwd}) do - case FS.resolve(Map.get(args, "path", "."), cwd) do - {:ok, _, :inside} -> nil - {:ok, _, :outside} -> :ask - end - end + def escalate(args, %Context{cwd: cwd}), do: FS.escalation(Map.get(args, "path", "."), cwd, :dir) + + @impl true + def fs_paths(args, _ctx), do: [{Map.get(args, "path", "."), :dir}] @impl true - def execute(%{"pattern" => pattern} = args, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(Map.get(args, "path", "."), cwd) + def execute(%{"pattern" => pattern} = args, %Context{cwd: cwd} = ctx) do + with {:ok, real, where} <- FS.resolve(Map.get(args, "path", "."), cwd, :dir) do + # Slice 135: a search outside the roots (approved) reads from a path tagged sensitive. + if where == :outside, + do: Taint.note_read(%{decision: :ask, canonical: real}, ctx.session_id) + grep(real, pattern, args, cwd) + end + end + + defp grep(real, pattern, args, cwd) do case Regex.compile(pattern) do {:ok, regex} -> files = @@ -60,14 +67,18 @@ defmodule Trinity.Tools.FS.Grep do |> Enum.filter(&File.regular?/1) |> Enum.take(@max_files) - {lines, count} = collect(files, regex, real) + # Slice 135: every file is opened by the guard (one scope for the call), so a link, a hard + # link or a protected inode met on the way is refused per file and named in the receipt. + scope = Guard.scope(cwd) + {lines, count, refused} = collect(files, regex, real, cwd, scope) text = Enum.join(lines, "\n") meta = %{ "path" => real, "files" => length(files), "matches" => count, - "capped" => count >= @max_matches + "capped" => count >= @max_matches, + "fs_refused" => refused } {:ok, Untrusted.result(text, tool: "fs_grep", source_ref: real, meta: meta)} @@ -77,30 +88,38 @@ defmodule Trinity.Tools.FS.Grep do end end - # Matches across the files, stopping at the cap. - defp collect(files, regex, root) do - Enum.reduce_while(files, {[], 0}, fn file, {acc, n} -> - hits = grep_file(file, regex, root) - room = @max_matches - n - - cond do - hits == [] -> {:cont, {acc, n}} - length(hits) >= room -> {:halt, {acc ++ Enum.take(hits, room), @max_matches}} - true -> {:cont, {acc ++ hits, n + length(hits)}} - end - end) + # Matches across the files, stopping at the cap; the guard's refusals beside them. + defp collect(files, regex, root, cwd, scope) do + {lines, n, refused} = + Enum.reduce_while(files, {[], 0, []}, fn file, acc -> + file |> grep_file(regex, root, cwd, scope) |> add(acc) + end) + + {lines, n, Enum.reverse(refused)} + end + + defp add({:refused, verdict}, {acc, n, refused}), do: {:cont, {acc, n, [verdict | refused]}} + defp add([], acc), do: {:cont, acc} + + defp add(hits, {acc, n, refused}) do + room = @max_matches - n + + if length(hits) >= room, + do: {:halt, {acc ++ Enum.take(hits, room), @max_matches, refused}}, + else: {:cont, {acc ++ hits, n + length(hits), refused}} end # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] - defp grep_file(file, regex, root) do - with {:ok, %{size: size}} when size <= @max_file_bytes <- File.stat(file), - {:ok, bytes} <- File.read(file), + defp grep_file(file, regex, root, cwd, scope) do + with {:ok, %{size: size}} when size <= @max_file_bytes <- File.lstat(file), + {:ok, bytes} <- guarded_read(file, cwd, scope), true <- String.valid?(bytes) do bytes |> String.split("\n") @@ -110,7 +129,16 @@ defmodule Trinity.Tools.FS.Grep do "#{Path.relative_to(file, root)}:#{n}: #{String.slice(l, 0, 300)}" end) else + {:refused, _} = refused -> refused _ -> [] end end + + defp guarded_read(file, cwd, scope) do + case Guard.read(file, cwd, scope: scope) do + {:ok, bytes, _verdict} -> {:ok, bytes} + {:error, {:fs_denied, verdict}} -> {:refused, verdict} + {:error, _} = error -> error + end + end end diff --git a/lib/trinity/tools/fs/guard.ex b/lib/trinity/tools/fs/guard.ex new file mode 100644 index 0000000..442ccb4 --- /dev/null +++ b/lib/trinity/tools/fs/guard.ex @@ -0,0 +1,586 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.FS.Guard do + @moduledoc """ + The one judge of a path a tool was given (slice 135, docs/07 "Filesystem"). A tool the agent + calls cannot read or write a secret or a database file of Trinity's own, by any path: direct, + `..`, a symlink, a hard link, `/proc/self/fd`, or a bind mount. The process still *uses* its keys + through their APIs; it can no longer *read* them through a tool. + + ## What is protected + + The **data directory** and the **secrets directory** (`Trinity.Paths`), by path; and, by inode, an + inventory of `(st_dev, st_ino)` taken at every call: every entry under the secrets directory and + the configured keys directory, the database files with their journals, the MCP token file and + OAuth store when configured elsewhere, and the data and secrets directories themselves. The + inventory is what catches a hard link and a bind mount, which no path comparison can see. + + ## The checks, in order (the deny-list is the second line, never the first) + + 1. The path is expanded lexically against the canonical working directory (`..` included). A + configured root's own spelling is rewritten to its canonical form, so a root reached through a + symlinked prefix still works. + 2. `/proc` and `/dev` are refused. + 3. Every existing component is `lstat`ed: a symlink anywhere is refused (no link is followed, so + the lexical path is the physical one), and a component whose `(dev, ino)` is in the inventory + is refused (a bind mount of the data or secrets directory). + 4. A path under the data directory or the secrets directory is refused. + 5. The final component: anything not a regular file or a directory is refused, and a regular file + with `nlink > 1` is refused unless `config :trinity, :fs, allow_hard_links: true`. + 6. The deny-list, on the canonical basename: `#{Enum.join(~w(*.key *.db *.db-wal *.db-shm *.sqlite* .env* id_* *.pem), ", ")}`. + 7. Inside a root (configured, or the session's working directory) the verdict is `:allow`; + outside, `:ask` (slice 022's rule: the tool escalates, the gate decides). + + **At open** (`open/3`), the file is opened and its descriptor `fstat`ed (`:file.read_file_info/1` + on the open device, which is `fstat`: measured in slice 135 NOTES): an inventoried `(dev, ino)`, + `nlink > 1`, a non-regular file, or an inode other than the one the walk saw is refused. A swap + between the walk and the open therefore cannot hand a tool a protected inode. + + No NIF: `file:open/2` has no `O_NOFOLLOW` or `openat2`, and the post-open check closes the window + for every protected inode (slice 135 NOTES, "Decision: no NIF"). + + ## Roots fail closed + + `check_roots/2` refuses a root equal to, an ancestor of, or a descendant of the data directory or + the secrets directory; `Trinity.Application` asks it before any child starts, in every profile, + and the Settings page asks it before saving a folder. At call time a root that overlaps either is + not a root. + """ + + # Sobelow reads `@sobelow_skip` from the source; the compiler would call it unused (the + # measure `Trinity.Paths` takes). + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @deny_list ~w(*.key *.db *.db-wal *.db-shm *.sqlite* .env* id_* *.pem) + @refused_trees ["/proc", "/dev"] + @inventory_cap 20_000 + + @typedoc "How a tool means to use a path." + @type mode :: :read | :write | :dir + + @typedoc "The guard's answer for one path." + @type verdict :: %{ + requested: String.t(), + canonical: String.t() | nil, + dev: non_neg_integer() | nil, + ino: non_neg_integer() | nil, + nlink: non_neg_integer() | nil, + type: atom() | nil, + root: String.t() | nil, + rule: String.t() | nil, + decision: :allow | :ask | :deny + } + + @typedoc "What one call is judged against, taken fresh at the call." + @type scope :: %{ + data_dir: String.t(), + secrets_dir: String.t(), + protected: [String.t()], + inventory: %{{non_neg_integer(), non_neg_integer()} => String.t()}, + roots: [{String.t(), String.t()}], + cwd: String.t() | nil, + allow_hard_links: boolean() + } + + @doc "The deny-list, as glob patterns over the canonical basename." + @spec deny_list() :: [String.t()] + def deny_list, do: @deny_list + + ## Boot + + @doc """ + Refuses a root that is equal to, an ancestor of, or a descendant of a protected directory. + `roots` and `protected` (`[{name, dir}]`) are compared in canonical form. + """ + @spec check_roots([String.t()], [{atom(), String.t()}]) :: + :ok + | {:error, {:fs_root_overlaps, String.t(), atom(), :equal | :ancestor | :descendant}} + def check_roots(roots, protected) do + Enum.reduce_while(roots, :ok, fn root, :ok -> + canon = canonical(root) + + case Enum.find_value(protected, &overlap(canon, &1)) do + nil -> {:cont, :ok} + {which, relation} -> {:halt, {:error, {:fs_root_overlaps, root, which, relation}}} + end + end) + end + + defp overlap(root, {which, dir}) do + dir = canonical(dir) + + cond do + root == dir -> {which, :equal} + under?(dir, root) -> {which, :ancestor} + under?(root, dir) -> {which, :descendant} + true -> nil + end + end + + @doc "The protected directories in force, named: the data directory and the secrets directory." + @spec protected_dirs() :: [{atom(), String.t()}] + def protected_dirs, + do: [data_dir: Trinity.Paths.data_dir(), secrets_dir: Trinity.Paths.secrets_dir()] + + @doc "The roots configured (`config :trinity, :fs, roots`) and chosen in Settings, expanded." + @spec configured_roots() :: [String.t()] + def configured_roots do + configured = Application.get_env(:trinity, :fs, []) |> Keyword.get(:roots, []) + chosen = Trinity.Settings.get(:fs_roots) + (configured ++ chosen) |> Enum.map(&Path.expand/1) |> Enum.uniq() + end + + @doc """ + The boot check (AC2): every configured root against the data and secrets directories. Raises with + the named reason, which stops the application before any child exists. + """ + @spec verify_boot!() :: :ok + def verify_boot! do + case check_roots(configured_roots(), protected_dirs()) do + :ok -> + :ok + + {:error, reason} -> + raise "Trinity refuses to boot: #{inspect(reason)}. A filesystem root must not be, contain " <> + "or sit inside the data directory or the secrets directory; remove it from " <> + "TRINITY_FS_ROOTS (config :trinity, :fs, roots) or from the folders in Settings." + end + end + + ## Scope + + @doc "What one call is judged against: the protected set, the inventory and the roots, taken now." + @spec scope(String.t() | nil) :: scope() + def scope(cwd) do + data = canonical(Trinity.Paths.data_dir()) + secrets = canonical(Trinity.Paths.secrets_dir()) + protected_dirs = [data_dir: data, secrets_dir: secrets] + + roots = + for root <- configured_roots(), + canon = canonical(root), + check_roots([canon], protected_dirs) == :ok, + do: {root, canon} + + %{ + data_dir: data, + secrets_dir: secrets, + protected: Enum.map(protected_files(), &canonical/1), + inventory: inventory(data, secrets), + roots: roots, + cwd: cwd && canonical(Path.expand(cwd)), + allow_hard_links: + Application.get_env(:trinity, :fs, []) |> Keyword.get(:allow_hard_links, false) + } + end + + @doc "The canonical roots in force: configured and chosen, less any that overlaps a protected directory." + @spec roots() :: [String.t()] + def roots, do: nil |> scope() |> Map.fetch!(:roots) |> Enum.map(&elem(&1, 1)) |> Enum.uniq() + + # Files Trinity keeps that may sit outside both directories when configured there: the databases + # and their journals, the keys directory, the MCP token and OAuth store. + defp protected_files do + dbs = + for repo <- [Trinity.Repo, Trinity.Repo.Receipts], + db = Application.get_env(:trinity, repo, [])[:database], + is_binary(db), + suffix <- ["", "-wal", "-shm", "-journal"], + do: db <> suffix + + keys = List.wrap(Application.get_env(:trinity, :receipts, [])[:keys_dir]) + auth = Application.get_env(:trinity, :mcp_auth, []) + dbs ++ keys ++ Enum.reject([auth[:token_path], auth[:store_dir]], &is_nil/1) + end + + # Every (dev, ino) the guard refuses whatever path reaches it, with the rule a refusal names: the + # two directories by their own names (so a bind mount of either is named for what it is), every + # other inventoried inode as `protected_inode`. + defp inventory(data, secrets) do + trees = [secrets | protected_files()] ++ [Path.join(data, "keys")] + + entries = + trees + |> Enum.flat_map(&walk(&1, @inventory_cap)) + |> Map.new(&{&1, "protected_inode"}) + + [{data, "data_dir"}, {secrets, "secrets_dir"}] + |> Enum.reduce(entries, fn {dir, label}, acc -> + case inode(dir) do + nil -> acc + key -> Map.put(acc, key, label) + end + end) + end + + defp walk(path, budget) do + case lstat(path) do + {:ok, %{type: :directory} = info} -> + children = + case File.ls(path) do + {:ok, names} -> names |> Enum.take(budget) |> Enum.map(&Path.join(path, &1)) + _ -> [] + end + + [key(info) | Enum.flat_map(children, &walk(&1, budget))] + + {:ok, info} -> + [key(info)] + + _ -> + [] + end + end + + defp inode(path) do + case lstat(path) do + {:ok, info} -> key(info) + _ -> nil + end + end + + defp key(%File.Stat{major_device: dev, inode: ino}), do: {dev, ino} + + ## Call time + + @doc """ + The verdict for `path` (relative to `cwd`) used as `mode`. Never raises; never opens the file. + """ + @spec check(String.t(), String.t() | nil, mode(), scope() | nil) :: verdict() + def check(path, cwd, mode, scope \\ nil) do + scope = scope || scope(cwd) + base = scope.cwd || canonical(File.cwd!()) + absolute = path |> Path.expand(base) |> unalias(scope.roots) + verdict = %{blank(path) | canonical: absolute} + + with :ok <- refused_tree(absolute), + {:ok, final} <- components(absolute, scope), + :ok <- zone(absolute, scope), + :ok <- final_kind(final, mode, scope), + :ok <- denied_name(absolute) do + root = matching_root(absolute, scope) + decision = if root, do: :allow, else: :ask + %{with_info(verdict, final) | root: root, decision: decision} + else + {:deny, rule, final} -> %{with_info(verdict, final) | rule: rule, decision: :deny} + {:deny, rule} -> %{verdict | rule: rule, decision: :deny} + end + end + + defp blank(path), + do: %{ + requested: path, + canonical: nil, + dev: nil, + ino: nil, + nlink: nil, + type: nil, + root: nil, + rule: nil, + decision: :deny + } + + defp with_info(verdict, nil), do: verdict + + defp with_info(verdict, %File.Stat{} = info), + do: %{ + verdict + | dev: info.major_device, + ino: info.inode, + nlink: info.links, + type: info.type + } + + # A configured root reached through its own (symlinked) spelling is read as its canonical form: + # roots are the operator's configuration, trusted as written. + defp unalias(absolute, roots) do + Enum.find_value(roots, absolute, fn {spelled, canon} -> + cond do + spelled == canon -> nil + absolute == spelled -> canon + under?(absolute, spelled) -> canon <> String.replace_prefix(absolute, spelled, "") + true -> nil + end + end) + end + + defp refused_tree(absolute) do + if Enum.any?(@refused_trees, &under?(absolute, &1)), + do: {:deny, "proc_or_dev"}, + else: :ok + end + + # lstat on every existing component, from the top. A symlink anywhere is refused; an inventoried + # inode anywhere is refused. Returns the final component's stat, or nil when it does not exist. + defp components(absolute, scope) do + [top | parts] = Path.split(absolute) + walk_components(parts, top, nil, scope) + end + + defp walk_components([], _dir, info, _scope), do: {:ok, info} + + defp walk_components([part | rest], dir, _info, scope) do + path = Path.join(dir, part) + + case lstat(path) do + {:ok, info} -> component(info, path, rest, scope) + {:error, reason} when reason in [:enoent, :enotdir] -> {:ok, nil} + {:error, _} -> {:deny, "unverifiable"} + end + end + + defp component(%File.Stat{type: :symlink} = info, _path, _rest, _scope), + do: {:deny, "symlink", info} + + defp component(info, path, rest, scope) do + case Map.fetch(scope.inventory, key(info)) do + {:ok, rule} -> {:deny, rule, info} + :error -> walk_components(rest, path, info, scope) + end + end + + defp zone(absolute, scope) do + cond do + under?(absolute, scope.data_dir) -> {:deny, "data_dir"} + under?(absolute, scope.secrets_dir) -> {:deny, "secrets_dir"} + Enum.any?(scope.protected, &under?(absolute, &1)) -> {:deny, "protected_path"} + true -> :ok + end + end + + defp final_kind(nil, _mode, _scope), do: :ok + + defp final_kind(%File.Stat{type: :regular, links: n} = info, _mode, scope) when n > 1 do + if scope.allow_hard_links, do: :ok, else: {:deny, "hard_link", info} + end + + defp final_kind(%File.Stat{type: type}, _mode, _scope) when type in [:regular, :directory], + do: :ok + + defp final_kind(%File.Stat{} = info, _mode, _scope), do: {:deny, "special_file", info} + + defp denied_name(absolute) do + base = Path.basename(absolute) + + case Enum.find(@deny_list, &glob?(&1, base)) do + nil -> :ok + pattern -> {:deny, "deny_list:" <> pattern} + end + end + + defp glob?(pattern, name) do + regex = + pattern + |> Regex.escape() + |> String.replace("\\*", ".*") + + Regex.match?(~r/\A#{regex}\z/, name) + end + + defp matching_root(absolute, scope) do + candidates = Enum.map(scope.roots, &elem(&1, 1)) ++ List.wrap(scope.cwd) + + candidates + |> Enum.filter(&under?(absolute, &1)) + |> Enum.max_by(&String.length/1, fn -> nil end) + end + + ## Open time + + @doc """ + Opens `path` for reading after `check/4`, `fstat`s the descriptor and refuses what the walk could + not see; returns the bytes and the verdict. `opts`: `zones: false` for a caller with its own jail + (`skill_file`), which keeps the inode, link-count, kind and deny-list checks and skips the + directory and symlink ones (the jail resolved the path itself). + """ + @spec read(String.t(), String.t() | nil, keyword()) :: + {:ok, binary(), verdict()} | {:error, {:fs_denied, verdict()}} | {:error, term()} + # sobelow_skip reason: Traversal.FileModule: this is the guard itself. The path has been through + # check/4 (lexical expansion, no symlinks, no protected directory, the deny-list) before the open, + # and the opened descriptor is checked against the inode inventory after it. + @sobelow_skip ["Traversal.FileModule"] + def read(path, cwd, opts \\ []) do + scope = Keyword.get_lazy(opts, :scope, fn -> scope(cwd) end) + + verdict = + if Keyword.get(opts, :zones, true), + do: check(path, cwd, :read, scope), + else: jailed(path, scope) + + with :ok <- allowed(verdict, opts), + {:ok, io} <- :file.open(verdict.canonical, [:read, :binary, :raw]) do + try do + with {:ok, info} <- :file.read_file_info(io, [{:time, :posix}]), + :ok <- after_open(File.Stat.from_record(info), verdict, scope) do + {:ok, read_all(io, []), verdict} + end + after + :file.close(io) + end + end + |> case do + {:ok, _, _} = ok -> ok + {:error, {:fs_denied, _}} = denied -> denied + {:deny, rule} -> {:error, {:fs_denied, %{verdict | rule: rule, decision: :deny}}} + {:error, reason} -> {:error, {:file, reason, verdict.canonical}} + end + end + + # Inside a jail the caller resolved (skill_file): the kind, link count and deny-list checks, on + # the path as given. + defp jailed(path, scope) do + base = %{blank(path) | canonical: path} + + case lstat(path) do + {:ok, info} -> + v = with_info(base, info) + + with :ok <- not_inventoried(info, scope), + :ok <- final_kind(info, :read, scope) |> strip(), + :ok <- denied_name(path) do + %{v | decision: :allow} + else + {:deny, rule} -> %{v | rule: rule, decision: :deny} + end + + {:error, _} -> + %{base | decision: :allow} + end + end + + defp not_inventoried(info, scope) do + case Map.fetch(scope.inventory, key(info)) do + {:ok, rule} -> {:deny, rule} + :error -> :ok + end + end + + defp strip({:deny, rule, _info}), do: {:deny, rule} + defp strip(other), do: other + + # A read the gate asked about and the owner allowed is an `:ask` verdict that is no longer a + # question; only a denial stops it here. + defp allowed(%{decision: :deny} = v, _opts), do: {:error, {:fs_denied, v}} + defp allowed(_v, _opts), do: :ok + + defp after_open(%File.Stat{} = info, verdict, scope) do + cond do + Map.has_key?(scope.inventory, key(info)) -> + {:deny, Map.fetch!(scope.inventory, key(info))} + + info.type != :regular -> + {:deny, "special_file"} + + info.links > 1 and not scope.allow_hard_links -> + {:deny, "hard_link"} + + verdict.ino != nil and {verdict.dev, verdict.ino} != key(info) -> + {:deny, "changed_during_open"} + + true -> + :ok + end + end + + defp read_all(io, acc) do + case :file.read(io, 1_048_576) do + {:ok, chunk} -> read_all(io, [acc | chunk]) + :eof -> IO.iodata_to_binary(acc) + {:error, _} -> IO.iodata_to_binary(acc) + end + end + + ## Receipts + + @doc """ + The fields a receipt carries for one fs decision (AC6): tool, requested path, canonical path, + `(dev, ino)`, the root matched or none, the deny rule matched, nlink, the decision, the core policy + hash, the principal and the turn id. Never the file's contents. + """ + @spec receipt_fields(verdict(), String.t(), map()) :: map() + def receipt_fields(verdict, tool, ctx) do + %{ + "tool" => tool, + "requested_path" => verdict.requested, + "canonical_path" => verdict.canonical, + "dev" => verdict.dev, + "ino" => verdict.ino, + "root" => verdict.root, + "deny_rule" => verdict.rule, + "nlink" => verdict.nlink, + "decision" => Atom.to_string(verdict.decision), + "policy_hash" => policy_hash(), + "principal" => principal(ctx), + "turn_id" => Map.get(ctx, :turn_id) + } + end + + defp principal(%{principal: %{} = p}) when map_size(p) > 0, do: p + defp principal(%{caller: caller}) when is_binary(caller), do: %{"caller" => caller} + defp principal(%{caller: nil}), do: nil + defp principal(%{caller: caller}), do: %{"caller" => inspect(caller)} + defp principal(_), do: nil + + # The core policy hash is a digest over object code that does not change while the VM runs. + defp policy_hash do + case :persistent_term.get({__MODULE__, :policy_hash}, nil) do + nil -> + hash = Trinity.CorePolicy.hash() + :persistent_term.put({__MODULE__, :policy_hash}, hash) + hash + + hash -> + hash + end + end + + ## Paths + + @doc "True when `path` is `root` or under it." + @spec under?(String.t(), String.t()) :: boolean() + def under?(path, root) do + # A filesystem root ("/", "C:/") already ends in its separator. + if String.ends_with?(root, "/"), + do: String.starts_with?(path, root), + else: path == root or String.starts_with?(path, root <> "/") + end + + @doc """ + The canonical form of a trusted path (a root, a protected directory, the working directory): + expanded, with every symlink on its longest existing prefix resolved. Never used on a tool's + argument, which is refused at its first symlink instead. + """ + @spec canonical(String.t()) :: String.t() + def canonical(path) do + absolute = Path.expand(path) + {existing, rest} = split_existing(absolute, []) + Path.join([resolve_links(existing, 40) | rest]) + end + + defp split_existing("/", rest), do: {"/", rest} + + defp split_existing(path, rest) do + case lstat(path) do + {:ok, _} -> {path, rest} + _ -> split_existing(Path.dirname(path), [Path.basename(path) | rest]) + end + end + + defp resolve_links(path, 0), do: path + + defp resolve_links(path, hops) do + case :file.read_link_all(String.to_charlist(path)) do + {:ok, target} -> + target |> List.to_string() |> Path.expand(Path.dirname(path)) |> resolve_links(hops - 1) + + _ -> + parent = Path.dirname(path) + + if parent == path, + do: path, + else: Path.join(resolve_links(parent, hops), Path.basename(path)) + end + end + + defp lstat(path), do: File.lstat(path, time: :posix) +end diff --git a/lib/trinity/tools/fs/list.ex b/lib/trinity/tools/fs/list.ex index a1b14ba..2b12cf0 100644 --- a/lib/trinity/tools/fs/list.ex +++ b/lib/trinity/tools/fs/list.ex @@ -13,7 +13,7 @@ defmodule Trinity.Tools.FS.List do @impl true def description, do: - "Lists a directory: one entry per line as `kind size name` (kind d or f). Hidden entries included; at most 2,000." + "Lists a directory: one entry per line as `kind size name` (kind d, f or l for a link). Hidden entries included; at most 2,000." @impl true def schema, @@ -31,17 +31,19 @@ defmodule Trinity.Tools.FS.List do def effect, do: :none @impl true - def escalate(args, %Context{cwd: cwd}) do - case FS.resolve(Map.get(args, "path", "."), cwd) do - {:ok, _, :inside} -> nil - {:ok, _, :outside} -> :ask - end - end + def escalate(args, %Context{cwd: cwd}), do: FS.escalation(Map.get(args, "path", "."), cwd, :dir) + + @impl true + def fs_paths(args, _ctx), do: [{Map.get(args, "path", "."), :dir}] @impl true def execute(args, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(Map.get(args, "path", "."), cwd) + with {:ok, real, _} <- FS.resolve(Map.get(args, "path", "."), cwd, :dir) do + list(real) + end + end + defp list(real) do case File.ls(real) do {:ok, names} -> lines = @@ -55,9 +57,11 @@ defmodule Trinity.Tools.FS.List do end end + # Slice 135: `lstat`, so a link is listed as a link and its target is never stat'ed. defp entry(dir, name) do - case File.stat(Path.join(dir, name)) do + case File.lstat(Path.join(dir, name)) do {:ok, %{type: :directory}} -> "d\t-\t#{name}/" + {:ok, %{type: :symlink}} -> "l\t-\t#{name}" {:ok, %{size: size}} -> "f\t#{size}\t#{name}" _ -> "?\t-\t#{name}" end diff --git a/lib/trinity/tools/fs/read.ex b/lib/trinity/tools/fs/read.ex index 3e83d23..e7c657c 100644 --- a/lib/trinity/tools/fs/read.ex +++ b/lib/trinity/tools/fs/read.ex @@ -4,7 +4,8 @@ defmodule Trinity.Tools.FS.Read do @moduledoc "`fs_read`: a file's text, by line range, capped. Outside the roots it asks. Slice 022." @behaviour Trinity.Tools.Tool - alias Trinity.Tools.{Context, FS, Untrusted} + alias Trinity.Tools.{Context, FS, Taint, Untrusted} + alias Trinity.Tools.FS.Guard @max_bytes 256 * 1024 @@ -41,28 +42,29 @@ defmodule Trinity.Tools.FS.Read do def effect, do: :none @impl true - def escalate(%{"path" => path}, %Context{cwd: cwd}) do - case FS.resolve(path, cwd) do - {:ok, _, :inside} -> nil - {:ok, _, :outside} -> :ask - end - end + def escalate(%{"path" => path}, %Context{cwd: cwd}), do: FS.escalation(path, cwd) + + @impl true + def fs_paths(%{"path" => path}, _ctx), do: [{path, :read}] # sobelow_skip reason: Traversal.FileModule fires on every File call whose path is a variable, # and a filesystem tool's path is the model's argument by design. The control is not the - # path's shape but the gate: `Trinity.Tools.FS.resolve/2` judges every path after symlink - # resolution against the roots and the tools escalate anything outside to `:ask` (docs/07, - # filesystem; slice 022 AC1), and a write is atomic with a backup. Scoped to the function - # rather than .sobelow-skips, which keys on file and line. + # path's shape but the gate: `Trinity.Tools.FS.Guard` judges every path (no symlink + # followed, protected directories and inodes refused) against the roots and the tools + # escalate anything outside to `:ask` (docs/07, filesystem; slice 022 AC1), and a write is + # atomic with a backup. Scoped to the function rather than .sobelow-skips, which keys on file + # and line. @sobelow_skip ["Traversal.FileModule"] @impl true - def execute(%{"path" => path} = args, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(path, cwd) + def execute(%{"path" => path} = args, %Context{cwd: cwd} = ctx) do offset = Map.get(args, "offset", 1) limit = Map.get(args, "limit", 500) - case File.read(real) do - {:ok, bytes} -> + # Slice 135: opened by the guard, which fstats the descriptor before a byte is read. + case Guard.read(path, cwd) do + {:ok, bytes, %{canonical: real} = verdict} -> + Taint.note_read(verdict, ctx.session_id) + {text, cut?} = cap(bytes) lines = @@ -77,7 +79,7 @@ defmodule Trinity.Tools.FS.Read do {:ok, Untrusted.result(lines, tool: "fs_read", source_ref: real, meta: meta)} {:error, reason} -> - {:error, {:file, reason, real}} + {:error, reason} end end diff --git a/lib/trinity/tools/fs/write.ex b/lib/trinity/tools/fs/write.ex index 73e691b..dc14ece 100644 --- a/lib/trinity/tools/fs/write.ex +++ b/lib/trinity/tools/fs/write.ex @@ -42,16 +42,24 @@ defmodule Trinity.Tools.FS.Write do @impl true def escalate(%{"path" => path} = args, %Context{cwd: cwd}) do - cond do - Map.get(args, "allow_placeholders", false) -> :destructive - match?({:ok, _, :outside}, FS.resolve(path, cwd)) -> :ask - true -> nil - end + if Map.get(args, "allow_placeholders", false), + do: :destructive, + else: FS.escalation(path, cwd, :write) end + @impl true + def fs_paths(%{"path" => path}, _ctx), do: [{path, :write}] + + # Slice 135: judged again here, at execution, by the same guard the runner asked: a path that + # became a link or a protected file since the decision is refused rather than written. @impl true def execute(%{"path" => path, "content" => content} = args, %Context{cwd: cwd}) do - {:ok, real, _} = FS.resolve(path, cwd) + with {:ok, real, _} <- FS.resolve(path, cwd, :write) do + write(real, content, args) + end + end + + defp write(real, content, args) do new? = not File.exists?(real) with :ok <- validate(content, new?, Map.get(args, "allow_placeholders", false)), diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index a924e7a..846394a 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -28,6 +28,7 @@ defmodule Trinity.Tools.Runner do alias Trinity.Permissions alias Trinity.Tools.{Context, Registry, Result, Schema} + alias Trinity.Tools.FS.Guard @default_timeout 30_000 @grace 100 @@ -107,6 +108,7 @@ defmodule Trinity.Tools.Runner do def execute_direct(entry, args, ctx) do case decide(entry, args, ctx) do {:allow, _fp, _basis} -> call_tool(entry, args, ctx) + {:deny, _fp, "fs_guard"} -> {:error, fs_denied(entry, args, ctx)} {:deny, _fp, _basis} -> {:error, :denied} {:ask, reason, _fp, _basis} -> {:error, reason} end @@ -123,9 +125,43 @@ defmodule Trinity.Tools.Runner do {:allow, String.t(), String.t()} | {:deny, String.t(), String.t()} | {:ask, term(), String.t(), String.t()} - def decide(%{name: name} = entry, args, ctx) do + def decide(entry, args, ctx), do: decide(entry, args, ctx, fs_verdicts(entry, args, ctx)) + + @doc """ + The same, with the guard's verdicts for the call's host paths already taken (slice 135), so the + caller that receipts them (`Trinity.Effects.Runner`) records the verdicts that decided. A refused + path is `{:deny, fp, "fs_guard"}`, before the policy is asked: no grant, rule or approval lifts it. + """ + @spec decide(Registry.entry(), map(), Context.t(), [Guard.verdict()]) :: + {:allow, String.t(), String.t()} + | {:deny, String.t(), String.t()} + | {:ask, term(), String.t(), String.t()} + def decide(%{name: name} = entry, args, ctx, verdicts) do fp = Permissions.fingerprint(ctx.session_id, name, args, ctx.cwd) + if Enum.any?(verdicts, &(&1.decision == :deny)), + do: {:deny, fp, "fs_guard"}, + else: decide_by_policy(entry, args, ctx, fp) + end + + @doc """ + The guard's verdict for each host path the call names (`Trinity.Tools.Tool.fs_paths/2`), in the + order the tool listed them; empty for a tool that names none. + """ + @spec fs_verdicts(Registry.entry(), map(), Context.t()) :: [Guard.verdict()] + def fs_verdicts(%{module: module}, args, ctx) when is_atom(module) do + if function_exported?(module, :fs_paths, 2) do + paths = module.fs_paths(args, ctx) + scope = if paths == [], do: nil, else: Guard.scope(ctx.cwd) + for {path, mode} <- paths, do: Guard.check(path, ctx.cwd, mode, scope) + else + [] + end + end + + def fs_verdicts(_entry, _args, _ctx), do: [] + + defp decide_by_policy(%{name: name} = entry, args, ctx, fp) do case Permissions.decide_with_basis(ctx.session_id, name, args, persona: ctx.persona, cwd: ctx.cwd, @@ -138,6 +174,12 @@ defmodule Trinity.Tools.Runner do end end + @doc "The refusal a guard denial answers the model with: the first refused path's verdict." + @spec fs_denied(Registry.entry(), map(), Context.t()) :: {:fs_denied, Guard.verdict()} + def fs_denied(entry, args, ctx) do + {:fs_denied, entry |> fs_verdicts(args, ctx) |> Enum.find(&(&1.decision == :deny))} + end + # Slice 028: the context states this call is made under. They can only make the gate stricter # (`Trinity.Permissions.Policy.State`), so measuring one wrongly costs an unnecessary approval # and never an unearned one, which is why measuring here rather than in the policy is safe. diff --git a/lib/trinity/tools/shell/run.ex b/lib/trinity/tools/shell/run.ex index 049c84f..a9e3fe9 100644 --- a/lib/trinity/tools/shell/run.ex +++ b/lib/trinity/tools/shell/run.ex @@ -61,22 +61,28 @@ defmodule Trinity.Tools.Shell.Run do @impl true def escalate(%{"command" => command} = args, %Context{cwd: cwd}) do - cond do - Dangerous.match(command) != [] -> :destructive - match?({:ok, _, :outside}, FS.resolve(Map.get(args, "cwd", "."), cwd)) -> :ask - true -> nil - end + if Dangerous.match(command) != [], + do: :destructive, + else: FS.escalation(Map.get(args, "cwd", "."), cwd, :dir) end + # Slice 135: the working directory is a path the guard judges; the command text is not (no path + # check can read free text), which docs/07 states. + @impl true + def fs_paths(args, _ctx), do: [{Map.get(args, "cwd", "."), :dir}] + @impl true def execute(%{"command" => command} = args, %Context{cwd: cwd}) do - {:ok, dir, _} = FS.resolve(Map.get(args, "cwd", "."), cwd) timeout = args |> Map.get("timeout_ms", @default_timeout_ms) |> min(@max_timeout_ms) - if File.dir?(dir) do - run(command, dir, timeout) - else - {:error, {:cwd, "no such directory: #{dir}"}} + case FS.resolve(Map.get(args, "cwd", "."), cwd, :dir) do + {:ok, dir, _} -> + if File.dir?(dir), + do: run(command, dir, timeout), + else: {:error, {:cwd, "no such directory: #{dir}"}} + + {:error, _} = refused -> + refused end end diff --git a/lib/trinity/tools/supervisor.ex b/lib/trinity/tools/supervisor.ex index 9f6610d..f898031 100644 --- a/lib/trinity/tools/supervisor.ex +++ b/lib/trinity/tools/supervisor.ex @@ -14,6 +14,8 @@ defmodule Trinity.Tools.Supervisor do def init(_opts) do children = [ {Task.Supervisor, name: Trinity.Tools.TaskSupervisor}, + # Slice 135: which sessions read a path tagged sensitive, for web_fetch's escalation. + Trinity.Tools.Taint, Trinity.Tools.Registry ] diff --git a/lib/trinity/tools/taint.ex b/lib/trinity/tools/taint.ex new file mode 100644 index 0000000..807352c --- /dev/null +++ b/lib/trinity/tools/taint.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.Taint do + @moduledoc """ + Which sessions have read a file from a path tagged sensitive (slice 135, the network half of S1). + + A path is tagged sensitive when it lies outside the roots: the read had to be approved, so it is + the read the owner marked as out of the ordinary (slice 135 NOTES, D2). Once a session has made + one, `web_fetch` asks for every fetch in that session, because whether a request "carries" bytes + read earlier cannot be decided against any encoding of them. + + **The taint is not the gate for the read.** The read was already allowed or asked as before; this + only raises what a later egress needs, and it rides on receipts as an attribute. A public table + under `Trinity.Tools.Supervisor`, one row per session and path, at most 100 paths per session; + nothing here survives a restart, and a restarted session has no taint (the owner's approval of the + earlier read is still on its chain). + """ + use GenServer + + @table :trinity_tools_taint + @max_paths 100 + + @doc false + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :public, :bag, read_concurrency: true]) + {:ok, nil} + end + + @doc "Records a read the guard judged outside the roots (`decision: :ask`) for the session; ignores any other." + @spec note_read(map(), String.t() | nil) :: :ok + def note_read(%{decision: :ask, canonical: path}, session_id) + when is_binary(session_id) and is_binary(path) do + if table?() and length(sensitive_paths(session_id)) < @max_paths do + :ets.insert(@table, {session_id, path}) + end + + :ok + end + + def note_read(_verdict, _session_id), do: :ok + + @doc "The sensitive paths the session has read, in no particular order." + @spec sensitive_paths(String.t() | nil) :: [String.t()] + def sensitive_paths(session_id) when is_binary(session_id) do + if table?(), + do: @table |> :ets.lookup(session_id) |> Enum.map(&elem(&1, 1)) |> Enum.uniq(), + else: [] + end + + def sensitive_paths(_), do: [] + + @doc "True when the session has read a path tagged sensitive." + @spec sensitive?(String.t() | nil) :: boolean() + def sensitive?(session_id), do: sensitive_paths(session_id) != [] + + @doc "Forgets a session's taint." + @spec clear(String.t()) :: :ok + def clear(session_id) do + if table?(), do: :ets.delete(@table, session_id) + :ok + end + + defp table?, do: :ets.whereis(@table) != :undefined +end diff --git a/lib/trinity/tools/tool.ex b/lib/trinity/tools/tool.ex index b3c6005..b88f645 100644 --- a/lib/trinity/tools/tool.ex +++ b/lib/trinity/tools/tool.ex @@ -55,7 +55,14 @@ defmodule Trinity.Tools.Tool do @doc "False on a platform where the tool cannot keep its guarantee (slice 022: the shell on Windows); the registry skips it." @callback available?() :: boolean() - @optional_callbacks timeout: 0, format_result: 1, escalate: 2, available?: 0 + @doc """ + The host paths this call names, each with how the tool uses it (slice 135): `:read`, `:write` or + `:dir`. The runner hands each to `Trinity.Tools.FS.Guard` before the gate is asked, and a refused + path is a denial no grant or approval can lift. A tool that takes no host path omits it. + """ + @callback fs_paths(args(), Context.t()) :: [{String.t(), :read | :write | :dir}] + + @optional_callbacks timeout: 0, format_result: 1, escalate: 2, available?: 0, fs_paths: 2 @doc "True when `module` implements this behaviour." @spec implemented_by?(module()) :: boolean() diff --git a/lib/trinity/tools/web/fetch.ex b/lib/trinity/tools/web/fetch.ex index 9db9365..f4d8260 100644 --- a/lib/trinity/tools/web/fetch.ex +++ b/lib/trinity/tools/web/fetch.ex @@ -14,7 +14,7 @@ defmodule Trinity.Tools.Web.Fetch do """ @behaviour Trinity.Tools.Tool - alias Trinity.Tools.Untrusted + alias Trinity.Tools.{Taint, Untrusted} @max_bytes 1_048_576 @timeout_ms 20_000 @@ -42,17 +42,28 @@ defmodule Trinity.Tools.Web.Fetch do @impl true def timeout, do: @timeout_ms + 5_000 + # Slice 135: two more reasons to ask, each only ever raising the tier. A session that has read a + # file from a path tagged sensitive (outside the roots: `Trinity.Tools.Taint`) asks for every + # fetch after it; and under `:regulated` a host off `TRINITY_REGULATED_EGRESS` asks. @impl true - def escalate(%{"url" => url}, _ctx) do + def escalate(%{"url" => url}, ctx) do case URI.parse(url) do %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> - if public_host?(host), do: nil, else: :ask + cond do + not public_host?(host) -> :ask + Taint.sensitive?(session_id(ctx)) -> :ask + not Trinity.Profile.allowed_egress?(Trinity.Profile.current(), host) -> :ask + true -> nil + end _ -> :ask end end + defp session_id(%{session_id: sid}), do: sid + defp session_id(_), do: nil + @impl true def execute(%{"url" => url}, _ctx) do with {:ok, uri} <- parse(url), diff --git a/lib/trinity_web/components/settings_components.ex b/lib/trinity_web/components/settings_components.ex index a19cf65..c1311d5 100644 --- a/lib/trinity_web/components/settings_components.ex +++ b/lib/trinity_web/components/settings_components.ex @@ -17,6 +17,7 @@ defmodule TrinityWeb.SettingsComponents do require Phoenix.LiveView alias Trinity.{Desktop, Secrets, Settings} + alias Trinity.Tools.FS.Guard ## State @@ -97,6 +98,17 @@ defmodule TrinityWeb.SettingsComponents do not File.dir?(root) -> assign(socket, root_error: gettext("No folder exists at %{root}.", root: root)) + # Slice 135: a folder that is, contains or sits inside the data or secrets directory would + # refuse the next boot; it is refused here instead, with the reason. + match?({:error, _}, Guard.check_roots([root], Guard.protected_dirs())) -> + assign(socket, + root_error: + gettext( + "%{root} is, contains or sits inside Trinity's data or secrets folder, which no tool may reach.", + root: root + ) + ) + true -> roots = Enum.uniq(Settings.get(:fs_roots) ++ [root]) diff --git a/test/paths_test.exs b/test/paths_test.exs index 60cb404..b018e13 100644 --- a/test/paths_test.exs +++ b/test/paths_test.exs @@ -15,6 +15,12 @@ defmodule PathsTest do defp env(map), do: fn key -> Map.get(map, key) end + # By path component, either separator: "Trinity Secrets" is not inside "Trinity". + defp inside?(path, dir), + do: + path == dir or String.starts_with?(path, dir <> "/") or + String.starts_with?(path, dir <> "\\") + describe "data_dir/2 resolves a different root per OS" do test "linux honours XDG_DATA_HOME" do got = Paths.data_dir({:unix, :linux}, env(%{"XDG_DATA_HOME" => "/home/a/.local/share"})) @@ -55,6 +61,39 @@ defmodule PathsTest do end end + # Slice 135: the secrets directory, per OS, never inside the data directory (nor the other way). + describe "secrets_dir/2 is outside the data directory on every OS" do + test "linux: XDG_CONFIG_HOME, else ~/.config" do + assert Paths.secrets_dir({:unix, :linux}, env(%{"XDG_CONFIG_HOME" => "/home/a/.cfg"})) == + "/home/a/.cfg/trinity/secrets" + + assert Paths.secrets_dir({:unix, :linux}, env(%{"HOME" => "/home/a"})) == + "/home/a/.config/trinity/secrets" + end + + test "macOS and windows have their own" do + assert Paths.secrets_dir({:unix, :darwin}, env(%{"HOME" => "/Users/a"})) == + "/Users/a/Library/Application Support/Trinity Secrets" + + assert Paths.secrets_dir( + {:win32, :nt}, + env(%{"LOCALAPPDATA" => "C:\\Users\\a\\AppData\\Local"}) + ) == + "C:\\Users\\a\\AppData\\Local\\Trinity\\Secrets" + end + + test "neither directory contains the other, for the same environment" do + e = env(%{"HOME" => "/home/a", "APPDATA" => "R", "LOCALAPPDATA" => "L"}) + + for os <- [{:unix, :linux}, {:unix, :darwin}, {:win32, :nt}] do + data = Paths.data_dir(os, e) + secrets = Paths.secrets_dir(os, e) + refute inside?(secrets, data), "#{inspect(os)}: #{secrets} in #{data}" + refute inside?(data, secrets), "#{inspect(os)}: #{data} in #{secrets}" + end + end + end + describe "data_dir/0" do test "reads the real OS and returns an absolute path" do assert Paths.data_dir() |> Path.type() == :absolute diff --git a/test/trinity/profile_egress_test.exs b/test/trinity/profile_egress_test.exs new file mode 100644 index 0000000..0bafabe --- /dev/null +++ b/test/trinity/profile_egress_test.exs @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.ProfileEgressTest do + @moduledoc """ + Slice 135, AC5's decisions, pure: `Trinity.Profile.check_egress/3` and the allow-list's reading. + The boot that asks them is `Trinity.SecretsBootNodeTest`; `web_fetch`'s use of them is below. + """ + use ExUnit.Case, async: false + + alias Trinity.Profile + alias Trinity.Tools.Context + alias Trinity.Tools.Web.Fetch + + test "the default profile never refuses" do + assert Profile.check_egress(:default, :allow, nil) == :ok + end + + test "regulated refuses network: :allow with no list, by the variable's name" do + assert Profile.check_egress(:regulated, :allow, nil) == + {:error, + {:regulated_network_allow_without_egress_allowlist, "TRINITY_REGULATED_EGRESS"}} + + assert {:error, _} = Profile.check_egress(:regulated, :allow, " , ") + end + + test "regulated accepts a list, or a network default that already asks" do + assert Profile.check_egress(:regulated, :allow, "docs.internal") == :ok + assert Profile.check_egress(:regulated, :ask, nil) == :ok + assert Profile.check_egress(:regulated, :deny, nil) == :ok + end + + test "the list reads bare hosts and URLs, lower-cased" do + assert Profile.allowed_egress("Docs.Internal, https://api.example.com/v1") == + ["docs.internal", "api.example.com"] + end + + describe "web_fetch under :regulated" do + setup do + saved = System.get_env("TRINITY_REGULATED_EGRESS") + saved_profile = System.get_env("TRINITY_PROFILE") + System.put_env("TRINITY_REGULATED_EGRESS", "docs.internal,.example.org") + System.put_env("TRINITY_PROFILE", "regulated") + + on_exit(fn -> + for {k, v} <- [{"TRINITY_REGULATED_EGRESS", saved}, {"TRINITY_PROFILE", saved_profile}] do + if v, do: System.put_env(k, v), else: System.delete_env(k) + end + end) + end + + test "a host on the list fetches without asking; one off it asks" do + ctx = %Context{} + assert Fetch.escalate(%{"url" => "https://docs.internal/a"}, ctx) == nil + assert Fetch.escalate(%{"url" => "https://www.example.org/a"}, ctx) == nil + assert Fetch.escalate(%{"url" => "https://example.com/a"}, ctx) == :ask + end + end +end diff --git a/test/trinity/regulated_boot_node_test.exs b/test/trinity/regulated_boot_node_test.exs index 870aa8f..45b6b7a 100644 --- a/test/trinity/regulated_boot_node_test.exs +++ b/test/trinity/regulated_boot_node_test.exs @@ -98,6 +98,10 @@ defmodule Trinity.RegulatedBootNodeTest do {"TRINITY_PROFILE", nil}, {"TRINITY_AUTHORITY", nil}, {"TRINITY_REGULATED_LLM_ENDPOINTS", nil}, + # Slice 135: a regulated node with the default `network: :allow` needs an egress + # allow-list. Every case here has one, so each refuses (or boots) for the reason it names; + # the refusal without one is `Trinity.SecretsBootNodeTest`'s AC5. + {"TRINITY_REGULATED_EGRESS", "docs.internal"}, {"TRINITY_MCP_AUTH_PROFILE", nil} ] diff --git a/test/trinity/secrets/migration_test.exs b/test/trinity/secrets/migration_test.exs new file mode 100644 index 0000000..7b3f17f --- /dev/null +++ b/test/trinity/secrets/migration_test.exs @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Secrets.MigrationTest do + @moduledoc """ + Slice 135: `Trinity.Secrets.Migration.run/1` on temporary directories (the boot that runs it is + `Trinity.SecretsBootNodeTest`, AC4). What moves, the modes it leaves, a conflict left in place, + an operator's configured location left alone, and nothing to do when nothing is there. + """ + use ExUnit.Case, async: false + + alias Trinity.Secrets.Migration + + setup do + base = Path.join(System.tmp_dir!(), "s135m-#{System.unique_integer([:positive])}") + data = Path.join(base, "data") + secrets = Path.join(base, "secrets") + File.mkdir_p!(data) + saved_receipts = Application.get_env(:trinity, :receipts, []) + saved_auth = Application.get_env(:trinity, :mcp_auth, []) + # The release defaults: nothing pinned. + Application.put_env(:trinity, :receipts, Keyword.delete(saved_receipts, :keys_dir)) + Application.put_env(:trinity, :mcp_auth, []) + + on_exit(fn -> + Application.put_env(:trinity, :receipts, saved_receipts) + Application.put_env(:trinity, :mcp_auth, saved_auth) + File.rm_rf!(base) + end) + + {:ok, data: data, secrets: secrets} + end + + defp mode(path), do: File.stat!(path).mode |> Bitwise.band(0o777) + + test "nothing there, nothing moved", %{data: data, secrets: secrets} do + assert Migration.run(data_dir: data, secrets_dir: secrets) == nil + end + + test "keys, the bearer token and the OAuth store move; modes are set; the old places go", %{ + data: data, + secrets: secrets + } do + File.mkdir_p!(Path.join(data, "keys")) + File.write!(Path.join(data, "keys/receipts-ed25519.key"), "k") + File.write!(Path.join(data, "keys/registry.json"), "[]") + File.write!(Path.join(data, "keys/mcp-state.key"), "s") + File.write!(Path.join(data, "mcp-server-token"), "t") + File.mkdir_p!(Path.join(data, "secrets/oauth")) + File.write!(Path.join(data, "secrets/oauth/r.json"), "{}") + + report = Migration.run(data_dir: data, secrets_dir: secrets) + + assert report["to"] == Path.join(secrets, "keys") + assert report["files"] == ~w(mcp-state.key receipts-ed25519.key registry.json) + assert report["conflicts"] == [] + assert length(report["moved"]) == 3 + + assert File.read!(Path.join(secrets, "keys/receipts-ed25519.key")) == "k" + assert mode(Path.join(secrets, "keys/receipts-ed25519.key")) == 0o400 + assert mode(Path.join(secrets, "keys/mcp-state.key")) == 0o400 + assert mode(Path.join(secrets, "keys/registry.json")) == 0o600 + assert mode(Path.join(secrets, "mcp-server-token")) == 0o400 + assert mode(Path.join(secrets, "oauth/r.json")) == 0o600 + assert mode(secrets) == 0o700 + assert mode(Path.join(secrets, "keys")) == 0o700 + + refute File.exists?(Path.join(data, "keys")) + refute File.exists?(Path.join(data, "mcp-server-token")) + refute File.exists?(Path.join(data, "secrets")) + refute inspect(report) =~ "\"k\"" + end + + test "a file already at the destination is never overwritten; the source stays and is named", %{ + data: data, + secrets: secrets + } do + File.mkdir_p!(Path.join(data, "keys")) + File.write!(Path.join(data, "keys/registry.json"), "old") + File.write!(Path.join(data, "keys/device_id"), "d") + File.mkdir_p!(Path.join(secrets, "keys")) + File.write!(Path.join(secrets, "keys/registry.json"), "new") + + report = Migration.run(data_dir: data, secrets_dir: secrets) + + assert report["conflicts"] == [Path.join(data, "keys/registry.json")] + assert report["files"] == ["device_id"] + assert File.read!(Path.join(secrets, "keys/registry.json")) == "new" + assert File.read!(Path.join(data, "keys/registry.json")) == "old" + end + + test "a location the operator configured is the operator's", %{data: data, secrets: secrets} do + Application.put_env(:trinity, :receipts, keys_dir: Path.join(data, "keys")) + File.mkdir_p!(Path.join(data, "keys")) + File.write!(Path.join(data, "keys/receipts-ed25519.key"), "k") + + assert Migration.run(data_dir: data, secrets_dir: secrets) == nil + assert File.exists?(Path.join(data, "keys/receipts-ed25519.key")) + end +end diff --git a/test/trinity/secrets_boot_node_test.exs b/test/trinity/secrets_boot_node_test.exs new file mode 100644 index 0000000..722c571 --- /dev/null +++ b/test/trinity/secrets_boot_node_test.exs @@ -0,0 +1,268 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.SecretsBootNodeTest do + @moduledoc """ + Slice 135, AC2 and AC4, at the only place they are real: a boot in a separate OS process. + + The child is built the way `Trinity.RegulatedBootNodeTest` builds one (its own `XDG_DATA_HOME`, + its own databases through `Trinity.BootIsolation`, `MIX_ENV=test`), and additionally gets its own + `TRINITY_SECRETS_DIR`. + + **AC2.** A root equal to, above or below the data directory or the secrets directory refuses the + boot, with a named reason, in every profile. One child tries each of the six relations under both + profiles, starting and stopping the application between attempts, and prints one line per + attempt; the parent asserts on those lines. + + **AC4.** A data directory laid out the way every earlier release laid it out (the receipt key and + its registry in `/keys`, receipts already signed with that key) boots, the key moves to + the secrets directory, the move is on the signed record, and every receipt written before the move + still verifies. The first child writes the old layout by pointing the signer at `/keys` + exactly as the previous default did; the second boots with nothing pinned. + """ + use ExUnit.Case, async: false + + @moduletag timeout: 300_000 + + @isolate """ + Trinity.BootIsolation.isolate!(System.fetch_env!("TRINITY_BOOT_TAG")) + """ + + defp case_setup(name) do + tag = Trinity.BootIsolation.tag(name) + dir = Path.join(System.tmp_dir!(), "s135boot-#{name}-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + on_exit(fn -> + Trinity.BootIsolation.drop!(tag) + File.rm_rf(dir) + end) + + {dir, tag} + end + + defp boot(env, script) do + base = [ + {"MIX_ENV", "test"}, + {"TRINITY_PROFILE", nil}, + {"TRINITY_AUTHORITY", nil}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", nil}, + {"TRINITY_MCP_AUTH_PROFILE", nil}, + {"TRINITY_FS_ROOTS", nil} + ] + + System.cmd("mix", ["run", "--no-start", "-e", @isolate <> script], + env: base ++ env, + stderr_to_stdout: true + ) + end + + describe "AC2: a root overlapping the data or secrets directory refuses the boot" do + test "equal, ancestor and descendant of either, under :default and :regulated" do + {root, tag} = case_setup("roots") + # Two separate branches, so an ancestor of one is not an ancestor of the other. + xdg = Path.join(root, "xdg") + data = Path.join(xdg, "trinity") + secrets = Path.join([root, "sec", "secrets"]) + + cases = [ + {"equal_data", data}, + {"ancestor_data", xdg}, + {"descendant_data", Path.join(data, "sub")}, + {"equal_secrets", secrets}, + {"ancestor_secrets", Path.dirname(secrets)}, + {"descendant_secrets", Path.join(secrets, "sub")} + ] + + script = """ + File.mkdir_p!(#{inspect(Path.join(data, "sub"))}) + File.mkdir_p!(#{inspect(Path.join(secrets, "sub"))}) + + # The dependencies once, so each attempt starts and stops :trinity alone: a refused start + # rolls back what that call started, and some dependencies cannot be started twice. + for app <- Application.spec(:trinity, :applications), do: {:ok, _} = Application.ensure_all_started(app) + + for profile <- ["default", "regulated"], + {name, r} <- #{inspect(cases)} do + System.put_env("TRINITY_PROFILE", profile) + Application.put_env(:trinity, :fs, roots: [r]) + + line = + case Application.ensure_all_started(:trinity) do + {:ok, _} -> + Application.stop(:trinity) + "BOOT_OK" + + {:error, reason} -> + "BOOT_REFUSED " <> inspect(reason, limit: :infinity, printable_limit: :infinity) + end + + IO.puts("CASE " <> profile <> " " <> name <> " " <> line) + end + + System.halt(0) + """ + + {out, status} = + boot( + [ + {"XDG_DATA_HOME", xdg}, + {"TRINITY_SECRETS_DIR", secrets}, + {"TRINITY_BOOT_TAG", tag} + ], + script + ) + + assert status == 0, String.slice(out, -3000, 3000) + lines = out |> String.split("\n") |> Enum.filter(&String.starts_with?(&1, "CASE ")) + assert length(lines) == 12, "expected twelve attempts:\n#{String.slice(out, -3000, 3000)}" + + for line <- lines do + assert line =~ "BOOT_REFUSED", "this attempt booted: #{line}" + assert line =~ "fs_root_overlaps", "it refused, but not for the root: #{line}" + + which = if line =~ "_data ", do: "data_dir", else: "secrets_dir" + assert line =~ which, "the reason does not name #{which}: #{line}" + + relation = + cond do + line =~ " equal_" -> "equal" + line =~ " ancestor_" -> "ancestor" + true -> "descendant" + end + + assert line =~ relation, "the reason does not name the relation #{relation}: #{line}" + end + end + end + + describe "AC4: secrets migrate out of the data directory on boot" do + test "keys under /keys move to the secrets directory, receipted, and old receipts verify" do + {root, tag} = case_setup("migrate") + data = Path.join(root, "trinity") + secrets = Path.join(root, "secrets") + env = [{"XDG_DATA_HOME", root}, {"TRINITY_SECRETS_DIR", secrets}, {"TRINITY_BOOT_TAG", tag}] + + # The layout every release before this one wrote: the signer's keys directory is + # `/keys`, which is what `Trinity.Paths.keys_dir/0` answered. + old = """ + r = Application.get_env(:trinity, :receipts, []) + Application.put_env(:trinity, :receipts, Keyword.put(r, :keys_dir, #{inspect(Path.join(data, "keys"))})) + {:ok, _} = Application.ensure_all_started(:trinity) + %{key_id: kid, key_path: path} = Trinity.Receipts.KeyCustody.selected() + IO.puts("OLD_KEY " <> kid) + IO.puts("OLD_PATH " <> path) + IO.puts("OLD_COUNT " <> Integer.to_string(Trinity.Receipts.count("boot"))) + System.halt(0) + """ + + {out, 0} = boot(env, old) + [_, old_kid] = Regex.run(~r/OLD_KEY (\S+)/, out) + # The file is named for the signing algorithm, which is Ed25519 off the FIPS leg and P-384 + # on it (`KeyCustody.select/0`), so the path is the signer's own answer, not a literal. + [_, old_path] = Regex.run(~r/OLD_PATH (\S+)/, out) + assert Path.dirname(old_path) == Path.join(data, "keys") + assert File.regular?(old_path), out + + # The same data directory, booted with nothing pinned, as a release would. + new = """ + Application.put_env(:trinity, Trinity.Secrets.Migration, enabled: true) + r = Application.get_env(:trinity, :receipts, []) + Application.put_env(:trinity, :receipts, Keyword.delete(r, :keys_dir)) + {:ok, _} = Application.ensure_all_started(:trinity) + %{key_id: kid, key_path: path} = Trinity.Receipts.KeyCustody.selected() + IO.puts("NEW_KEY " <> kid) + IO.puts("NEW_PATH " <> path) + boot = Trinity.Receipts.boot_receipt() + IO.puts("MOVE " <> JSON.encode!(JSON.decode!(boot.signed_payload)["subject"]["secrets_migration"])) + {:ok, export} = Trinity.Receipts.export("boot") + IO.puts("COUNT " <> Integer.to_string(length(export["receipts"]))) + IO.puts("VERIFY " <> inspect(Trinity.Receipts.Verifier.verify(export |> JSON.encode!() |> JSON.decode!()))) + System.halt(0) + """ + + {out, status} = boot(env, new) + assert status == 0, String.slice(out, -3000, 3000) + [_, new_kid] = Regex.run(~r/NEW_KEY (\S+)/, out) + [_, new_path] = Regex.run(~r/NEW_PATH (\S+)/, out) + + assert new_kid == old_kid, "the key was replaced rather than moved" + assert Path.dirname(new_path) == Path.join(secrets, "keys") + refute File.exists?(old_path) + assert {:ok, %{mode: mode}} = File.stat(new_path) + assert Bitwise.band(mode, 0o777) == 0o400 + assert {:ok, %{mode: dmode}} = File.stat(Path.join(secrets, "keys")) + assert Bitwise.band(dmode, 0o777) == 0o700 + + [_, move] = Regex.run(~r/MOVE (.+)/, out) + move = JSON.decode!(move) + assert move["to"] == Path.join(secrets, "keys") + assert Path.basename(old_path) in move["files"] + + [_, count] = Regex.run(~r/COUNT (\d+)/, out) + assert String.to_integer(count) >= 2, "the earlier boot's receipt is in the chain" + assert out =~ "VERIFY {:ok,", "the chain does not verify after the move:\n#{out}" + end + end + + describe "AC5: under :regulated, network: :allow with no egress allow-list refuses the boot" do + # Everything `Trinity.RegulatedBootNodeTest` case 4 needs to boot a regulated node, and no + # TRINITY_REGULATED_EGRESS; then the same with it. + @fixture """ + defmodule SecretsBootFixture do + @behaviour Trinity.Authority + def stage(staged, _ctx), do: {:ok, staged} + def decide(_staged, decision, _ctx), do: {:ok, decision, "fixture"} + def execute(_staged, _decision, _ctx), do: {:error, :not_used} + def receipt(_kind, _attrs), do: {:error, :not_used} + end + Application.put_env(:trinity, :mcp_auth, profile: :production) + llm = Application.get_env(:trinity, :llm, []) + Application.put_env(:trinity, :llm, Keyword.put(llm, :models, [ + %{id: "allowed", provider: :req_llm, model: "openai:x", + base_url: "https://models.internal/v1", api_key_env: "NONE", + caps: [:stream], price: %{input: 0.0, output: 0.0}} + ])) + case Application.ensure_all_started(:trinity) do + {:ok, _} -> IO.puts("BOOT_OK"); System.halt(0) + {:error, reason} -> + IO.puts("BOOT_REFUSED " <> inspect(reason, limit: :infinity, printable_limit: :infinity)) + System.halt(3) + end + """ + + defp regulated_env(root, tag, extra) do + [ + {"XDG_DATA_HOME", root}, + {"TRINITY_SECRETS_DIR", Path.join(root, "secrets")}, + {"TRINITY_BOOT_TAG", tag}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "SecretsBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"} + ] ++ extra + end + + test "no TRINITY_REGULATED_EGRESS: refused, and the reason names it" do + {root, tag} = case_setup("egress-unset") + + {out, status} = + boot(regulated_env(root, tag, [{"TRINITY_REGULATED_EGRESS", nil}]), @fixture) + + assert status == 3, + "a regulated node with network: :allow and no egress list booted:\n#{out}" + + line = out |> String.split("\n") |> Enum.find("", &String.starts_with?(&1, "BOOT_REFUSED")) + assert line =~ "regulated_network_allow_without_egress_allowlist", line + assert line =~ "TRINITY_REGULATED_EGRESS", line + end + + test "with TRINITY_REGULATED_EGRESS set, the same node boots" do + {root, tag} = case_setup("egress-set") + + {out, status} = + boot(regulated_env(root, tag, [{"TRINITY_REGULATED_EGRESS", "docs.internal"}]), @fixture) + + assert status == 0, String.slice(out, -3000, 3000) + assert out =~ "BOOT_OK" + end + end +end diff --git a/test/trinity/sessions/fs_turn_receipt_test.exs b/test/trinity/sessions/fs_turn_receipt_test.exs new file mode 100644 index 0000000..191b943 --- /dev/null +++ b/test/trinity/sessions/fs_turn_receipt_test.exs @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Sessions.FsTurnReceiptTest do + @moduledoc """ + Slice 135, AC6 where it lives: a real Session turn, scripted through the fake provider, calls + `fs_read`, and the decision receipt on the session's chain carries the fs fields with the turn's + own id (the unit tests call the runner directly, where there is no turn and the id is nil). + """ + use Trinity.SessionCase + @moduletag :capture_log + + alias Trinity.Factory + alias Trinity.LLM.Providers.Fake + alias Trinity.Receipts + + setup do + root = Path.join(System.tmp_dir!(), "s135turn-#{System.unique_integer([:positive])}") + File.mkdir_p!(root) + File.write!(Path.join(root, "a.txt"), "hello from a file\n") + saved_fs = Application.get_env(:trinity, :fs, []) + Application.put_env(:trinity, :fs, Keyword.put(saved_fs, :roots, [root])) + + row = Factory.session!() + {:ok, _} = Sessions.set_project_root(row.id, root) + :ok = Sessions.subscribe(row.id) + + on_exit(fn -> + Application.put_env(:trinity, :fs, saved_fs) + Receipts.stop_writer(Receipts.session_scope(row.id)) + File.rm_rf!(root) + end) + + {:ok, id: row.id, root: root} + end + + test "the decision receipt of a turn's fs_read carries the fs fields and the turn id", %{ + id: id, + root: root + } do + Fake.scripts([ + [ + {:tool_call_start, "call_fs", "fs_read"}, + {:tool_call_end, "call_fs", %{"path" => "a.txt"}}, + {:done, :tool_calls} + ], + script_deltas(2, "done ") + ]) + + {:ok, pid} = start_drained(id) + {:ok, _} = Session.send_user_message(pid, "read a.txt") + # The first assistant message carries the call; the turn is over at the idle after the tools. + events = await_event(id, &match?({:state, :idle}, &1), 8_000) + assert :tool_wait in for({:state, s} <- events, do: s) + + [decision] = + id + |> Receipts.session_scope() + |> Receipts.list(kind: "decision") + |> Enum.filter(&(&1.subject["tool"] == "fs_read")) + + [fs] = JSON.decode!(decision.signed_payload)["decision"]["fs"] + assert fs["decision"] == "allow" + assert fs["canonical_path"] == Path.join(root, "a.txt") + assert fs["root"] == root + assert fs["principal"] == %{"caller" => id} + assert {:ok, _} = Ecto.UUID.cast(fs["turn_id"]) + end +end diff --git a/test/trinity/tools/fs/fs_test.exs b/test/trinity/tools/fs/fs_test.exs index 54412c5..75c3a9f 100644 --- a/test/trinity/tools/fs/fs_test.exs +++ b/test/trinity/tools/fs/fs_test.exs @@ -42,15 +42,21 @@ defmodule Trinity.Tools.FSTest do assert Permissions.decide(nil, "fs_read", %{"path" => "a.txt"}, escalate: nil) == :allow end - test "a symlink pointing outside the roots resolves outside", %{dir: dir, ctx: ctx} do + # Slice 135 changed this rule: a symlink was followed and its target judged (outside, so it + # asked); now no symlink is followed, and one anywhere in the path is refused outright. + test "a symlink pointing outside the roots is refused, not followed", %{dir: dir, ctx: ctx} do outside_dir = Path.join(System.tmp_dir!(), "trinity-out-#{System.unique_integer([:positive])}") File.mkdir_p!(outside_dir) on_exit(fn -> File.rm_rf!(outside_dir) end) File.ln_s!(outside_dir, Path.join(dir, "link")) - assert {:ok, _, :outside} = FS.resolve("link/x.txt", dir) + assert {:error, {:fs_denied, %{rule: "symlink"}}} = FS.resolve("link/x.txt", dir) assert Read.escalate(%{"path" => "link/x.txt"}, ctx) == :ask + + assert {:error, {:fs_denied, %{rule: "symlink"}}} = + Read.execute(%{"path" => "link/x.txt"}, ctx) + assert {:ok, _, :inside} = FS.resolve("new/file.txt", dir) end diff --git a/test/trinity/tools/fs/guard_test.exs b/test/trinity/tools/fs/guard_test.exs new file mode 100644 index 0000000..b6133b9 --- /dev/null +++ b/test/trinity/tools/fs/guard_test.exs @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.FS.GuardTest do + @moduledoc """ + Slice 135: `Trinity.Tools.FS.Guard`'s rules one at a time, in a temporary production layout + (`XDG_DATA_HOME`, `TRINITY_SECRETS_DIR`), so each refusal is shown with the rule that made it. + The end-to-end routes are `SecretsReachTest` and the census. + """ + use ExUnit.Case, async: false + + alias Trinity.Tools.FS.Guard + + @env ["XDG_DATA_HOME", "TRINITY_SECRETS_DIR"] + + setup do + base = Path.join(System.tmp_dir!(), "s135g-#{System.unique_integer([:positive])}") + xdg = Path.join(base, "xdg") + secrets = Path.join(base, "secrets") + root = Path.join(base, "root") + Enum.each([xdg, secrets, root], &File.mkdir_p!/1) + + saved_env = Map.new(@env, &{&1, System.get_env(&1)}) + saved_fs = Application.get_env(:trinity, :fs, []) + System.put_env("XDG_DATA_HOME", xdg) + System.put_env("TRINITY_SECRETS_DIR", secrets) + Application.put_env(:trinity, :fs, Keyword.put(saved_fs, :roots, [root])) + + on_exit(fn -> + Enum.each(saved_env, fn + {k, nil} -> System.delete_env(k) + {k, v} -> System.put_env(k, v) + end) + + Application.put_env(:trinity, :fs, saved_fs) + File.rm_rf!(base) + end) + + {:ok, base: base, root: root, secrets: secrets, data: Path.join(xdg, "trinity")} + end + + describe "check_roots/2 (AC2's rule)" do + test "equal, ancestor and descendant of either directory are refused by name; a sibling is not", + %{base: base} do + protected = [data_dir: Path.join(base, "d/trinity"), secrets_dir: Path.join(base, "s/x")] + + assert {:error, {:fs_root_overlaps, _, :data_dir, :equal}} = + Guard.check_roots([Path.join(base, "d/trinity")], protected) + + assert {:error, {:fs_root_overlaps, _, :data_dir, :ancestor}} = + Guard.check_roots([Path.join(base, "d")], protected) + + assert {:error, {:fs_root_overlaps, _, :data_dir, :descendant}} = + Guard.check_roots([Path.join(base, "d/trinity/skills")], protected) + + assert {:error, {:fs_root_overlaps, _, :secrets_dir, :ancestor}} = + Guard.check_roots([Path.join(base, "s")], protected) + + assert {:error, {:fs_root_overlaps, "/", :data_dir, :ancestor}} = + Guard.check_roots(["/"], protected) + + assert :ok = Guard.check_roots([Path.join(base, "d/trinity-other")], protected) + end + + test "a root spelled through a symlink is compared in canonical form", %{base: base} do + File.mkdir_p!(Path.join(base, "real/trinity")) + File.ln_s!(Path.join(base, "real"), Path.join(base, "alias")) + protected = [data_dir: Path.join(base, "real/trinity"), secrets_dir: "/nonexistent-s135"] + + assert {:error, {:fs_root_overlaps, _, :data_dir, :ancestor}} = + Guard.check_roots([Path.join(base, "alias")], protected) + end + + test "at call time an overlapping root is not a root", %{data: data} do + File.mkdir_p!(data) + Application.put_env(:trinity, :fs, roots: [data]) + assert Guard.roots() == [] + end + end + + describe "check/4" do + test "inside a root: allowed, with the root and the file's inode", %{root: root} do + File.write!(Path.join(root, "a.txt"), "a") + v = Guard.check("a.txt", root, :read) + assert %{decision: :allow, root: ^root, rule: nil, nlink: 1, type: :regular} = v + assert v.canonical == Path.join(root, "a.txt") + assert is_integer(v.ino) + end + + test "outside every root: asks", %{base: base, root: root} do + File.write!(Path.join(base, "out.txt"), "o") + assert %{decision: :ask, root: nil} = Guard.check(Path.join(base, "out.txt"), root, :read) + end + + test "/proc and /dev are refused", %{root: root} do + assert %{decision: :deny, rule: "proc_or_dev"} = + Guard.check("/proc/self/environ", root, :read) + + assert %{decision: :deny, rule: "proc_or_dev"} = Guard.check("/dev/zero", root, :read) + end + + test "a symlink anywhere in the path is refused, even one that stays inside the root", %{ + root: root + } do + File.mkdir_p!(Path.join(root, "real")) + File.write!(Path.join(root, "real/a.txt"), "a") + File.ln_s!(Path.join(root, "real"), Path.join(root, "via")) + assert %{decision: :deny, rule: "symlink"} = Guard.check("via/a.txt", root, :read) + assert %{decision: :allow} = Guard.check("real/a.txt", root, :read) + end + + test "the data and secrets directories are refused by path, a new file in them too", %{ + root: root, + data: data, + secrets: secrets + } do + File.mkdir_p!(data) + + assert %{decision: :deny, rule: "data_dir"} = + Guard.check(Path.join(data, "x"), root, :write) + + assert %{decision: :deny, rule: "secrets_dir"} = + Guard.check(Path.join(secrets, "new"), root, :write) + + assert %{decision: :deny, rule: "data_dir"} = Guard.check(data, root, :dir) + end + + test "a hard link is refused unless the policy opts in", %{root: root, base: base} do + File.write!(Path.join(base, "elsewhere.txt"), "e") + :ok = File.ln(Path.join(base, "elsewhere.txt"), Path.join(root, "h.txt")) + assert %{decision: :deny, rule: "hard_link", nlink: 2} = Guard.check("h.txt", root, :read) + + Application.put_env(:trinity, :fs, roots: [root], allow_hard_links: true) + assert %{decision: :allow, nlink: 2} = Guard.check("h.txt", root, :read) + end + + test "an inventoried inode is refused through a hard link even when hard links are allowed", + %{root: root, secrets: secrets} do + File.write!(Path.join(secrets, "token"), "t") + :ok = File.ln(Path.join(secrets, "token"), Path.join(root, "t.txt")) + Application.put_env(:trinity, :fs, roots: [root], allow_hard_links: true) + assert %{decision: :deny, rule: "protected_inode"} = Guard.check("t.txt", root, :read) + end + + test "the deny-list is the second check: it names a file the structural checks passed", %{ + root: root + } do + for name <- ~w(a.key b.db b.db-wal b.db-shm c.sqlite3 .env .env.local id_ed25519 cert.pem) do + File.write!(Path.join(root, name), "x") + assert %{decision: :deny, rule: "deny_list:" <> _} = Guard.check(name, root, :read), name + end + + File.write!(Path.join(root, "notes.txt"), "x") + assert %{decision: :allow} = Guard.check("notes.txt", root, :read) + end + + test "a special file is refused", %{root: root} do + {_, 0} = System.cmd("mkfifo", [Path.join(root, "pipe")]) + assert %{decision: :deny, rule: "special_file"} = Guard.check("pipe", root, :read) + end + + test "a root reached through its symlinked spelling is read as the canonical root", %{ + base: base + } do + real = Path.join(base, "realroot") + File.mkdir_p!(real) + File.write!(Path.join(real, "a.txt"), "a") + File.ln_s!(real, Path.join(base, "rootalias")) + Application.put_env(:trinity, :fs, roots: [Path.join(base, "rootalias")]) + + assert %{decision: :allow, canonical: canonical} = + Guard.check(Path.join(base, "rootalias/a.txt"), nil, :read) + + assert canonical == Path.join(real, "a.txt") + end + end + + describe "read/3" do + test "answers the bytes and the verdict for an allowed file; refuses a denied one", %{ + root: root + } do + File.write!(Path.join(root, "a.txt"), "hello") + assert {:ok, "hello", %{decision: :allow}} = Guard.read("a.txt", root) + File.write!(Path.join(root, "k.key"), "secret") + assert {:error, {:fs_denied, %{rule: "deny_list:*.key"}}} = Guard.read("k.key", root) + assert {:error, {:file, :enoent, _}} = Guard.read("missing.txt", root) + end + + test "inside a caller's own jail (zones: false) the inode and link checks still hold", %{ + data: data, + secrets: secrets + } do + File.mkdir_p!(Path.join(data, "skills/s")) + File.write!(Path.join(data, "skills/s/ref.md"), "skill text") + + assert {:ok, "skill text", _} = + Guard.read(Path.join(data, "skills/s/ref.md"), nil, zones: false) + + File.write!(Path.join(secrets, "k"), "k") + :ok = File.ln(Path.join(secrets, "k"), Path.join(data, "skills/s/k.md")) + + assert {:error, {:fs_denied, %{rule: rule}}} = + Guard.read(Path.join(data, "skills/s/k.md"), nil, zones: false) + + assert rule in ["protected_inode", "hard_link"] + end + end + + describe "receipt_fields/3" do + test "carries the AC6 fields and never the file's bytes", %{root: root} do + File.write!(Path.join(root, "a.txt"), "do-not-copy") + v = Guard.check("a.txt", root, :read) + + fields = + Guard.receipt_fields(v, "fs_read", %{ + caller: "s1", + principal: nil, + turn_id: "t1" + }) + + assert Map.keys(fields) |> Enum.sort() == + Enum.sort( + ~w(tool requested_path canonical_path dev ino root deny_rule nlink decision policy_hash principal turn_id) + ) + + assert fields["principal"] == %{"caller" => "s1"} + assert fields["turn_id"] == "t1" + refute inspect(fields) =~ "do-not-copy" + end + end +end diff --git a/test/trinity/tools/fs/secrets_census_test.exs b/test/trinity/tools/fs/secrets_census_test.exs new file mode 100644 index 0000000..ceea9b7 --- /dev/null +++ b/test/trinity/tools/fs/secrets_census_test.exs @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.FS.SecretsCensusTest do + @moduledoc """ + Slice 135, AC3: a census. It walks the data directory and the secrets directory and asserts that + no file in either is reachable through any configured root by any tool in the registry that takes + a path. + + **Both populations come from the tree.** The files are what a walk of the two directories finds + (a release's layout is planted first: keys, the registry, the MCP state key and bearer token, an + OAuth token, the databases and their journals, settings, a backup). The tools are the registry's + entries whose schema takes `path`, `file` or `cwd`, and the census fails if that set is not the set + it knows how to call, so a path-taking tool added later is a failure here until it is. + + **Routes.** For a tool that takes a file: the direct path, `..` from a root, a symlink inside a + root, a hard link inside a root, and `/proc/self/fd/N`. For a tool that takes a directory: the + file's directory directly, through `..` and through a symlink, and the root itself holding a + symlink and a hard link to the file. `skill_file` is jailed to a skill's directory, so its routes + are a symlink and a hard link planted inside a project skill. Each call goes through + `Trinity.Effects.Runner.run/2`; an approval request is answered "allow once" and the call made + again, because an ask the owner approves is not a refusal. + + **What counts as reached.** The file's bytes in the tool's answer, or the file changed (bytes or + inode) by the call, or (for `learn`) the bytes in a staged skill change. + + **What the census does not cover, stated.** `shell`'s path argument is its working directory, and + that is held here; the command text is free text and can name any file, and no path check can + read it. The shell is `:exec`, asks every time, and docs/07 states the BEAM is not an OS sandbox. + """ + use Trinity.DataCase, async: false + + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.Context + + @env ["XDG_DATA_HOME", "TRINITY_SECRETS_DIR"] + @moduletag timeout: 300_000 + + # The path-taking tools this census knows how to call, by how they take a path. + @file_tools ~w(fs_read fs_write fs_edit learn) + @dir_tools ~w(fs_list fs_glob fs_grep shell) + @jailed_tools ~w(skill_file) + # Takes a path relative to a staged skill tree and refuses an absolute one or `..` itself; called + # with the file routes all the same. + @relative_tools ~w(skill_manage) + + setup do + base = Path.join(System.tmp_dir!(), "s135c-#{System.unique_integer([:positive])}") + xdg = Path.join(base, "xdg") + secrets = Path.join(base, "secrets") + root = Path.join(base, "root") + Enum.each([xdg, secrets, root], &File.mkdir_p!/1) + + saved_env = Map.new(@env, &{&1, System.get_env(&1)}) + saved_fs = Application.get_env(:trinity, :fs, []) + System.put_env("XDG_DATA_HOME", xdg) + System.put_env("TRINITY_SECRETS_DIR", secrets) + Application.put_env(:trinity, :fs, Keyword.put(saved_fs, :roots, [root])) + + session = Trinity.Factory.session!() + {:ok, _} = Trinity.Sessions.set_project_root(session.id, root) + + on_exit(fn -> + Enum.each(saved_env, fn + {k, nil} -> System.delete_env(k) + {k, v} -> System.put_env(k, v) + end) + + Application.put_env(:trinity, :fs, saved_fs) + Receipts.stop_writer(Receipts.session_scope(session.id)) + File.rm_rf!(base) + end) + + plant_release_layout!(Trinity.Paths.data_dir(), secrets) + ctx = %Context{session_id: session.id, caller: session.id, cwd: root} + {:ok, root: root, secrets: secrets, ctx: ctx} + end + + # What a release keeps, where this tree keeps it: the keys wherever `Trinity.Paths.keys_dir/0` + # puts them, and every other secret under the secrets directory, the databases and settings in + # the data directory. + defp plant_release_layout!(data, secrets) do + keys = Trinity.Paths.keys_dir() + + files = [ + Path.join(keys, "receipts-ed25519.key"), + Path.join(keys, "registry.json"), + Path.join(keys, "mcp-state.key"), + Path.join(secrets, "mcp-server-token"), + Path.join([secrets, "oauth", "resource.json"]), + Path.join(data, "trinity.db"), + Path.join(data, "trinity.db-wal"), + Path.join(data, "receipts.db"), + Path.join(data, "settings.json"), + Path.join([data, "backups", "abc", "20261008T000000Z-1"]) + ] + + for f <- files do + File.mkdir_p!(Path.dirname(f)) + File.write!(f, "SENTINEL-#{System.unique_integer([:positive])}-END\n") + end + end + + # The population: every regular file under the data directory and the secrets directory. + defp walk(dir) do + dir + |> Path.join("**") + |> Path.wildcard(match_dot: true) + |> Enum.filter(&match?({:ok, %{type: :regular}}, File.lstat(&1))) + end + + defp run(ctx, name, args, n \\ 0) do + call = %{id: "k#{n}-#{System.unique_integer([:positive])}", name: name, args: args} + + case Effects.Runner.run(call, ctx) do + {:error, {:approval_required, id}, _} when n < 2 -> + {:ok, _} = Permissions.decide_request(id, :once) + run(ctx, name, args, n + 1) + + other -> + other + end + end + + defp dotdot(root, target) do + ups = root |> Path.split() |> tl() |> Enum.map_join("/", fn _ -> ".." end) + ups <> target + end + + defp open_fd(path) do + {:ok, io} = File.open(path, [:read]) + on_exit(fn -> File.close(io) end) + + fd = + "/proc/self/fd" + |> File.ls!() + |> Enum.find(fn n -> match?({:ok, ^path}, File.read_link("/proc/self/fd/" <> n)) end) + + fd && "/proc/self/fd/" <> fd + end + + defp file_routes(root, file, i) do + sym = Path.join(root, "census-sym-#{i}.txt") + hard = Path.join(root, "census-hard-#{i}.txt") + File.ln_s!(file, sym) + :ok = File.ln(file, hard) + + [direct: file, dotdot: dotdot(root, file), symlink: sym, hardlink: hard, proc: open_fd(file)] + |> Enum.reject(fn {_, p} -> is_nil(p) end) + end + + # `shell` is called with its working directory as the path and `cat ./*` as the command. The root + # holding links is left out for it alone: there the command, not the path, names the file, which + # is the residual the moduledoc states. + defp dir_routes(tool, root, file, i) do + dir = Path.dirname(file) + sym = Path.join(root, "census-dir-#{i}") + File.ln_s!(dir, sym) + routes = [direct: dir, dotdot: dotdot(root, dir), symlink: sym] + if tool == "shell", do: routes, else: routes ++ [root_with_links: root] + end + + defp args("fs_read", p, _), do: %{"path" => p} + defp args("fs_write", p, _), do: %{"path" => p, "content" => "overwritten\n"} + defp args("fs_edit", p, bytes), do: %{"path" => p, "search" => bytes, "replace" => "edited"} + defp args("learn", p, _), do: %{"file" => p} + + defp args("skill_manage", p, _), + do: %{ + "action" => "write_file", + "name" => "census", + "rationale" => "census", + "path" => p, + "content" => "x" + } + + defp args("fs_list", p, _), do: %{"path" => p} + defp args("fs_glob", p, _), do: %{"pattern" => "**/*", "path" => p} + defp args("fs_grep", p, _), do: %{"pattern" => "SENTINEL", "path" => p} + defp args("shell", p, _), do: %{"command" => "cat ./* 2>/dev/null; true", "cwd" => p} + + defp reached?(outcome, file, bytes, stat) do + content = + case outcome do + {:ok, %{content: c}, _} -> c + _ -> "" + end + + after_stat = File.lstat(file) + + cond do + String.contains?(content, String.trim(bytes)) -> :content + File.read(file) != {:ok, bytes} -> :changed + inode(after_stat) != inode(stat) -> :replaced + true -> nil + end + end + + defp restore!(file, bytes) do + File.rm(file) + File.write!(file, bytes) + end + + defp inode({:ok, %{inode: i, major_device: d}}), do: {d, i} + defp inode(_), do: nil + + # A project skill under the root, for `skill_file`'s jail. + defp skill_dir!(root) do + dir = Path.join([root, ".trinity", "skills", "census"]) + File.mkdir_p!(dir) + + File.write!(Path.join(dir, "SKILL.md"), """ + --- + name: census + description: A skill the census plants links in. Use never. + --- + + # Census + """) + + dir + end + + test "the path-taking tools in the registry are the ones this census calls" do + # The suite's own fixtures (`Trinity.TestTools.*`, test/support/tools) are not in the tree that + # ships: `write_note` pretends to write and touches nothing. + taking = + for %{name: name, module: module} = entry <- Trinity.Tools.list(), + not String.starts_with?(inspect(module), "Trinity.TestTools."), + props = get_in(Trinity.Tools.Registry.schema(entry), ["properties"]) || %{}, + Enum.any?(~w(path file cwd), &Map.has_key?(props, &1)), + do: name + + assert Enum.sort(taking) == + Enum.sort(@file_tools ++ @dir_tools ++ @jailed_tools ++ @relative_tools) + end + + test "no file under the data directory or the secrets directory is reachable", %{ + root: root, + secrets: secrets, + ctx: ctx + } do + population = walk(Trinity.Paths.data_dir()) ++ walk(secrets) + assert length(population) >= 10, "the planted layout is the floor: #{inspect(population)}" + skill = skill_dir!(root) + + breaches = + population + |> Enum.with_index() + |> Enum.flat_map(fn {file, i} -> + bytes = File.read!(file) + + # Each call is judged on its own: the file is put back after one that changed it, so a + # write that got through is one breach and not every later route's. + check = fn tool, route, p, args -> + stat = File.lstat(file) + outcome = run(ctx, tool, args) + how = reached?(outcome, file, bytes, stat) + if how in [:changed, :replaced], do: restore!(file, bytes) + how && %{file: file, tool: tool, route: route, path: p, how: how} + end + + file_calls = + for tool <- @file_tools ++ @relative_tools, + {route, p} <- file_routes(root, file, "#{i}-#{tool}"), + do: check.(tool, route, p, args(tool, p, String.trim(bytes))) + + dir_calls = + for tool <- @dir_tools, + {route, p} <- dir_routes(tool, root, file, "#{i}-#{tool}"), + do: check.(tool, route, p, args(tool, p, bytes)) + + File.ln_s!(file, Path.join(skill, "sym-#{i}.md")) + :ok = File.ln(file, Path.join(skill, "hard-#{i}.md")) + + skill_calls = + for {route, rel} <- [symlink: "sym-#{i}.md", hardlink: "hard-#{i}.md"], + do: check.("skill_file", route, rel, %{"name" => "census", "path" => rel}) + + Enum.reject(file_calls ++ dir_calls ++ skill_calls, &is_nil/1) + end) + + pending = Application.get_env(:trinity, :skills, [])[:pending_dir] + + staged = + for f <- walk(pending), + s <- population, + String.contains?(File.read!(f), "SENTINEL"), + do: {f, s} + + assert breaches == [], + "#{length(breaches)} routes reached a protected file:\n" <> + Enum.map_join(Enum.take(breaches, 40), "\n", &inspect/1) + + assert staged == [] + end +end diff --git a/test/trinity/tools/fs/secrets_reach_test.exs b/test/trinity/tools/fs/secrets_reach_test.exs new file mode 100644 index 0000000..5944b82 --- /dev/null +++ b/test/trinity/tools/fs/secrets_reach_test.exs @@ -0,0 +1,212 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.FS.SecretsReachTest do + @moduledoc """ + Slice 135, AC1, AC3 and AC6: a tool the agent calls cannot read or write a secret or a database + file of Trinity's own, by any path. + + **The production layout, not the suite's.** `config/test.exs` points the receipt keys at + `tmp/test_keys`, outside the data directory, which hid the defect from every earlier test. Here + the data directory is moved to a temporary directory through `XDG_DATA_HOME` (which + `Trinity.Paths.data_dir/0` reads on Linux at each call) and the secrets directory through + `TRINITY_SECRETS_DIR`, and the key is planted where `Trinity.Paths.keys_dir/0` puts it in a + release. Every call goes through `Trinity.Effects.Runner.run/2`, the runner the Session uses, + with the default policy (`Policy.Layered`, `read: :allow`). + + **An ask is not a refusal.** A read that asks and is then approved returns the bytes, and an owner + approving `/proc/self/fd/7` cannot know it is the key. So every route here answers an approval + request with "allow once" and runs the call again, as the owner clicking the card would; the + assertion is on what comes back after that. + """ + use Trinity.DataCase, async: false + + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.Context + + @env ["XDG_DATA_HOME", "TRINITY_SECRETS_DIR"] + + setup do + base = Path.join(System.tmp_dir!(), "s135-#{System.unique_integer([:positive])}") + xdg = Path.join(base, "xdg") + secrets = Path.join(base, "secrets") + root = Path.join(base, "root") + Enum.each([xdg, secrets, root], &File.mkdir_p!/1) + + saved_env = Map.new(@env, &{&1, System.get_env(&1)}) + saved_fs = Application.get_env(:trinity, :fs, []) + System.put_env("XDG_DATA_HOME", xdg) + System.put_env("TRINITY_SECRETS_DIR", secrets) + Application.put_env(:trinity, :fs, Keyword.put(saved_fs, :roots, [root])) + + session = Trinity.Factory.session!() + scope = Receipts.session_scope(session.id) + + on_exit(fn -> + Enum.each(saved_env, fn + {k, nil} -> System.delete_env(k) + {k, v} -> System.put_env(k, v) + end) + + Application.put_env(:trinity, :fs, saved_fs) + Receipts.stop_writer(scope) + File.rm_rf!(base) + end) + + # Where a release keeps the receipt key: `Trinity.Paths.keys_dir/0` in force on this tree. + key = Path.join(Trinity.Paths.keys_dir(), "receipts-ed25519.key") + secret = "PRIVATE-#{System.unique_integer([:positive])}-SENTINEL" + File.write!(key, ~s({"private_b64":"#{secret}"})) + + ctx = %Context{session_id: session.id, caller: session.id, cwd: root} + {:ok, base: base, root: root, key: key, secret: secret, ctx: ctx, scope: scope} + end + + # One call through the runner in force; an approval request is answered "allow once" and the + # call is made again, as the owner would. + defp run(ctx, name, args, n \\ 0) do + call = %{id: "c#{n}-#{System.unique_integer([:positive])}", name: name, args: args} + + case Effects.Runner.run(call, ctx) do + {:error, {:approval_required, id}, _} when n < 2 -> + {:ok, _} = Permissions.decide_request(id, :once) + run(ctx, name, args, n + 1) + + other -> + other + end + end + + defp leaked?({:ok, %{content: content}, _}, secret), do: String.contains?(content, secret) + defp leaked?(_, _), do: false + + # The relative path from `root` to `target` that climbs to `/` through `..` and back down. + defp dotdot(root, target) do + ups = root |> Path.split() |> tl() |> Enum.map_join("/", fn _ -> ".." end) + ups <> target + end + + # The descriptor number this VM holds open on `path`, read from /proc/self/fd. + defp open_fd!(path) do + {:ok, io} = File.open(path, [:read]) + on_exit(fn -> File.close(io) end) + + "/proc/self/fd" + |> File.ls!() + |> Enum.find(fn n -> match?({:ok, ^path}, File.read_link("/proc/self/fd/" <> n)) end) + end + + describe "AC1: fs_read of the receipt key" do + test "by direct path", %{key: key, secret: secret, ctx: ctx} do + outcome = run(ctx, "fs_read", %{"path" => key}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:error, {:fs_denied, _}, _} = outcome + end + + test "via .. from a root", %{root: root, key: key, secret: secret, ctx: ctx} do + outcome = run(ctx, "fs_read", %{"path" => dotdot(root, key)}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:error, {:fs_denied, _}, _} = outcome + end + + test "via a symlink inside a root", %{root: root, key: key, secret: secret, ctx: ctx} do + File.ln_s!(key, Path.join(root, "notes.txt")) + outcome = run(ctx, "fs_read", %{"path" => "notes.txt"}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:error, {:fs_denied, _}, _} = outcome + end + + test "via a hard link inside a root on the same filesystem", %{ + root: root, + key: key, + secret: secret, + ctx: ctx + } do + :ok = File.ln(key, Path.join(root, "notes.txt")) + outcome = run(ctx, "fs_read", %{"path" => "notes.txt"}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:error, {:fs_denied, _}, _} = outcome + end + + test "via /proc/self/fd/N", %{key: key, secret: secret, ctx: ctx} do + fd = open_fd!(key) + assert fd, "no descriptor of this VM points at the key" + outcome = run(ctx, "fs_read", %{"path" => "/proc/self/fd/" <> fd}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:error, {:fs_denied, _}, _} = outcome + end + + test "fs_grep over a root holding a hard link to the key", %{ + root: root, + key: key, + secret: secret, + ctx: ctx + } do + :ok = File.ln(key, Path.join(root, "notes.txt")) + File.write!(Path.join(root, "plain.txt"), "PRIVATE plain line\n") + outcome = run(ctx, "fs_grep", %{"pattern" => "PRIVATE"}) + refute leaked?(outcome, secret), "the key came back: #{inspect(outcome)}" + assert {:ok, %{content: content}, _} = outcome + assert content =~ "plain.txt" + end + end + + describe "AC6: every fs decision is receipted" do + test "an allowed read and a denied one each carry the fields", %{ + root: root, + key: key, + ctx: ctx, + scope: scope + } do + File.write!(Path.join(root, "a.txt"), "hello\n") + assert {:ok, _, _} = run(ctx, "fs_read", %{"path" => "a.txt"}) + _ = run(ctx, "fs_read", %{"path" => key}) + + fs = + scope + |> Receipts.list(kind: "decision") + |> Enum.flat_map(&(JSON.decode!(&1.signed_payload)["decision"]["fs"] || [])) + + allowed = Enum.find(fs, &(&1["decision"] == "allow")) + denied = Enum.find(fs, &(&1["decision"] == "deny")) + assert allowed, "no allowed fs decision in #{inspect(fs)}" + assert denied, "no denied fs decision in #{inspect(fs)}" + + for entry <- [allowed, denied] do + for field <- + ~w(tool requested_path canonical_path dev ino root deny_rule nlink decision policy_hash principal turn_id), + do: assert(Map.has_key?(entry, field), "#{field} missing from #{inspect(entry)}") + end + + assert allowed["tool"] == "fs_read" + assert allowed["requested_path"] == "a.txt" + assert allowed["canonical_path"] == Path.join(root, "a.txt") + assert allowed["root"] == root + assert is_integer(allowed["ino"]) and allowed["nlink"] == 1 + assert denied["requested_path"] == key + assert denied["root"] == nil + assert is_binary(denied["deny_rule"]) + assert denied["policy_hash"] == Trinity.CorePolicy.hash() + end + + test "a refusal at open, inside a directory a grep walked, rides on the query receipt", %{ + root: root, + key: key, + ctx: ctx, + scope: scope + } do + :ok = File.ln(key, Path.join(root, "notes.txt")) + assert {:ok, _, _} = run(ctx, "fs_grep", %{"pattern" => "PRIVATE"}) + + [query] = Receipts.list(scope, kind: "query") + fs = JSON.decode!(query.signed_payload)["decision"]["fs"] + # The key is inventoried, so the inode check names it before the link count would. + assert [%{"deny_rule" => "protected_inode", "nlink" => 2, "decision" => "deny"} = entry] = + fs + + assert entry["canonical_path"] == Path.join(root, "notes.txt") + assert entry["tool"] == "fs_grep" + end + end +end diff --git a/test/trinity/tools/web/fetch_taint_test.exs b/test/trinity/tools/web/fetch_taint_test.exs new file mode 100644 index 0000000..5f3f9cc --- /dev/null +++ b/test/trinity/tools/web/fetch_taint_test.exs @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.Web.FetchTaintTest do + @moduledoc """ + Slice 135, AC5 (the first half): `web_fetch` asks once the session has read a file from a path + tagged sensitive, which is a path outside the roots (slice 135 NOTES, D2). Through + `Trinity.Effects.Runner.run/2`, with the default policy (`network: :allow`), against + `Trinity.FakeWeb` (no socket). + """ + use Trinity.DataCase, async: false + + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.Context + + setup do + base = Path.join(System.tmp_dir!(), "s135w-#{System.unique_integer([:positive])}") + root = Path.join(base, "root") + File.mkdir_p!(root) + outside = Path.join(base, "outside.txt") + File.write!(outside, "a line from outside the roots\n") + File.write!(Path.join(root, "inside.txt"), "a line from inside\n") + + saved_fs = Application.get_env(:trinity, :fs, []) + saved_web = Application.get_env(:trinity, :web, []) + Application.put_env(:trinity, :fs, Keyword.put(saved_fs, :roots, [root])) + + Application.put_env( + :trinity, + :web, + Keyword.put(saved_web, :req_options, plug: Trinity.FakeWeb) + ) + + session = Trinity.Factory.session!() + + on_exit(fn -> + Application.put_env(:trinity, :fs, saved_fs) + Application.put_env(:trinity, :web, saved_web) + Receipts.stop_writer(Receipts.session_scope(session.id)) + File.rm_rf!(base) + end) + + ctx = %Context{session_id: session.id, caller: session.id, cwd: root} + {:ok, ctx: ctx, outside: outside} + end + + defp call(ctx, name, args), + do: + Effects.Runner.run( + %{id: "w#{System.unique_integer([:positive])}", name: name, args: args}, + ctx + ) + + @page %{"url" => "http://example.test/text"} + + test "a session that read only inside the roots fetches without asking", %{ctx: ctx} do + assert {:ok, _, _} = call(ctx, "fs_read", %{"path" => "inside.txt"}) + assert {:ok, _, _} = call(ctx, "web_fetch", @page) + end + + test "after an approved read outside the roots, web_fetch asks", %{ctx: ctx, outside: outside} do + assert {:error, {:approval_required, id}, _} = call(ctx, "fs_read", %{"path" => outside}) + {:ok, _} = Permissions.decide_request(id, :once) + assert {:ok, %{content: content}, _} = call(ctx, "fs_read", %{"path" => outside}) + assert content =~ "outside the roots" + + assert {:error, {:approval_required, _}, _} = call(ctx, "web_fetch", @page) + end +end diff --git a/test/trinity_web/live/settings_live_test.exs b/test/trinity_web/live/settings_live_test.exs index d9eb580..de1edce 100644 --- a/test/trinity_web/live/settings_live_test.exs +++ b/test/trinity_web/live/settings_live_test.exs @@ -159,6 +159,17 @@ defmodule TrinityWeb.SettingsLiveTest do assert Settings.get(:fs_roots) == [] end + # Slice 135: the boot refuses a root that overlaps the data or secrets directory, so the page + # refuses to save one rather than leave a folder that stops the next start. + test "a folder that contains the secrets directory is refused with the reason", %{conn: conn} do + secrets = Trinity.Paths.ensure_secrets_dir() + parent = Path.dirname(secrets) + {:ok, view, _html} = live(conn, ~p"/settings") + html = view |> form("#root-form", %{"root" => parent}) |> render_submit() + assert html =~ "data or secrets folder" + assert Settings.get(:fs_roots) == [] + end + test "the gateways can be paused from here as from the tray", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/settings") view |> element("#gateways-pause") |> render_click()