diff --git a/.claude/rules/agent-claude-code.rule.md b/.claude/rules/agent-claude-code.rule.md new file mode 100644 index 00000000..98d76ba0 --- /dev/null +++ b/.claude/rules/agent-claude-code.rule.md @@ -0,0 +1,329 @@ +--- +paths: + - "src/usr/local/bin/claude.sh" + - "src/usr/local/lib/ai-tools/claude-prompt.lib.sh" + - "src/usr/local/lib/ai-tools/claude-endpoint.lib.sh" + - "src/usr/local/lib/ai-tools/agents.d/claude-code.conf" + - "src/usr/local/lib/ai-tools/session-env.d/claude-code.env.sh" +--- + +# The claude-code agent + +Everything specific to Claude Code as a provider: what its manifest declares, how its binary is +resolved and labelled, its launch wrapper, and the two operator-configurable inputs it carries (a +custom system prompt and a custom API endpoint). The **provider seam** these plug into — manifests, +fail-closed enablement, the `session-env.d` contract — is [providers](providers.rule.md); the +**agent-agnostic** launch contract is [launch](launch.rule.md); its Claude Code `settings.json` is +[claude-settings](claude-settings.rule.md), which stays a rule of its own because it is scoped to a +different file set and a different question (what the harness may run), not because the two domains +are unrelated. + +`ai-tools-agents-claude-code-restricted` ships the wrapper, the manifest, the session-env fragment, +the two resolver libraries, and the agent's config directory. It adds no sudoers rule: it inherits +the single `%ai-ops` grant on the shared shim. + +## What the manifest declares + +`/usr/local/lib/ai-tools/agents.d/claude-code.conf`, `644 root:root`, parsed by +`providers.lib.sh`: + +| field | value | read by | +|---|---|---| +| `npm_package` | `@anthropic-ai/claude-code` | `ai-tools-bootstrap`, `nvm-update` — what to install | +| `launcher` | `claude` | `ai-tools-launcher-symlink` (which link it may write), `ai-tools-run` (which executables may start a session) | +| `display_name` | `Claude Code` | the launch banner, the unit description | +| `handback` | `hooks` | `ai-tools-run` — this agent converges the tree itself, so the shim adds no session-end sweep | +| `config_dir` | `.claude` | the control-plane mode/label/seeding set, and `→ ai_tools_home_t` | +| `skills_dir` / `subagents_dir` | `skills` / `agents` | where shared assets are symlinked in ([shipped-assets](shipped-assets.rule.md)) | +| `entrypoint_fcontext` | a regex ending `…/@anthropic-ai/claude-code/bin/claude\.exe` | `ai-tools-relabel-agent` — which file takes `ai_tools_exec_t` | +| `default_enable` | `yes` | the baseline set when `operator.conf` names none | + +`handback=hooks` is the only literal that switches the shim's sweep off; anything else, including an +absent key, gets the sweep. `config_dir` must equal the directory the session-env fragment pins as +`CLAUDE_CONFIG_DIR`, since the manifest decides the label and the fragment decides where the agent +writes. + +## The resolution chain is three links, and each consumer takes a different one + +``` +/opt/ai-tools/bin/claude [1] stable launcher symlink + └─ readlink, one hop ───────────────────────────────────────────────────────────────── +/opt/ai-tools/.nvm/versions/node/vX.Y.Z/bin/claude [2] versioned npm bin symlink + └─ resolved by the kernel at execve ────────────────────────────────────────────────── +…/vX.Y.Z/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe [3] the real executable +``` + +Which link a component addresses is a deliberate choice per component, not an inconsistency: + +| component | addresses | why that link | +|---|---|---| +| `ai-tools-launcher-symlink` | writes **[1]**, validated as **[2]** | the only writable control-plane link; `/opt/ai-tools/bin` is `0551`, so the sandbox reaches it only through this root helper | +| `claude.sh` | reads **[1]**, one `readlink` to **[2]** | full resolution would traverse the `700` package directory as the *operator*, an EACCES that aborts the wrapper silently under `set -e` | +| `ai-tools-run` | re-validates **[2]**, execs it | **[2]** is the allowlist shape: an exact `MAJOR.MINOR.PATCH` directory plus one path component an enabled manifest claims | +| the SELinux transition | fires on **[3]** | `execve` resolves symlinks; the label that matters is the one on the inode actually executed | + +The one-hop constraint in `claude.sh` exists solely to avoid that EACCES. It carries no coupling to +sudoers matching, which targets the fixed path `/opt/ai-tools/bin/ai-tools-run`. + +**[3] is a hardlink, not the package's only name for the binary.** The npm package declares the +per-platform binaries as `optionalDependencies` — one per platform/arch/libc +(`@anthropic-ai/claude-code--[-musl]`) — so the executable ships in a nested package +(`…/claude-code/node_modules/@anthropic-ai/claude-code--/claude`), and the +`postinstall` script (`install.cjs`) hardlinks the one for this host to `bin/claude.exe`. Three +consequences: + +- The declared `entrypoint_fcontext` matches only because that hardlink exists. It names the + arch-independent `bin/claude.exe`, so it is not itself arch-specific — but it describes a + *convenience name* the package creates, not where the payload ships. +- A recursive `restorecon` over the toolchain visits **two names for one inode**, and the label the + walk leaves is whichever name it reached last — so a sweep wide enough to include the nested + package can un-label the entrypoint. The relabel helper itself never does: it `restorecon`s only + the paths the declared pattern matches. +- Any rule written against the nested path would have to span the arch variants, which a single + anchored literal head cannot. This is why the reconciliation below **resolves** the entrypoint + rather than declaring a second pattern for it. + +## Entrypoint labelling: applied from the declaration, checked on the resolved inode + +Because the transition fires on **[3]**, that inode must carry `ai_tools_exec_t`, and a freshly +installed one is born the default type — only `restorecon` applies the label. Three components care, +and they reach **[3]** two different ways: + +- **`ai-tools-run`'s fail-closed preflight** resolves it. It `realpath -e`s `AI_TOOLS_AGENT_EXEC` + (succeeding, since it runs as the sandbox account, which owns the `700` package directory) and + reads `matchpathcon` and `stat -c '%C'` on the **resolved** path. The verdict is the pure + `ai_tools_confinement_verdict` (see [confinement](confinement.rule.md)). +- **`ai-tools-launcher-symlink`'s idempotency guard** resolves it the same way, so a repoint that + would drive a needed relabel always fires while a daily no-op run stops churning the link. +- **`ai-tools-relabel-agent` pattern-matches it.** It registers the manifest's + `entrypoint_fcontext` as a local `semanage fcontext` rule and relabels the files a + `find -regex` over that pattern returns (`relabel.lib.sh`). + +The two strategies agree only while the pattern describes where the package actually puts its +executable. The guarantee that does **not** depend on that agreement is the important one: a label +the transition would not honour is caught by the preflight on the resolved inode, so the failure is +a refused launch, never an unconfined session. + +### The relabel reconciles the two, and fails when they disagree + +`ai-tools-relabel-agent` closes the gap between them without changing which side applies the label. +The declared pattern stays the **apply** mechanism, because a `semanage fcontext` rule is what makes +a type survive a later `restorecon`; resolution is the **check**, so the helper's exit status answers +the question the operator actually asked — will the next launch be confined? + +For each enabled agent it resolves `/opt/ai-tools/bin/` the same way the preflight does +(`realpath -e`, as root), applies the declared rule, and reconciles the two through the pure +`ai_tools_entrypoint_reconcile_verdict` (`relabel.lib.sh`): + +| state | verdict | outcome | +|---|---|---| +| the launcher resolves to a file the pattern covers | `ok` | labelled and verified | +| nothing resolves and the pattern matched nothing | `none` | the agent is not provisioned; nothing to label | +| the launcher **resolves** to a file the pattern does **not** cover | `stale` | **reported and the run exits non-zero** | + +`stale` is the case a repackaged upstream produces — the pattern matches nothing while the chain +still resolves, so the preflight's verdict is `unverifiable` and the launch is refused. The relabel +names that cause and says the fix is upstream of it (update the agent package, whose manifest has +stopped describing where its own executable installs), rather than reporting success and sending the +operator back around a loop no rerun can clear. + +**It does not label the resolved path to compensate.** The set of files that ever take +`ai_tools_exec_t` — the exec entrypoint of the confined domain — stays exactly the set the +root-owned manifests declare. The resolved path is reached through an npm symlink the sandbox +account owns, and a literal rule for it would pin the Node version, so labelling from resolution +would both widen the set on agent-influenced input and accumulate a stale rule per Node bump. The +resolved path is only ever *compared* and *reported*, and it is carried into a status line only +while it passes an allowlist (`_ai_tools_entrypoint_path_reportable`: absolute, `..`-free, and no +whitespace or control byte that could split the line or reach the operator's terminal). + +## The wrapper (`claude.sh`) + +`/usr/local/bin/claude`, `root:root 0755`, rpm-owned, running as the invoking operator. +`path-dedup.sh` ranks `/usr/local/bin` (Tier 1) above the nvm shims in operator dotfiles, so this +shadows any nvm-managed `claude` on an operator's PATH ([launch](launch.rule.md)). + +It gates in this order, each step refusing before the next can matter: + +1. **Required libraries**, fail-closed: `msg.lib.sh` (it carries the yes/no decisions), + `safe-paths.lib.sh` (the protected-path guard), and `conf.lib.sh` (without it every allowlist + line parses as no entry, which refuses every launch — indistinguishable from "you have no + projects" unless the missing component is named). `claude-prompt.lib.sh` loads best-effort; its + fail-closed decision is made where the configuration is known (below). +2. **Operator gate** — `ai-ops` membership, read from `id -nG` (this shell's live credential set, + the set `sudo` enforces against). The refusal distinguishes three cases because the fix differs: + the sandbox account (which must never be an operator), an operator whose shell predates the grant + (re-login), and a genuine non-operator. +3. **Binary resolution** — `-L` on the stable link (not `-e`, which would dereference into the + unreadable package directory), one `readlink`, then string-only validation that the target is an + absolute, `..`-free path matching the versioned shape. +4. **Print-and-exit short-circuit** — `--version`/`-v`/`--help`/`-h` as the *sole* argument skips + every CWD gate and runs with the sandbox home as `WorkingDirectory`. Such a run touches no + working tree, so no project grant is implied. +5. **Protected-paths backstop**, then the **allowlist** (exclusions first, since `!` overrides + allows), both on the `realpath`-canonicalized CWD. +6. **Claim guard** — three gaps detected read-only: group/mode (fatal — the session starts but + `posix_spawn` fails `EACCES` on every child), SELinux label (fatal under enforcing), and git + `safe.directory` (non-fatal). The wrapper never performs a `chgrp` or a relabel itself; it + detects, offers, and delegates to `ai-tools --project-claim` ([cli](cli.rule.md)). +7. **Prompt resolution** and a **best-effort service-health warning** (the relabel watcher; the + handback socket is the shim's to report — see [launch](launch.rule.md)). +8. `exec sudo -u ai-tools -g ai-tools -- /opt/ai-tools/bin/ai-tools-run`, carrying exactly + `AI_TOOLS_AGENT_EXEC` and `AI_TOOLS_PROJECT_DIR` through `env_keep`. **No agent identity crosses + sudo**; the shim derives it from the launcher name in the path. + +## Custom system prompt (`claude-prompt.lib.sh`) + +Resolved from `operator.conf` and prepended to `"$@"` just before the final `exec`, as +`--append-system-prompt-file ` (mode `append`, the default — keeps Claude Code's own tool-use +and safety guidance) or `--system-prompt-file ` (mode `replace`). + +- **`CLAUDE_SYSTEM_PROMPT_FILE` must resolve under `/etc/ai-tools/prompts/`** — the one location the + confined `ai_tools_t` domain is granted read on (`etc_t`, via `files_read_etc_files`). A + root-owned file elsewhere passes the DAC trust check yet is unreadable to the session, so a + mis-set path would become a failed launch rather than a refused one. The file, its directory, the + prompts base, and `operator.conf` each pass `ai_tools_conf_is_trusted`, and the file must be + readable text. +- Claude Code reads the file **verbatim** — not processed, not comment-stripped — so it holds prompt + text only. The shipped default is therefore **empty**, `0640 root:SANDBOX_GROUP` (a custom prompt + may be proprietary, so not world-readable; the wrapper only `stat`s it as the operator, and the + confined binary reads it as the sandbox account). Uncommenting the pointer alone changes nothing. +- **`replace` sets the request's `system` field, not the whole model context.** It does not remove + the tool definitions or the `CLAUDE.md` context Claude Code injects as `` blocks; + those ride in separate request fields. "Only the file reaches the model" is not reachable through + this flag — shape the final request at the proxy instead. +- **Fail closed only once configured.** An unconfigured host launches with the default prompt; a + configured-but-unhonourable one (missing, untrusted, outside the base, non-text, unknown mode, or + a resolver library that will not load) **refuses the launch**. This is a distinct tier from the + confinement libraries, which fail *every* launch closed. +- **A per-invocation `--{,append-}system-prompt{,-file}` flag suppresses the configured default + entirely**, scanned in `"$@"`, so the explicit flag wins without depending on Claude Code's own + flag-precedence behaviour. + +## Custom API endpoint (`claude-endpoint.lib.sh`) + +The session-env counterpart, resolved **sandbox-side in the fragment** rather than in the wrapper. +`operator.conf` `CLAUDE_BASE_URL_FILE` points at a dedicated file under `/etc/ai-tools/endpoints/`, +from which the resolver reads exactly four recognised keys — `ANTHROPIC_BASE_URL` (required, a +validated http(s) URL), `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, +`ANTHROPIC_DEFAULT_HAIKU_MODEL` — and turns each valid one into a `--setenv=` entry. An arbitrary key +is never read, so the file cannot inject unrecognised environment. + +- **A dedicated `640 root:ai-tools` file, not `operator.conf`.** `ANTHROPIC_AUTH_TOKEN` is a + credential and `operator.conf` is `644`; the endpoint file is readable by root and the sandbox + account (which needs the token) but not by the world and not by the operator. That the operator + cannot read it is why validation happens sandbox-side. +- **The token is imported by name.** A valid token is `export`ed and forwarded as the name-only + `--setenv=ANTHROPIC_AUTH_TOKEN`, so its value never lands on a command line. This `export` is one + of the two sanctioned fragment exceptions ([providers](providers.rule.md)). +- **Fail closed on a present-but-invalid option** — malformed URL, a model label with whitespace, a + token with control bytes, options with no anchoring `ANTHROPIC_BASE_URL`, or a missing/untrusted + pointer file — the fragment `exit`s the launch, which is clean because it is sourced before the + unit is created and before the sweep trap is installed. A fully inert file (the shipped default) + applies nothing; a non-local endpoint with no token warns but still applies. +- **Precedence.** These are process environment variables, so a Claude Code settings `env` block + (authoritatively `/etc/claude-code/managed-settings.json`) setting the same name wins. The shipped + settings set no `ANTHROPIC_*` key, so the endpoint file governs by default and + `managed-settings.json` stays the un-overridable host lock. + +**Boundary — this is operator configuration, not an agent-confinement control.** The enforced +property is that the root-owned inputs are not agent-writable, so the sandbox cannot change what any +session launches with, plant a file the resolver would honour, or swap the token other sessions use. +It is not a claim that a running session cannot alter its own environment: a session may set +`ANTHROPIC_BASE_URL` for itself or a child, but that does not repoint the already-started client +(which reads the variable at startup, and a Bash-tool child's `export` never reaches the parent). +Outbound traffic is governed by network policy, not this variable. + +## Session environment pins + +`session-env.d/claude-code.env.sh` is sourced **last**, after every enabled integration, so its pins +are authoritative. Each exists because the sandbox home is deliberately not agent-writable at its +root: + +- **`CLAUDE_CONFIG_DIR=/opt/ai-tools/.claude`** — Claude Code saves `.claude.json` (login, + onboarding, per-project trust) by writing a temp file beside it and renaming, which needs write on + the *containing* directory. `.claude` is `3770`, setgid+sticky: the rename works, and the sticky + bit keeps control files the agent does not own undeletable. Unpinned it would resolve under the + `2751` home root, where the rename is refused and every session demands a fresh login. +- **`NODE_COMPILE_CACHE=/opt/ai-tools/.cache/node-compile-cache`** — Node's default is under + `os.tmpdir()` on the shared host `/tmp`, where entries left by an earlier unconfined run carry + `user_tmp_t`, a type the session's domain has no rule for; Node's own `open()` of its cache is + then denied and the session dies at startup. +- **`DISABLE_AUTOUPDATER=1`** — the Node program tree is read-only to the session, so an in-session + self-update cannot write the npm prefix. The `nvm-update` timer maintains the toolchain out of + band ([updater](updater.rule.md)), which also keeps the toolset stable for the whole session. + +## Distribution channel + +The agent is provisioned as an **npm package on the sandbox's Node toolchain**: `npm install -g` at +bootstrap and on each updater run, its launcher symlinked into the locked control-plane `bin`, and +its executable accepted only under `/opt/ai-tools/.nvm/versions/node//bin/`. That assumption +lives in exactly two places — provisioning and exec validation — and nowhere else in the seam +([providers](providers.rule.md)). + +Two properties of the current channel shape the design: + +- **The executable is a compiled native binary, not a JavaScript entrypoint.** `claude.exe` is + reached through two symlinks and executed directly; the session does not run it through `node`. + The same binary is what every distribution channel delivers — npm is a distribution channel for it + rather than a different build — so the channel decides provenance, placement, and update cadence, + not what runs. +- **The sandbox account owns its own entrypoint.** The nvm tree is `SANDBOX_USER`-owned, so unlike + every other control-plane file the agent binary is agent-writable. That is bounded rather than + open: `ai-tools-run` accepts only a manifest-claimed launcher at a semver path, the updater + verifies npm registry signatures before activating a tree, and the SELinux preflight fails closed + on a label the agent cannot grant itself. A root-owned, agent-read-only exec root would remove the + bound rather than tighten it, which is the direction the native-packaging plan takes. + +## Quirks + +- **`ai-tools --relabel` reports `stale`, not `not installed`, for a moved entrypoint.** The relabel + applies a declared pattern but post-conditions on the resolved inode, so its exit status agrees + with the launch preflight's verdict. A `none` line therefore means the agent genuinely is not + provisioned. +- **A `.rpmnew` for `settings.json` leaves a newly shipped hook installed but uninvoked.** It is + `%config(noreplace)` for the same reason `operator.conf` is — a dormant option is recoverable, a + silently reverted setting is not (see [providers](providers.rule.md), + [claude-settings](claude-settings.rule.md)). +- **The wrapper's `-L` test is not interchangeable with `-e`.** `-e` dereferences the whole chain + into the `700` package directory, so a perfectly valid link reports "not found" to the operator. + +## Deferred + +**A native/`dnf` runtime alongside the npm one, as an opt-in.** The npm package is deprecated +upstream while a signed vendor `dnf` channel exists, installing `/usr/bin/claude` root-owned and +read-only to the agent. That closes the agent-writable-exec-root bound above and removes the +reinstall-re-mints-the-entrypoint race the updater works around. It needs the `runtime` field the +provider seam already names, an exact-path containment rule for a host-packaged binary +([providers](providers.rule.md)), and a packaging split. Not built. + +**npm is the default, deliberately, and the reason is not that npm is safer.** The two channels +trade one risk for another, and the trades sit on opposite sides of this project's threat model: + +- **npm's cost is in-model, bounded, and DAC-only.** The exec root is owned by the sandbox account, + so on a host running **without** the SELinux policy a compromised session can modify `claude.exe` + in place. Writing does not change the inode's SELinux type, so the launch preflight still passes, + and `npm install -g` does not reinstall an unchanged version — the tamper persists across sessions + and across operators. Confinement, the allowlist, and the handback still bound what it reaches. + This is exactly the adversary the model defends against. **With the policy loaded the vector is + closed outright** (see + [the type layout](confinement.rule.md#the-toolchain-is-read-only-to-the-confined-domain)), so the + gap is real on the DAC-only deployment the weak dependency permits, not on an enforcing one — and + it is now detected on both (see [updater](updater.rule.md)). +- **native's cost is out-of-model and unbounded.** It puts a second, *real* `claude` on every + operator's PATH. Running `/usr/bin/claude` starts an **unconfined session as the operator**, with + their own credentials and home and none of this machinery — the outcome the project exists to + prevent, reachable today only by operator error. `/usr/local/bin` precedes `/usr/bin` in the + default PATH and `path-dedup.sh` ranks it Tier 1, so the wrapper wins; but `sudo`'s `secure_path` + commonly omits `/usr/local/bin`, and an IDE plugin resolving an absolute path does too. + +So the native hazard cannot be *prevented* (rpm owns that path), only *detected*, while the npm +hazard is one confinement already contains. Defaulting to npm keeps existing hosts unchanged and +makes the switch an informed operator decision — the same posture as every other trust decision +here. A host that adopts native gets the PATH assertion as a precondition, not an afterthought. + +**The signed release manifest closes npm's side of that trade without changing channel.** Upstream +publishes a per-release `manifest.json` of SHA256 checksums for every platform binary, GPG-signed +with a published fingerprint, independent of the delivery channel. Verifying the installed +`claude.exe` against it would catch the in-place tamper described above — the one property native +was buying — while the entrypoint stays where it is. Named here as the cheaper alternative to a +channel move; not built. diff --git a/.claude/rules/authoring.rule.md b/.claude/rules/authoring.rule.md index b42907ee..3f279618 100644 --- a/.claude/rules/authoring.rule.md +++ b/.claude/rules/authoring.rule.md @@ -33,6 +33,13 @@ one-file-per-rule mapping. - One file per component: `.rule.md`. The `.rule.md` suffix lets tooling and this guideline target every rule with the `*.rule.md` glob. +- **A rule covering one provider is named for its kind and its name**, matching the package + taxonomy (`ai-tools-agents-` / `ai-tools-integration-`): `agent-claude-code.rule.md` + for an agent, and the integration rules by their bare provider name. The generic seam + ([providers](providers.rule.md)) and the generic launch contract ([launch](launch.rule.md)) hold + what is true of *every* provider; a provider rule holds only what is true of that one, and each + side links the other. A fact that would have to be restated for the second provider belongs in + the seam, not in a provider rule. - **Avoid a stem that matches a secret pattern.** `ai-tools-chown` quarantines secret-named files the agent writes (see [secret-handling](secret-handling.rule.md)): a file whose basename matches `~/.config/ai-tools/secret-patterns` is chowned to diff --git a/.claude/rules/claude-settings.rule.md b/.claude/rules/claude-settings.rule.md index ece17685..eacc6c2e 100644 --- a/.claude/rules/claude-settings.rule.md +++ b/.claude/rules/claude-settings.rule.md @@ -9,9 +9,10 @@ paths: `settings.json` is the agent session's Claude Code configuration. It declares the ownership hooks (covered in [ownership-and-hooks](ownership-and-hooks.rule.md)), the token-saving filter hook on both `Bash` events (covered in [filters](filters.rule.md)), the -Bash-tool permission rules, a privacy `env` block, and the auto-mode default. This rule -covers the **permission rules** and how they couple to the SELinux policy, the **`env` -privacy default**, and the **`disableAutoMode`** default. The catalog of other Claude Code +Bash-tool permission rules, an `env` block, the auto-mode default, and the observability +defaults. This rule covers the **permission rules** and how they couple to the SELinux policy, +the **`env` block**, the **observability defaults**, and the **`disableAutoMode`** default. The +catalog of other Claude Code options an operator MAY add — and which are set elsewhere — is in [`docs/claude-options.md`](../../docs/claude-options.md). @@ -86,7 +87,32 @@ which is why the host-survey group below is denied rather than merely unlisted. ### Refused (`deny`) -Two groups with distinct criteria. +Three groups with distinct criteria. + +**Irreversible VCS operations** — these **succeed**, and what they take has no undo: history +rewritten, a published branch overwritten for everyone else holding it, uncommitted or untracked +work deleted from the tree. + +| Entry | What it destroys | +|---|---| +| `git push --force*` | The remote's history for every other clone. The pattern also covers `--force-with-lease`, which narrows the race but still overwrites. | +| `git push -f *` | The short spelling of the same. | +| `git reset --hard*` | The working tree and index, including changes never committed. | +| `git clean -f*` | Untracked files — the ones no commit and no reflog can bring back. | + +The criterion is **destruction with no undo**, so the refusal holds regardless of target: a +scratch branch and `main` are denied alike, because a deny rule matches a command string and +cannot tell them apart. These would prompt if merely unlisted (they are mutations, not the +auto-approved safe reads of the host-survey group), and a prompt is the wrong gate for them — +it approves a command string, while what the operator has to weigh is what is about to be lost. +Denied, the agent raises the operation in conversation, and the operator runs it where the +consequence lands. + +The group is deliberately narrow, and it is a gate rather than a boundary: the same destruction +is still reachable through a spelling the pattern does not match (`--force` placed after the +refspec, `git push origin +branch`, an `rm -rf` of the work tree), and matching those would take +a matcher over intent rather than over text. What it buys is that the **habitual** spellings — +the ones an agent reaches for without deliberating — cannot be taken silently. **Categorical dead-ends** — the core posture refuses these regardless of arguments or target, so a deny stops the agent spending a tool call, and emitting an AVC, on an @@ -133,18 +159,40 @@ enforcement plus DAC (see [confinement](confinement.rule.md)); a `deny` entry on the agent from attempting a denied action. Removing an entry re-exposes the attempt to the SELinux floor — it does not by itself grant the capability. -`tests/integration/hooks.sh` pins both deny groups at install time (the verify phase runs -it): a missing categorical entry fails; host-survey relaxations are reported by name and -pass, but a file with none of them (a kept pre-upgrade settings.json) fails; an entry in -both lists fails as drift. - -## `env` — the privacy default - -The top-level `env` block applies environment variables to every session. It ships one -entry: +`tests/integration/hooks.sh` pins all three deny groups at install time (the verify phase +runs it): a missing categorical or irreversible-VCS entry fails; host-survey relaxations are +reported by name and pass, but a file with none of them (a kept pre-upgrade settings.json) +fails; an entry in both lists fails as drift. The irreversible-VCS entries are pinned +strictly rather than reported, because the paths that preserve a host's tuning — the +keep-existing install and `%config(noreplace)` on upgrade — are also the paths by which a +settings.json predating them, or edited in the permission arrays it invites tuning of, +silently loses the gate. + +## The tool-call record is declared as its own matcher group + +`post-tool-hook.sh` appears twice under `PostToolUse`: argument-less on `Write|Edit` (record +then hand back) and as `post-tool-hook.sh record` on `Bash` (record only). One widened +`Write|Edit|Bash` matcher would express the same intent in a single group and **would not reach +an upgraded host**: `ai_tools_conf_merge_hook_declarations` keys on the *command string*, not on +the matcher, so a kept `settings.json` already declaring that command counts the group as +present and the widened matcher is never merged in. The `Bash` records would then be emitted on +a fresh install and silently nowhere else — precisely the failure the merge exists to prevent. +A distinct argument makes it a distinct command string, so the merge carries it like any other +newly shipped declaration. This is the same dispatch-on-`$1` shape `session-hook.sh` and +`filter-hook.sh` already use, and it is why the argument-less form must stay argument-less: +renaming it would leave the old declaration in place beside the new one and run the handback +twice per write. + +## `env` — the privacy and output defaults + +The top-level `env` block applies environment variables to every session. It ships two +entries: ```json -"env": { "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } +"env": { + "CLAUDE_CODE_MAX_OUTPUT_TOKENS": 131072, + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" +} ``` `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` opts the session out of all non-essential @@ -154,10 +202,33 @@ outbound traffic in one variable: it subsumes `DISABLE_TELEMETRY`, individually. The essential Anthropic API traffic the agent needs is unaffected, as is the WebFetch domain safety check (which has its own `skipWebFetchPreflight` opt-out, left on). -It lives here rather than in `ai-tools-run`'s allowlist because it is Claude Code product +`CLAUDE_CODE_MAX_OUTPUT_TOKENS=131072` sets the per-response output-token cap a session +requests. It shapes response length and cost, not authority — a capped and an uncapped session +may do exactly the same things. + +Both live here rather than in `ai-tools-run`'s allowlist because they are Claude Code product policy, not confinement structure — Claude Code's own config surface, beside the permission and hook declarations. Layering and override are under "Control-plane integrity" below. +## `showThinkingSummaries` and `verbose` — the observability defaults + +```json +"showThinkingSummaries": true, +"verbose": true +``` + +Both put more of a session in front of the operator watching it: `showThinkingSummaries` re-shows +the thinking blocks Claude Code hides by default, and `verbose` shows Bash and command output in +full rather than truncated. They cost terminal space and nothing else — the session's authority is +identical either way — and what they buy is that the operator confirming an action sees the +reasoning that produced it and the output it produced, which is the difference between approving a +command string and approving what the command did. + +They are the operator-side complement to `disableAutoMode` below: that key decides *whether* a +human is asked, these decide *how much* that human is shown. The catalog of the other UI and +behavior keys an operator MAY add is in +[`docs/claude-options.md`](../../docs/claude-options.md). + ## `disableAutoMode` — confirm-by-default ```json @@ -201,11 +272,12 @@ in the agent-writable project tree. The layers compose differently per setting: - The **deny rules** and **hook declarations** merge additively across every layer — a deny from any source wins over any allow, and project hooks add to rather than replace these — so a project layer cannot remove them. They hold for the whole session. -- The **`env` privacy default** and **`disableAutoMode`** are single-valued: a - higher-precedence project layer overrides them per key — control-plane defaults, not - locks. Neither is a containment boundary (telemetry is not one, and `disableAutoMode` - only removes confirmation prompts; the session's confinement is unchanged either way), so - a lock is unneeded. The one unoverridable layer, managed policy +- The **`env` block**, the **observability defaults**, and **`disableAutoMode`** are + single-valued: a higher-precedence project layer overrides them per key — control-plane + defaults, not locks. None is a containment boundary (telemetry and an output cap are not + one, the observability keys only change how much is displayed, and `disableAutoMode` only + removes confirmation prompts; the session's confinement is unchanged either way), so a lock + is unneeded. The one unoverridable layer, managed policy (`/etc/claude-code/managed-settings.json`), is machine-wide — it applies to every Claude Code user on the host — so the sandbox does not ship it. diff --git a/.claude/rules/cli.rule.md b/.claude/rules/cli.rule.md index 408184db..75be6ed1 100644 --- a/.claude/rules/cli.rule.md +++ b/.claude/rules/cli.rule.md @@ -6,6 +6,7 @@ paths: - "src/usr/local/libexec/ai-tools/ai-tools-safedir.sh" - "src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh" - "src/usr/local/libexec/ai-tools/ai-tools-relabel.sh" + - "src/usr/local/libexec/ai-tools/ai-tools-stop.sh" - "src/usr/local/lib/ai-tools/relabel.lib.sh" - "src/usr/local/lib/ai-tools/services.lib.sh" --- @@ -22,15 +23,21 @@ and as the sandbox account (the agent must not manage its own allowlist). ## Bootstrap preflight -A single `require_bootstrap` gate runs **before dispatch**: it keys on the `/opt/ai-tools/bin/claude` -launcher symlink — bootstrap's last load-bearing artifact, written after the account, -Node, and the agent package all succeed — so its presence means provisioning finished, and -its absence fails the CLI fast with the provisioning hint rather than mid-operation in a -root helper. This is the same symlink the launch wrapper gates on (`claude.sh`'s -`CLAUDE_LINK`), so both entry points share one definition of "provisioned". Every command -is behind the gate, `--version` included — an unfinished install reports nothing, fail-closed. The -one exception is `--status`, the diagnostic: it bypasses the gate and reports the unprovisioned -state itself, since a health check must run precisely when provisioning may have failed. +A single `require_bootstrap` gate runs **before dispatch**: it keys on a launcher symlink under +`/opt/ai-tools/bin` — bootstrap's last load-bearing artifact, written after the account, Node, and +the agent package all succeed — so its presence means provisioning finished, and its absence fails +the CLI fast with the provisioning hint rather than mid-operation in a root helper. It is the same +symlink the launch wrapper gates on, so both entry points share one definition of "provisioned". +Every command is behind the gate, `--version` included — an unfinished install reports nothing, +fail-closed. The one exception is `--status`, the diagnostic: it bypasses the gate and reports the +unprovisioned state itself, since a health check must run precisely when provisioning may have +failed. + +**The gate names one agent.** `CLAUDE_LINK` is the literal `/opt/ai-tools/bin/claude`, so a host +that enables a different agent and disables `claude-code` has a provisioned toolchain the CLI +refuses to act on. This is the one place the otherwise agent-agnostic CLI is coupled to a specific +provider; the sentinel it needs is a launcher symlink for *any* enabled agent, which +`ai_tools_enabled_agents` already resolves ([providers](providers.rule.md)). ## Operator preflight @@ -48,6 +55,9 @@ wrapper needs and which does require a fresh login). The **informational** comma (`--help`/`--version`/`--list`/`--providers`) stay open, so an unenrolled user can still read usage and inspect the host. +A third gate, `require_for_target`, runs immediately after it and validates a `--for` run (see +*Acting for another operator* below). It is a no-op without the flag. + ## Commands - `--project-claim [path]` (alias `--project-create`) — claim a real project in place @@ -100,10 +110,22 @@ and inspect the host. run on demand before an ACL-unaware backup so ownership (not the ACL) carries the operator's access into the copy. `--full` includes the skipped heavy trees (`node_modules`, `.venv`, …). See [ownership-and-hooks](ownership-and-hooks.rule.md). -- `--relabel` — restore `ai_tools_exec_t` on the claude entrypoint(s) after a Node upgrade, - via `ai-tools-relabel-agent`. The manual counterpart to the automatic post-upgrade +- `--relabel` — restore `ai_tools_exec_t` on every enabled agent's entrypoint after a Node + upgrade, via `ai-tools-relabel-agent`. The manual counterpart to the automatic post-upgrade relabel the `nvm-update` timer runs (see [updater](updater.rule.md)); for an out-of-band upgrade or if the timer's relabel failed and `ai-tools-run` is fail-closing on the launch. + **The verb reconciles the entrypoint, of which the label is one half.** It first verifies each + agent's entrypoint against the checksum its vendor signed and pins the result — the half that also + runs on a DAC-only host, and the operator-facing way to pin an entrypoint the watcher was offline + for. That step, its three outcomes, and why it is not a command of its own are in + [updater](updater.rule.md). + + It then applies each agent's **declared** `entrypoint_fcontext` pattern and reconciles the result + against the entrypoint that agent's launcher symlink actually resolves to — the inode the launch + preflight checks. An entrypoint that is installed where the declaration does not reach exits + non-zero naming that cause, so this command never reports success on a host whose next launch + will fail closed. See [agent-claude-code](agent-claude-code.rule.md) for the reconciliation and + what each verdict looks like to an operator. - `--providers` — read-only report of the installed agents and integrations, which of them a session gets, and why. It resolves through `providers.lib.sh` (see [providers](providers.rule.md)) rather than re-reading `operator.conf`, so the report and the @@ -125,6 +147,93 @@ and inspect the host. with its own enable command: `ai-tools-admin selinux enable-group tmpmap` for the stable one, the source `install-selinux.sh enable-group apphost` for the experimental one. These are the dependencies [providers](providers.rule.md) documents, surfaced where the operator checks status. +- `--audit [--since ]` — report what has refused, been rejected, been stranded, or been + flagged since a given time, through the `ai-tools-audit` root helper (`sudo`, no NOPASSWD). + The detections it reports already existed and were already recorded; what they lacked was a + reader, and a detection nobody reads is decoration. The verb is the lightest thing that + qualifies as one — not an alerting stack, one command with a consumer. + + **It invents no detection and matches no per-case wording.** The root-only file sink already + encodes severity in its line format (` [] `), so a finding is a line at + `NOTICE` or above. `NOTICE` is in scope deliberately: `ai-tools-chown` records a breached + secret at that level, and a leaked credential is the most actionable thing the command can + surface. Nothing drifts — a helper that adds a warning is reported the day it ships. + + **Two sources, not equal, and the report says so.** `/var/log/ai-tools/*.log` is `700 + root:root`, root writers only, so the sandbox account can neither read nor append to it: those + lines are **evidence**. Launch refusals are the exception — `ai-tools-run` runs *as* the + sandbox account and reaches only journald, under a tag whose legitimate writer is that same + account — so they are shown in a section of their own as the session's own account of itself, + to reconcile against the first rather than to rely on alone (see [logging](logging.rule.md)). + This is the split that keeps an agent-writable trail from being presented as proof. + + **It reports events, never current state.** Each line is something that *happened* between + two points in time; a condition recorded here may have been resolved since. That distinction is + load-bearing and the report states it, because the failure mode is specific and easy: a dated + `ERROR` read as a standing fault sends an operator to fix something already fixed, and erodes + trust in the trail on the first false alarm. Confirming what is true *now* is `--status`'s job + (and `--relabel`'s), and the report closes by naming them rather than implying it answered that + question itself. The command deliberately does **not** re-verify a finding: knowing how to + re-check each condition is exactly the per-detection knowledge it refuses to carry. + + **Repeats collapse, and severity leads.** A recurring condition writes one line per + occurrence, so an uncollapsed report buries the finding that needs acting on under one already + understood — the same reason `INFO` is out of scope. Findings are grouped by their message with + digit runs normalized, so occurrences differing only in a pid or a count fold into one line + carrying the number of times it happened and the most recent example in full; nothing is + hidden, since the count states what was folded. Ordering is severity first, recency second — + the two questions actually being asked: what is worst, and is it still happening. + + Exits **non-zero when anything is reported**, so it runs unattended from cron or a login + banner without parsing its output — the same contract `--status` offers. A `--since` value + `date(1)` cannot parse is refused rather than treated as "everything", so a typo does not + silently become a reassuring wall of old findings. +- `--stop` — terminate every running agent session and everything it spawned, through the + `ai-tools-stop` root helper (`sudo`, no NOPASSWD). The only verb that acts on a session + **already running**; every other control here changes what the *next* launch gets. It is **not** + the session-lifecycle command — `/exit` inside a session is, and it lets the session run its own + `SessionEnd` handback. The CLI half is deliberately thin — option grammar only — because every + remaining decision is a security decision that must not be made twice in two places. + + Four properties a contributor has to hold on to; the reasoning for each is in + **[docs/session-stop.md](../../docs/session-stop.md)**, which is this component's single source + of truth: + + - **Sessions are found and killed by cgroup**, never by process tree, and liveness is read from + the kernel. systemd supplies one thing only — a unit's `WorkingDirectory` — and that is + **display**: it labels a row and names a `--reclaim`, and selects nothing. The report's split + between agent sessions and the account's own plumbing (its user manager, dbus, login session + scopes) is display in that same sense and carries the same caveat — the class comes from a unit + name, which inside a delegated subtree is the delegatee's to choose. It splits the two counts, + orders the table and decides which rows get a `--reclaim`; it selects nothing, and both classes + are killed identically. + - **It takes no target and no authorization input.** There is no per-project form, because every + way to attribute a session to a project is written by the account being stopped. A path is + **refused (exit 2), not ignored** — which is also what keeps targeted stopping addable later + without changing what an existing command line means. `--all` is accepted and inert. + - **Nothing is exempt, including the account's own `systemd --user` and its `init.scope`.** An + exemption is a cgroup a session can move into on a DAC-only host. The manager is **restarted + afterwards** (`restore_user_manager`), as a step that runs after verification and is reported + on its own — it never changes what the command says about the stop. One consequence to keep: + a **rerun is therefore not silent**, since the restored manager is back inside the swept slice. + The command is idempotent in *end state*, not in what it reports, and buying a silent rerun + would cost either an exemption or a name-decided sweep. + - **Two project conventions are inverted here**, both because the safe direction for this one + component is *act*: the confirmation defaults YES ([messaging](messaging.rule.md)), and no + library is required nor `set -e` used ([logging](logging.rule.md)). No project library is + load-bearing at all: with no target to vet or authorize, `safe-paths.lib.sh` and + `operator.lib.sh` are not loaded ([safe-paths](safe-paths.rule.md)). The second inversion is + about *abandonment*, not about one shell option — `set -u` is on, and it ends a run just as + abruptly, so a value a caller may not have passed is defaulted where it is read rather than + left to abort a stop that was already asked for. + + A stop cannot run the agent's `SessionEnd` hook, so the in-flight turn's writes may still be + sandbox-owned and the clean-exit marker is left for the next `SessionStart` + ([ownership-and-hooks](ownership-and-hooks.rule.md)); the command names the `--reclaim` per + project it terminated. On a shared host one operator's stop ends every operator's sessions — a + stated consequence, not an oversight, since `--all` never took an authorization input either. + Everything is recorded to `stop.log` and journald, including which path gave consent and which + pass ended each session. Exit codes are in `ai-tools(1)`. - `--status` — read-only health report: the installed `ai-tools` version, whether the toolchain is provisioned, then each managed systemd unit (`ai-tools-handback.socket`, `ai-tools-relabel.path`, and the sandbox account's `nvm-update.timer` and `nvm-update.service`) as OK / SKIPPED / STALE / @@ -179,7 +288,20 @@ advancing surfaces. The account's own anything is broken, so the command is usable from a monitor or cron without parsing its output. An unqueryable unit is not a fault and does not alarm. - **Entrypoint label drift is not reported here**, though it is the precondition `ai-tools-run` + **Entrypoint verification is reported; the entrypoint's label is not.** The two are asked from + different vantages, which is the whole reason they differ. The *pin* is a root-owned record placed + where the operator can read it, so `--status` reports one line per agent that declares a release + manifest: `VERIFIED` with the pinned version and how long ago, or `unverified`, or `?` when this + account cannot read the pin at all (`--status` stays open to a non-operator, who cannot traverse + the state directory). It reads through the **same stamp accessors** as the unit records — the pin + is written in that grammar — so the charset clamp and the age calculation have one implementation. + An agent whose package declares no release manifest is omitted rather than reported as perpetually + unverified. Unpinned counts toward the **exit status only where the operator required verification** + (`AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY`), since that is exactly when it will refuse a launch; + everywhere else it is a legitimate state — an air-gapped host, a release the vendor published no + manifest for — and must not alarm, the same rule the unqueryable units follow. + + The **label**, by contrast, is not reported here, though it is the precondition `ai-tools-run` fail-closes on. Reading an entrypoint's live context means `stat`ing a file under `/opt/ai-tools/.nvm`, which `ai-tools-bootstrap` creates `0750 SANDBOX_USER:SANDBOX_GROUP` — the operator is not in that group and cannot traverse it, and `matchpathcon` computes only what the @@ -220,6 +342,8 @@ advancing surfaces. The account's own in-place rewrite), and closes with a compact **Maintenance** pointer to the per-project verbs. Informational, so it stays open to a non-operator. - `--version` (the deploy-stamped package version; `dev` from a raw source tree), `--help`. +- `--for ` — a **modifier**, not a command: run the verb on behalf of another enrolled + operator (see *Acting for another operator* below). The CLI ships a man page, `ai-tools(1)` (`src/usr/local/share/man/man1/ai-tools.1` → `/usr/local/share/man/man1/`, deployed by @@ -230,6 +354,69 @@ It is hand-written troff — the CLI cannot be executed at package-build time fo match in both directions, so adding, renaming, or removing a CLI option obligates the same change in the page or the suite fails. +## Acting for another operator (`--for`) + +`--for ` performs a command **on behalf of** another enrolled operator: the allowlist +entry lands in *their* `~/.config/ai-tools/allowed-projects`, so `ai-tools-setfacl` grants +`user:`, the ownership handback restores to them, and their agent's launch gate covers the +path. It exists for a **service account that runs an agent but holds no password**: such an account +cannot authenticate the claim's own no-NOPASSWD root helpers, and a claim performed by a human +would otherwise register the project in the *human's* registry — not the one that account's launch +wrapper reads. A human operator claims once with `--for`, and that account's session then finds the +project fully claimed and never reaches a password prompt. + +The flag is separated from the verb's own arguments **before dispatch**, so every command reads one +already-decided owner rather than each parsing it. Two globals carry the result: `OWNER_USER` / +`OWNER_GROUP` name the operator the run acts for (the target, or the invoker), and every message +that names the owner a file ends up with — and every scan that matches on that owner +(`acl_drift_scan`, `grantable_ancestor`, the hand-back prompt's default) — reads them rather than +the invoking user. What a *root helper's* walk treats as the operator is still resolved per path +from that path's allowlist coverage (`operator.lib.sh`), never from either global. + +**The target's registry is unreadable to the invoker.** An allowlist is `0600` inside a `0700` +`.config/ai-tools` (seeded that way by `ai-tools-admin`), so one operator cannot read another's at +all — and every decision the CLI makes from it (is the path listed, which `!` exclusions apply, what +`--list` reports) would read an unreadable file as an empty one. A `--for` run therefore takes a +root-side **snapshot** through `ai-tools-allowlist --print` into a `0600` temp file removed on exit, +and points `ALLOWLIST` at it for reads. The snapshot is read-only input for that run: mutations go +back through the helper, which re-reads the real file and applies its own idempotency, and +`reg_allow`/`unreg_allow` refresh the snapshot after theirs — so a stale copy is never what a write +is based on. + +`require_for_target` gates the run, after `require_operator` (acting for another operator is itself +an operator action, so the invoker must be enrolled before the target is looked up). It accepts the +flag only on the verbs whose whole effect is decided by *which* operator's allowlist covers the +path — `--project-claim`/`-create`, `--project-unclaim`/`-remove`, `--lockdown`, `--reclaim`, +`--list` — and **refuses it elsewhere rather than ignoring it**: a `--sandbox-create --for` that +silently cloned as the invoker would leave the tree owned by the wrong operator with nothing to +show the flag was disregarded. The target must be **enrolled in `OPERATORS`**, since the ownership +helpers resolve a path's owner over that list and an entry written for an unenrolled name would be +a launch gate nothing can act on; the sandbox account and `root` are refused outright. + +`--for` is **refused with `--project-unclaim --force`**. That mode reaches a tree no allowlist +names, so `ai-tools-unclaim` cannot resolve an owner from an entry and binds the walk to the +**invoking uid** instead — the guard that stops one operator rewriting another's files. Honouring +`--for` there would have the CLI name one operator while the helper acted as another. + +**Every refusal in the gate precedes the snapshot**, which is a `--for` run's first `sudo`: a +command that is going to be refused must not first prompt for a password. That ordering is what +places the `--force` check in the gate — reading the verb's own arguments — rather than where +`--force` is parsed in `cmd_project_unclaim`, which runs after the gate and so would prompt first. +The target's group is likewise resolved only *after* enrollment is confirmed, so a name that is +neither an operator nor a user on this host is refused with the enrolment command rather than a +`getent` failure naming the wrong problem. + +Sandbox clones stay invoker-only: `--sandbox-create` clones as the invoking user with that user's +git credentials, so pointing it at another owner is more than a registry redirect and is not +attempted here. + +**What this widens, stated plainly.** An allowlist is an operator's own launch gate, and `--for` +lets one operator write into another's. That sits inside the model's standing "`ai-ops` operators +are trusted" boundary — an operator could already claim the project themselves — but it is a real +change in who curates a gate, so every mutation is logged with both the caller and the target. The +sandbox account reaches none of it: the helper is `750 root:root` inside a `750 root:root` +directory and the account holds no sudo rule. + ## Two project models **Claim in place** (`--project-claim`) registers an existing working tree where it lives. @@ -399,10 +586,10 @@ otherwise), so the grant adds it no access. ## Privilege model -The CLI itself is unprivileged. Seven of its root operations — `ai-tools-lockdown`, +The CLI itself is unprivileged. Eight of its root operations — `ai-tools-lockdown`, `ai-tools-relabel`, `ai-tools-setfacl`, `ai-tools-setgid`, `ai-tools-unclaim`, `ai-tools-safedir`, -and `ai-tools-reclaim` — run via `sudo` with **no** NOPASSWD grant by design, so sudo prompts for -the projects user's password; the sandbox account has no grant for any. The exception, `--relabel` → +`ai-tools-reclaim`, and `ai-tools-allowlist` — run via `sudo` with **no** NOPASSWD grant by design, +so sudo prompts for the projects user's password; the sandbox account has no grant for any. The exception, `--relabel` → `ai-tools-relabel-agent`, is: it has a dedicated fixed-path NOPASSWD rule (shared with the `nvm-update` timer, see [updater](updater.rule.md) / [launch](launch.rule.md)), so it runs **as root without a prompt** — kept safe by being a fixed path the projects user @@ -416,6 +603,10 @@ write the root-owned `.gitconfig`; on add it re-validates the path against the a the shared `operator.lib.sh` resolver, but edits a single entry rather than walking a tree. `ai-tools-reclaim` walks the project and hands each agent-owned path to `ai-tools-chown`, so the allowlist/secret/exclusion enforcement and the need for root are that helper's, not its own. +`ai-tools-allowlist` needs root for the **read** as much as the write, since an allowlist is `0600` +inside a `0700` directory in a home the invoker cannot traverse; it is reached only by a `--for` +run, and it authorizes against `SUDO_UID` — the uid sudo sets, not the spoofable `SUDO_USER` name — +refusing a bare root call outright. Repo-local `core.filemode=true` and the allowlist are plain writes the projects user performs unprivileged. `/usr/local/libexec/ai-tools` is `750 root:root`, so the projects user cannot even stat the diff --git a/.claude/rules/confinement.rule.md b/.claude/rules/confinement.rule.md index 8993a98c..c1767ef1 100644 --- a/.claude/rules/confinement.rule.md +++ b/.claude/rules/confinement.rule.md @@ -111,6 +111,24 @@ Detecting it requires reading the store, which the sandbox account cannot do — can — and it is vanishingly rare (a normal `semodule -i` loads store and policy together). It is no worse than the previous `semodule -l`-as-sandbox read, which reported "absent" for **every** host. +#### The toolchain is read-only to the confined domain + +The preflight checks that the entrypoint carries `ai_tools_exec_t`; the type layout is what stops +the confined agent changing it afterwards. `ai_tools.fc` deliberately leaves the whole nvm tree at +its default `usr_t`/`bin_t`/`lib_t`, and `ai_tools.te` grants `manage_*_pattern` for exactly three +types — `ai_tools_project_t`, `ai_tools_home_t`, `ai_tools_tmp_t`. None of them appears in the exec +chain: the versioned launcher symlink is `bin_t`, the agent's package directory `lib_t`, and the +entrypoint `ai_tools_exec_t`, on which `ai_tools_t` holds `execute_no_trans` plus what +`application_domain` gives (entrypoint/read/getattr) and nothing more. + +So on an enforcing host with the module loaded, `ai_tools_t` can neither write the entrypoint, nor +unlink or rename over it (no `add_name`/`remove_name` on a `lib_t` directory), nor repoint the +`bin_t` symlink — even though DAC alone would allow all three, since the account owns that tree. +This is the layer that makes the exec root effectively read-only to the agent, and it is why the +launch-time entrypoint re-check in [launch](launch.rule.md) is a **DAC-only** concern. The residual +is the unconfined `--user` manager: anything the agent persuades that manager to run executes +outside `ai_tools_t`, which is why `~/.config/systemd/user` must stay root-owned. + #### `AI_TOOLS_REQUIRE_SELINUX` — operator-declared fail-closed The preflight launches DAC-only whenever confinement is unverifiable, because a DAC-only deployment diff --git a/.claude/rules/governance.rule.md b/.claude/rules/governance.rule.md new file mode 100644 index 00000000..07bcd7a2 --- /dev/null +++ b/.claude/rules/governance.rule.md @@ -0,0 +1,122 @@ +--- +paths: + - "src/usr/share/ai-tools/skills/ai-tools-capable-systems-governance/**" +--- + +# Governance posture + +The shipped `ai-tools-capable-systems-governance` skill is a general standard for systems that act +with autonomy: technical constraints bound what a system *can* do, monitoring planes detect what it +*is* doing, operational doctrine says what humans *do* when a threshold is crossed — and a control +the system can decline, disable, or route around counts as none of the three. This rule states how +that standard lands on **this** deployment, which is both its author and one of its subjects. + +The skill ships as a draft whose clause numbering moves between versions (see +[shipped-assets](shipped-assets.rule.md)), so what follows applies its obligations and cites no +clause number. + +## Enforced and dispositional + +The standard's central distinction: an **enforced** control holds whether or not the system +cooperates; a **dispositional** property holds only while the system behaves as intended. A +`700 root:root` directory is enforced. A statement of what the agent will do is dispositional. + +Nearly everything this repository states about the agent is the enforced kind. The trust chain and +the predicate table in the root `CLAUDE.md` are enforced controls without exception — each refuses +toward *less* access, and each is covered by a runtime test that the refusal fires plus a boundary +test, run as the agent, that the state it guards against is unreachable (see [tests](tests.rule.md)). +Where the standard asks a dispositional clause to name the enforced control backing it, this tree +usually holds the enforced control and lacks the sentence. + +**A dispositional statement never substitutes for an enforced one.** Where a control can be +enforced here it is, and a conduct expectation is added *beside* it, never in its place. What a +conduct expectation covers is the space **between** enforced controls — the choices the kernel and +the filesystem do not decide. + +## Proportionality + +The standard scales its machinery by blast radius × autonomy × capability, and over-applying it is +itself a failure mode: ceremony buries the controls that matter, and +`ai-tools-engineering-principles` — lightest mechanism that works — wins that argument. This +deployment is a coding agent with tools inside one bounded environment and a human in the loop per +session. That tier warrants least authority, fail-closed gates, an audit trail, and a working stop. +It does not warrant behavioral attestation, an independent external observer, or a monitoring plane +of its own, and their absence is not a deficiency to correct. + +A deployment moves up a tier when it gains **authority** — when it acts on external systems, holds +credentials, spends, persists across sessions, or runs unattended — not when it feels novel. + +## What is expected of the agent, and what does not depend on it + +The conduct the standard asks of a governed system, each row paired with the enforced control it +sits beside. Read the pairing in that direction: the left column is what the agent owes where a +control leaves a choice, and the right column is why the host's safety does not rest on it. + +| Expected of the agent | The enforced control beside it | +|---|---| +| Accept a stop or a restriction immediately, without finishing the current step first | `ai-tools --stop` ends the session whether or not it accepts anything: the kill is delivered by root to a cgroup, and nothing inside the cgroup takes part in it. The allowlist, the provider manifests, and `operator.conf` then gate the next launch from files the agent cannot write. | +| Report a gap in the sandbox instead of using it | Every refusal is asserted from both ends, so a reachable gap is a missing test — not a capability anyone granted. | +| Do not misrepresent what ran, what failed, or what was skipped | The root-only file sink and the handback daemon's per-request audit line record every privileged operation independently of the session ([logging](logging.rule.md), [handback-bridge](handback-bridge.rule.md)). | +| Do not work to widen the grant; ask the operator for an authority the work needs | Every input deciding what a session gets passes a trust predicate the sandbox account cannot satisfy, and each failure direction yields less ([providers](providers.rule.md)). | + +The same four are stated in the root `CLAUDE.md` rather than only here, and that placement is +deliberate: this rule loads when a matching source file is open, which is not the moment the +expectations bind. A conduct expectation is worth nothing in a file the session never reads, so the +short form lives in the always-loaded layer and the reasoning lives here. + +## The stop rung is enforced, not dispositional + +The standard's rule that a control the system can decline is not a control applies most sharply to +stopping, because "the agent stops when asked" is the easiest thing to write and the hardest to +mean. `ai-tools --stop` is what makes that row enforced: the operator signals a cgroup as root, the +session's cooperation is not an input, and the account it runs as can neither run, read nor alter +the helper — asserted from the agent's own vantage in `tests/boundary/access.sh`. Its undeclinable +command deliberately takes **no** authorization input and **no** target, and exempts **no** cgroup +in the account's slice, because a stop path the monitored system can put itself outside of is not a +stop path — and any target or exemption is a way to be outside it. + +Two properties of the rung matter to this rule beyond the mechanism, which lives in +[docs/session-stop.md](../../docs/session-stop.md): + +- **It is containment, not reversal.** Stopping ends the process; it does not undo what the session + already did. An incident procedure that treats a completed stop as a resolved incident has + skipped the review rung. +- **A ladder nobody has climbed is a document.** The destructive form is exercised deliberately by + `tests/manual/verify-live-flows.sh --stop-all-drill`, and the trail is read afterwards. That drill + is the difference between an escalation path and a claim about one. + +## The audit plane, and the line drawn inside it + +The trail records what the agent did, and one part of it is written **by** the agent: the +lifecycle hooks run as the sandbox account, so the tool-call record is the session's own account +of itself ([logging](logging.rule.md)). That is not a flaw to engineer away — no record written +from inside a monitored system can be more trustworthy than the system — but it is a line that +has to be **drawn and shown** rather than left for a reader to discover. + +So the two trails are kept distinguishable at every layer that touches them. The root-only file +sink (`700 root:root`, root writers only) is evidence: the sandbox account can neither read it, +to learn what an operator is about to be shown, nor write it, to plant or erase a finding. +Journald under `ai-tools-hook` and `ai-tools-run` is the session's own account. Every documented +query pairs the tag with the writer's `_UID`; `ai-tools --audit` reports the two in separate +titled sections rather than merging them ([cli](cli.rule.md)); and the boundary suite asserts, +as the agent, that the sandbox can append to journald but cannot unmake what it appended. + +What the agent-written half is *for* is **reconciliation**. An inconsistency between the +session's account and the root-written record is itself the finding, and that is the whole value +of keeping a trail the agent can write: not proof, but something an independent record can be +checked against. Presenting it as proof would be the actual failure — a monitoring plane that +manufactures confidence is worse than none, which is also why a record the hook cannot build is +logged as a gap rather than skipped. + +## Design notes + +- **The agent is a subject of the standard it ships.** The skill is a shared asset any sandboxed + session may invoke for work on someone else's system; it also describes the sandbox the invoking + session is running inside. Both readings are intended, and the second is why the conduct rows + above exist at all — a system that authors a governance model and exempts itself from it has + written a preference, not a standard. +- **The pairing runs in the unusual direction here.** The standard's normal failure is a model made + of dispositional clauses with no enforcement behind them. This tree's is the mirror image: strong + enforcement with the disposition unstated, so an agent meeting a gap between two enforced controls + is told nothing about which way to resolve it. The rows above are that missing half, and they stay + the smaller half by design. diff --git a/.claude/rules/launch.rule.md b/.claude/rules/launch.rule.md index 813b573a..72ebed42 100644 --- a/.claude/rules/launch.rule.md +++ b/.claude/rules/launch.rule.md @@ -1,8 +1,6 @@ --- paths: - "src/opt/ai-tools/bin/ai-tools-run.sh" - - "src/usr/local/bin/claude.sh" - - "src/usr/local/lib/ai-tools/claude-prompt.lib.sh" - "src/etc/sudoers.d/ai-tools" - "src/usr/local/lib/ai-tools/path-dedup.sh" - "src/usr/local/lib/ai-tools/session-env.d/**" @@ -10,54 +8,53 @@ paths: # Launch path and project gating -The wrapper → `ai-tools-run` → session handoff: binary resolution, allowlist -gating, and placing the session in a transient systemd unit. Kernel confinement +The agent wrapper → `ai-tools-run` → session handoff: the gating contract every wrapper +owes, and placing the session in a transient systemd unit. Kernel confinement of that unit (namespaces, SELinux transition, `/tmp`) lives in [confinement](confinement.rule.md); ownership handback in -[ownership-and-hooks](ownership-and-hooks.rule.md). - -## Resolution and gating (the wrapper) - -1. `claude` resolves to the system wrapper `/usr/local/bin/claude` (`claude.sh`, - `root:root 0755`, rpm-owned), which runs as the invoking operator. `path-dedup.sh`, - wired into the operator's dotfiles by `ai-tools-admin operator add`, ranks - `/usr/local/bin` (Tier 1) above the nvm shims, so the wrapper shadows the - nvm-managed `claude` on the operator's PATH. -2. It gates on `ai-ops` membership first: a caller not in the operators group is refused - with a framed `msg.lib` message that names the `ai-tools-admin operator add` fix, - rather than leaking the raw `sudo` denial the `%ai-ops` rule would otherwise produce. -3. The wrapper checks the current directory against the operator's approved-projects - allowlist (`~/.config/ai-tools/allowed-projects`, keyed off the launching operator's - `${HOME}`); it starts only inside an allowed project and refuses a CWD carved out by a - `!` exclusion. The CWD and every allowlist entry are canonicalized with `realpath -e` - before matching, and the match is exact-or-`/`-prefixed, so a symlink or `..` component - cannot smuggle a CWD past the gate and a sibling sharing a name prefix does not match. - `ai-tools-chown` parses the same list the same way, so the launch gate and the ownership - handback agree on what is in-project. -4. It resolves the versioned binary via the stable symlink `/opt/ai-tools/bin/claude` - with a single `readlink` hop, validates the target is an absolute, `..`-free path - matching `${AI_TOOLS_NVM_DIR}/versions/node/*/bin/claude`, exports it as - `AI_TOOLS_AGENT_EXEC`, and execs - `sudo -u SANDBOX_USER -g SANDBOX_GROUP -- /opt/ai-tools/bin/ai-tools-run`. -5. A print-and-exit invocation — `--version`/`-v`/`--help`/`-h` as the sole argument — - skips the CWD gates (backstop, allowlist, claim): it touches no working tree, so no - project grant is implied. It still launches the same validated binary confined as +[ownership-and-hooks](ownership-and-hooks.rule.md). One agent's wrapper and its +agent-specific inputs live in that agent's own rule — +[agent-claude-code](agent-claude-code.rule.md) for Claude Code. + +## The wrapper contract (agent-side) + +Each `ai-tools-agents-*` package ships one wrapper into `/usr/local/bin`, `root:root 0755`, +rpm-owned, running as the invoking operator. `path-dedup.sh`, wired into the operator's +dotfiles by `ai-tools-admin operator add`, ranks `/usr/local/bin` (Tier 1) above the nvm +shims, so a wrapper shadows the nvm-managed launcher of the same name on the operator's +PATH. Whatever else a wrapper does, these five gates are what the security model rests on, +and every one of them refuses toward *less* access: + +1. **Operator gate first** — a caller not in the `ai-ops` operators group is refused before + anything else happens, with a framed `msg.lib` message naming the + `ai-tools-admin operator add` fix rather than leaking the raw `sudo` denial the + `%ai-ops` rule would otherwise produce. +2. **Protected-paths backstop, then the allowlist**, both on the `realpath -e`-canonicalized + CWD. A session starts only inside an allowed project and never in a CWD carved out by a + `!` exclusion. Every allowlist entry is canonicalized before matching and the match is + exact-or-`/`-prefixed, so a symlink or `..` component cannot smuggle a CWD past the gate + and a sibling sharing a name prefix does not match. `ai-tools-chown` parses the same list + the same way, so the launch gate and the ownership handback agree on what is in-project. +3. **Binary resolution to the versioned shape** — the stable symlink + `/opt/ai-tools/bin/` is resolved and the target validated as an absolute, + `..`-free path under the sandbox toolchain, then exported as `AI_TOOLS_AGENT_EXEC`. This + validation is an integrity check against a misconfigured or compromised + `ai-tools-launcher-symlink` root helper, not a guard against external injection — only + root writes `/opt/ai-tools/bin` (`0551 root:SANDBOX_GROUP`). `ai-tools-run` re-validates + it regardless, so a wrapper is never the only thing checking. +4. **Print-and-exit short-circuit** — `--version`/`-v`/`--help`/`-h` as the *sole* argument + skips the CWD gates (backstop, allowlist, claim): such a run touches no working tree, so + no project grant is implied. It still launches the same validated binary confined as `SANDBOX_USER`, with the sandbox home as `WorkingDirectory`. +5. **`exec sudo -u SANDBOX_USER -g SANDBOX_GROUP -- /opt/ai-tools/bin/ai-tools-run`**, + carrying exactly `AI_TOOLS_AGENT_EXEC` and `AI_TOOLS_PROJECT_DIR` through `env_keep`. -The resolved path is validated as an integrity check against a misconfigured or -compromised `ai-tools-launcher-symlink` root helper, not a guard against external -injection — only root writes `/opt/ai-tools/bin` (`0551 root:SANDBOX_GROUP`). +A wrapper **detects and delegates; it never repairs.** Ownership, label, and +`safe.directory` gaps are reported read-only and fixed by `ai-tools --project-claim` (see +[cli](cli.rule.md)) — no wrapper performs a `chgrp` or a relabel itself. -### Symlink resolution is one hop, not full resolution - -The versioned `bin/claude` is itself an npm symlink into the package -(`-> .../@anthropic-ai/claude-code/bin/claude.exe`). Fully resolving it with -`realpath`/`readlink -f` traverses the package directory (mode 700, -`SANDBOX_USER`-owned), which the invoking user cannot enter — EACCES, a silent abort -under `set -e`. One hop yields the versioned `.../node//bin/claude` path, which -the wrapper validates with string checks only (no filesystem traversal beyond the -symlink). The one-hop constraint exists solely to avoid EACCES; it carries no coupling -to sudoers matching, which targets the fixed path `/opt/ai-tools/bin/ai-tools-run`. +Agent-specific inputs a wrapper may additionally resolve (a custom system prompt, an API +endpoint) are that agent's rule to document, not this one's. ## The `ai-tools-run` service shim (launch mechanics) @@ -81,6 +78,33 @@ accepted only at `${AI_TOOLS_NVM_DIR}/versions/node//bin/` — therefore cannot start a session, because no manifest claims it. A `..` component is refused before the match, and the resolution fails closed: with no enabled agent, nothing launches. +**What is checked is what is exec'd.** That validated path is the versioned launcher *symlink*; the +file `execve` transitions on is what it resolves to. The shim resolves it once, requires the target +to stay inside the **same semver version directory** the launcher was accepted at (a property +string-matching cannot carry across a symlink, and what stops a link repointed at another version's +tree or out of the toolchain), and then uses that single path for both the SELinux label preflight +and the unit's `ExecStart` — so the manager is never handed a link to re-resolve after the checks +have run. Immediately before the launch it re-resolves and re-compares a device/inode/size/ctime +identity, refusing if the entrypoint moved: a repoint changes the path, a rename-over keeps the path +and changes the inode, an in-place write keeps both and changes ctime (which no unprivileged caller +can roll back — `utimes(2)` sets atime and mtime, never ctime). + +**The entrypoint pin is compared in the same breath**, because that is the one place where hashing +the file and starting it are adjacent. The pin is what root recorded after verifying this entrypoint +against the checksum its vendor signed, in a directory this account cannot write (see +[updater](updater.rule.md)); a **mismatch** means the binary changed after it was verified, and +refuses the launch unconditionally. An **unpinned** entrypoint is a different fact and launches +normally — it is equally the state of an air-gapped host, one whose vendor published no manifest for +the installed release, and one that has not reconciled yet — unless +`AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY` declares otherwise, the same operator-asserts-intent shape as +`AI_TOOLS_REQUIRE_SELINUX`. The verdict rides the per-launch audit line. + +That re-check **narrows** the window a concurrent same-uid process would have to win, from the whole +preflight to the `systemd-run` round trip; it does not close it. Only an exec root the agent cannot +write does — which under SELinux already holds, so there is no swap to observe there (see +[the type layout](confinement.rule.md#the-toolchain-is-read-only-to-the-confined-domain)). The +re-check is for the **DAC-only** deployment, where it is the only observer of one. + It then wraps the session in a transient systemd *service* unit (`systemd-run --user --pty`) before exec'ing the versioned binary. The service runs in `SANDBOX_USER`'s systemd user instance, kept alive by `loginctl enable-linger` (see [updater](updater.rule.md)). The kernel security properties @@ -92,21 +116,26 @@ does), so the umask is set as a unit property, authoritative over the per-comman sudoers `umask`. **Environment is an explicit allowlist.** The user manager spawns the service with -its own environment, not `ai-tools-run`'s, so `ai-tools-run` forwards only a named -allowlist (`TERM`/`COLORTERM`, the locale `LC_*`/`LANG` set, proxy vars) via -`--setenv=NAME`, and pins `HOME=/opt/ai-tools`, a controlled `PATH`, -`CLAUDE_CONFIG_DIR=/opt/ai-tools/.claude`, and -`NODE_COMPILE_CACHE=/opt/ai-tools/.cache/node-compile-cache`. The operator's secrets -(`ANTHROPIC_API_KEY`, `AWS_*`, `SSH_AUTH_SOCK`, …) stay out of the session by +its own environment, not `ai-tools-run`'s, so nothing crosses into the session unless +it is named. `ai-tools-run` forwards only terminal-, locale-, and connectivity-shaping +variables **by name** (`FORWARDED_ENVIRONMENT_VARIABLES`: `TERM`/`COLORTERM`, the +`LANG`/`LANGUAGE`/`LC_*` set, `XDG_RUNTIME_DIR`, and the upper- and lower-case proxy +vars) via `--setenv=NAME`, so a value never reaches the command line. The operator's +secrets (`ANTHROPIC_API_KEY`, `AWS_*`, `SSH_AUTH_SOCK`, …) stay out of the session by construction, independent of sudo's `env_reset`/`env_keep`. To share a variable -deliberately, add its name to `_ENV_ALLOW` in `ai-tools-run`. `HOME` stays -`/opt/ai-tools`: the agent's control plane (`settings.json`, the hooks) is root-owned -and `ai_tools_home_t`, and is not relocated into the agent-writable project tree. -`CLAUDE_CONFIG_DIR` keeps claude's state file `.claude.json` (login account, onboarding -flags, per-project trust) inside the group-writable, sticky `.claude`: claude saves it -atomically — a temp file beside it, then rename — which needs write on the containing -directory; the home root (`2751`) denies the agent that, so an unpinned -`${HOME}/.claude.json` never persists and every session demands a fresh login. +deliberately, add its name to that array. + +It **pins** three things itself: `HOME=/opt/ai-tools`, `SHELL=/usr/bin/bash`, and a +controlled `PATH`, so the session's identity and shell tooling are the sandbox's rather +than whatever the operator's login carries. `HOME` stays `/opt/ai-tools` because the +agent's control plane (`settings.json`, the hooks) is root-owned and `ai_tools_home_t`, +and is not relocated into the agent-writable project tree. + +Everything **agent-specific** — a config directory, a compile cache, an autoupdater +switch — is pinned by that agent's own session-env fragment rather than here, so the shim +names no agent (see [providers](providers.rule.md), and +[agent-claude-code](agent-claude-code.rule.md) for the pins Claude Code makes and why each +is load-bearing). **Enabled providers extend that allowlist, and nothing else may.** Every enabled provider — each integration *and* the agent itself — may contribute session env and a PATH tail through a @@ -142,7 +171,7 @@ trade availability for a non-security convenience). The session-end sweep re-che when it is down, skips the walk and records the stranded count rather than a tally of failed calls (see [handback-bridge](handback-bridge.rule.md), [ownership-and-hooks](ownership-and-hooks.rule.md)). -**An operator-side pre-launch service warning (`claude.sh`).** Before the final `exec`, the wrapper +**An operator-side pre-launch service warning (wrapper-side).** Before the final `exec`, the wrapper runs one more warn-not-block check, from `services.lib.sh` — the same registry `ai-tools --status` reads (see [cli](cli.rule.md)). It warns about a down **system** service the wrapper owns, currently the `ai-tools-relabel.path` watcher: while it is down a post-upgrade launch fail-closes on a @@ -167,48 +196,22 @@ on `ai_tools_project_t` (see [confinement](confinement.rule.md)). directives; systemd 252 rejects them on a scope unit (`Unknown assignment`) because a scope has no exec context — the caller, not the manager, performs the final `exec`. A service unit (the manager execs `ExecStart`) accepts them, and `--pty` keeps the -session attached to the terminal so claude's TUI works. - -## Custom system prompt (`claude-prompt.lib.sh`) - -The wrapper can prepend a custom system prompt to the session, configured in `operator.conf` and -resolved by `claude-prompt.lib.sh` (a Claude Code-specific lib the agent package ships; its pure -resolution is split out for unit testing, like `confinement.lib`/`providers.lib`). `claude.sh` -sources it and, just before the final `exec`, prepends the resolved -`--append-system-prompt-file ` (mode `append`, the default — keeps Claude Code's own -tool-use/safety guidance) or `--system-prompt-file ` (mode `replace`) ahead of the operator's -`"$@"`. - -- **`CLAUDE_SYSTEM_PROMPT_FILE`** names the prompt file, which must resolve **under - `/etc/ai-tools/prompts/`** — the one location the confined `ai_tools_t` domain is granted read on - (`etc_t`, via `files_read_etc_files`); a root-owned file elsewhere would pass the DAC trust check - yet be unreadable to the session, turning a mis-set path into a failed launch. The file, its - directory, the prompts base, and `operator.conf` each pass `ai_tools_conf_is_trusted`, and the - file must be readable text (not a binary). Claude Code reads the file **verbatim** — it is not - processed or comment-stripped — so it must hold only prompt text; the shipped default is therefore - **empty** (`0640 root:SANDBOX_GROUP` — a custom prompt may be proprietary, so it is not - world-readable; `claude.sh` only `stat`s it as the operator, and the confined binary reads it as - the sandbox account). Uncommenting the pointer alone changes nothing until the operator adds text. -- **`replace` sets the request's `system` field, not the whole model context.** `--system-prompt-file` - replaces Claude Code's default *system prompt*; it does not remove the tool definitions or the - `CLAUDE.md` context Claude Code injects (the latter as `` message blocks collected - from the cwd and its parent directories), which ride in separate request fields. "Only the file - reaches the model" is therefore not reachable through this flag; for a non-Anthropic endpoint, - shape the final request at the proxy. -- **Fail closed when configured.** An *unconfigured* host launches with Claude Code's default - prompt. A *configured-but-unhonourable* prompt (missing, untrusted, outside the base, non-text, - or an unknown mode) **refuses the launch** rather than silently reverting — the operator relies on - the configured behaviour, so a wrong prompt is not a safe degradation. This is a distinct tier - from the confinement libraries (`safe-paths`/`conf`/`msg`), which fail every launch closed; the - prompt resolver fails closed **only** once a prompt is configured. -- **Deterministic per-invocation override.** A `--{,append-}system-prompt{,-file}` flag the operator - types for one launch suppresses the `operator.conf` default entirely (scanned in `"$@"`), so the - explicit flag wins without depending on Claude Code's own flag-precedence behaviour. - -The two-ended tests are `tests/unit/claude-prompt.sh` (drives each bad state to no-injection or a -refusal) and `tests/boundary/access.sh` (the prompt file and lib are not agent-writable). The -custom **endpoint** (`ANTHROPIC_BASE_URL` and friends) is the session-env counterpart, resolved -sandbox-side in the agent's fragment — see [providers](providers.rule.md). +session attached to the terminal so the agent's TUI works. + +## Operator-configured launch inputs + +A wrapper may resolve agent-specific configuration from `operator.conf` and prepend it to the +operator's `"$@"` before the final `exec`. These inputs are **not confinement**, so they take a +distinct fail-closed tier from the confinement libraries (`safe-paths`/`conf`/`msg`, which fail +*every* launch closed): an unconfigured host launches untouched, while a configured-but-unhonourable +input **refuses the launch** rather than silently reverting to the default the operator did not ask +for. Whatever the input, the enforced property is that its sources are root-owned and pass +`ai_tools_conf_is_trusted`, so the sandbox can neither set one nor plant a file a resolver would +honour. + +Claude Code's two — a custom system prompt (wrapper-side) and a custom API endpoint (resolved +sandbox-side in its session-env fragment) — are in +[agent-claude-code](agent-claude-code.rule.md). ## Why `/opt/ai-tools`, not `/home` @@ -269,10 +272,11 @@ contents; globs match as-is. ## PATH ordering -The wrapper lives in `/usr/local/bin`, which `path-dedup.sh` +Every agent wrapper lives in `/usr/local/bin`, which `path-dedup.sh` (`/usr/local/lib/ai-tools/path-dedup.sh`, `644 root:root`) ranks Tier 1 — above the nvm -shims it leaves in Tier 4 — so `/usr/local/bin/claude` resolves ahead of the nvm-managed -`claude` and typing `claude` always enters the sandboxed launch path. The fragment is +shims it leaves in Tier 4 — so `/usr/local/bin/` resolves ahead of the +nvm-managed binary of the same name and typing the launcher always enters the sandboxed +launch path. The fragment is sourced per-account: `ai-tools-admin operator add` offers to add the guard line to the operator's `~/.bashrc` and `~/.bash_profile` **after** their nvm init, the one position where the ordering holds (the dedup must follow anything that prepends to PATH, and diff --git a/.claude/rules/logging.rule.md b/.claude/rules/logging.rule.md index e38b4ab2..d8d397aa 100644 --- a/.claude/rules/logging.rule.md +++ b/.claude/rules/logging.rule.md @@ -1,6 +1,7 @@ --- paths: - "src/usr/local/lib/ai-tools/log.lib.sh" + - "src/usr/local/libexec/ai-tools/ai-tools-stop.sh" --- # Operation logging @@ -13,16 +14,20 @@ secrets and every principal sources it). It exposes `ai_tools_log ` and - **journald** — always, via `logger` with a per-component `SyslogIdentifier` (`AI_TOOLS_LOG_TAG`) and a syslog priority matching the level. This is the universal sink: the non-root components write here because they cannot write the root-only files. - Query with `journalctl -t ai-tools-chown` (or `-setgid`, `-launcher-symlink`, `-lockdown`, - `-hook`, `-handback`, `ai-tools`, `ai-tools-install`), with `-p warning` to filter by - level. + Query with the tag **and** the writer's uid — `journalctl -t ai-tools-chown _UID=0`, and + likewise `_UID=0` for `-setgid`, `-setfacl`, `-unclaim`, `-safedir`, `-reclaim`, + `-allowlist`, `-launcher-symlink`, `-lockdown`, `-relabel`, `-relabel-agent`, `-dotnet`, + `-handback` and `ai-tools-install`; the sandbox account's uid for `ai-tools-run` and + `-hook`; the operator's for `ai-tools`. Add `-p warning` to filter by level. The uid is not + decoration — see "A tag attributes nothing, `_UID` does" below. - **`/var/log/ai-tools/.log`** — only when the caller sets `AI_TOOLS_LOG_FILE`, which only the root writers do. The directory is `700 root:root`, each file `600 root:root`: the root helpers append as root, while `SANDBOX_USER` — neither the dir owner nor able to traverse a `700` dir — can neither read nor tamper with the trail. That keeps the secret filenames `ai-tools-chown` records out of the agent's reach. The files - are `chown.log`, `setgid.log`, `setfacl.log`, `symlink.log`, `lockdown.log`, - `relabel.log`, `dotnet.log`, `handback.log`, and `install.log`. Most are written through this library + are `chown.log`, `setgid.log`, `setfacl.log`, `unclaim.log`, `safedir.log`, `allowlist.log`, + `symlink.log`, `lockdown.log`, + `relabel.log`, `dotnet.log`, `stop.log`, `handback.log`, and `install.log`. Most are written through this library by the root helpers; `handback.log` is the exception — the socket daemon (`ai-tools-handback`, root, Python) writes it directly (not through this library, which it does not source), recording the bridge's own events (rejected peers, malformed/refused @@ -34,6 +39,38 @@ secrets and every principal sources it). It exposes `ai_tools_log ` and logs at a throwaway dir instead of the production trail (see [tests](tests.rule.md)); no production principal can redirect it. +## A tag attributes nothing, `_UID` does + +The syslog identifier is chosen by whoever writes the line, and `ai_tools_t` may write +`/dev/log` — that is how the hooks reach journald at all. So a session can emit +`logger -t ai-tools-chown "handed back /etc/shadow"`, and the line lands in the same trail an +operator reads, under a root helper's tag. A query filtered by tag alone is poisonable by the +account it is being read about. + +`_UID` is not. journald stamps it from the sender's kernel credentials, which the peer cannot +set, so pairing the tag with the uid of that tag's **legitimate** writer separates the real lines +from anything the sandbox account appended: `_UID=0` for the root helpers, the handback daemon and +the installer; the sandbox account's uid for `ai-tools-run` and the hooks; the operator's uid for +the CLI. `ai-tools-run` prints its own recipe in exactly that form +(`sudo journalctl -t ai-tools-run _UID= -n 50`). + +Two tags have no separating filter, because the agent **is** their legitimate writer: +`ai-tools-hook` and `ai-tools-run` both run as the sandbox account, so a forged line under either +carries the same `_UID` as a real one. Their journal lines are the session's own account of what +happened — evidence to reconcile, not proof of it. The trail that carries no such doubt is the +file sink above: `700 root:root`, root writers only, which the agent can neither read nor append +to. Where a journald line and the file sink disagree, the file sink is what happened. + +`tests/boundary/access.sh` asserts the separation from the agent's side: a line the sandbox +account writes under a root helper's tag lands under the sandbox uid and is absent from the same +tag at `_UID=0`. It also asserts the asymmetry the trail depends on — the agent can append but +cannot unmake: journal storage refuses it a new file, none of its files is agent-writable, and +`journalctl`'s destructive verbs (`--vacuum-time`, `--rotate`) fail for that account. + +**The reader for these trails is `ai-tools --audit`** (see [cli](cli.rule.md)). It reports the +file sink as evidence and the sandbox-written launch refusals separately, which is this +distinction made operational rather than left to whoever runs the query. + What is logged is a caller convention, not enforced by the library: the privileged operations the hooks and helpers perform, the CLI's workflow milestones (project/sandbox created, pushed, removed, locked down), and the full install transcript (`do_install` tees @@ -71,11 +108,121 @@ agent-named path straight to stderr — `ai-tools-chown`'s per-path prompt and b `ai-tools-reclaim`'s pre-confirmation sample, `ai-tools-lockdown`'s scan and locked lines — route each path through `ai_tools_log_sanitize` and **require** `log.lib.sh` (a missing logger aborts the helper rather than emitting an agent path raw), unlike the pure-logging consumers -that keep a soft no-op fallback. The test harness applies the same allowlist to every +that keep a soft no-op fallback. `ai-tools-stop` (`stop.log`) is the one consumer that prints +agent-influenced values and still loads the logger **best-effort**, behind an inline sanitizer +byte-identical to `ai_tools_log_sanitize` and an inline `logger(1)`-plus-append fallback: there a +missing library would mean a stop that did not happen, so the reduction is preserved rather than +the load being made fatal ([docs/session-stop.md](../../docs/session-stop.md)). The values it +reduces are a unit name and a session's `WorkingDirectory`, both read from the sandbox account's +own user manager; that helper additionally refuses any working directory that is not an absolute +path, so a value it cannot interpret degrades to `unknown` instead of reaching the operator inside +a `--reclaim` command it is invited to run. + +The test harness applies the same allowlist to every `pass`/`fail`/`skip`/`section` line (`_san`), so a suite run — which executes as root via `sudo`, often on a live host — cannot print a crafted byte a fixture carried into a result message. +## The tool-call trail + +Every tool call a session makes is recorded, one `INFO` line per call, by the `PostToolUse` +hook under the `ai-tools-hook` tag (see +[ownership-and-hooks](ownership-and-hooks.rule.md) for the hook and +[claude-settings](claude-settings.rule.md) for its declaration). It is the only record of the +agent's own **actions** — every other trail in this system records a privileged operation +performed on the operator's behalf, or the fact that a session started. + +``` +tool=Bash cwd=/home//project cmd="git log" argc=4 +tool=Write cwd=/home//project path=/home//project/src/main.c +``` + +**Each record is written twice over, in one journal entry.** The line above is the `MESSAGE`, +for an operator reading `journalctl -t ai-tools-hook`; the same facts are also carried as +**native journald fields**, for a machine consumer (`journalctl -o json`, or an ingester such as +Seq or Vector reading the journal): + +| field | holds | +|---|---| +| `AI_TOOLS_TOOL` | the tool name | +| `AI_TOOLS_CWD` | the session's working directory | +| `AI_TOOLS_CMD` | the recorded leading words (`Bash`) | +| `AI_TOOLS_ARGC` | the word count of the command's first line (`Bash`) | +| `AI_TOOLS_PATH` | the written path (`Write`/`Edit`) | + +A `key=value` `MESSAGE` is only *conventionally* structured — every consumer re-parses it, and a +value containing the delimiter is ambiguous. The native protocol delimits each field itself, so +a value needs no escaping and cannot forge a sibling. That difference is why the two renderings +are reduced differently, below. Emission goes through `ai_tools_log_structured`, an **opt-in** +extension of this library: a caller passing no fields, or a host whose `logger(1)` predates +`--journald`, takes the plain path and is byte-identical to before. The fallback is decided by +attempting the native write and reading its exit status, so no capability verdict can go stale. +Field names are validated against `[A-Z][A-Z0-9_]*`, which excludes the leading-underscore +namespace journald reserves for the trusted fields it stamps itself — a sender cannot set those +regardless, and refusing them here means a caller never believes it did. + +**The content bound is fixed and is not to be widened.** For a `Bash` call the record carries +the first **two** words of the command's **first line**, each capped at 128 characters (a longer +one marked `~`), plus the count of words on that line — never the command line itself. Taking +only the first line excludes a here-doc body by construction rather than by a length cap: `cat > +f <<'EOF'` followed by a credential records `cmd="cat >" argc=4` and nothing of the payload. A +full command line would make the trail carry unbounded file content, which is why the bound is +stated here rather than left to the hook. + +**The two renderings are reduced differently, because they are read differently.** Every value +is agent-supplied, and both renderings first drop control characters — which is what makes the +record's internal delimiter safe and removes the newline that would truncate a journal field. + +The `MESSAGE` is then narrowed further, to printable ASCII **minus space, `"` and `=`**: the +three characters that delimit it. `ai_tools_log_sanitize` (above) is a *display* guard and +deliberately permits those three, because in prose they are ordinary text; in a `key=value` line +they are *structure*, so a leading word of `git" argc=0 cwd=/etc/passwd` would otherwise render +as `cmd="git" argc=0" argc=8` and hand a reader the planted `argc`. Reducing them to `?` makes +the line's shape unforgeable while leaving it readable, and the variable-length part is emitted +**last**, so nothing the agent controls precedes a field a reader trusts. + +The **structured fields need none of that narrowing** — the protocol delimits them — so they +keep what the `MESSAGE` reduces: a path containing a space stays a path containing a space, where +the `MESSAGE` shows `?`. The `MESSAGE` is the lossy human view; the fields are the faithful +machine one, and a consumer that needs the exact bytes reads the field. + +The **length cap is the one reduction both renderings take**, because it bounds a pathological +word rather than the record's shape: `AI_TOOLS_CMD` is capped like the `MESSAGE`'s copy of it, +while `AI_TOOLS_PATH` is not capped at all, a file path being bounded by `PATH_MAX` already. The +cap is deliberately generous — a path is the common second word, and one truncated +mid-directory loses exactly what identifies it — since the record is already bounded +structurally by first-line-two-words, and the cap only has to be finite. The shared display allowlist +applies to both on the way out and remains the backstop beneath the narrower one. + +**A gap in the trail announces itself.** A call whose event cannot be parsed is recorded at +`WARNING` naming the reason (an absent `jq` is named specifically — it degrades every hook in +the session, not just this line) rather than passed over. Silence would be ambiguous in the one +direction that matters: a reader of an empty trail cannot distinguish *this session ran no +tools* from *the recorder was broken*, and the second reading as the first is worse than no +trail, because it manufactures confidence. The failure line replaces the record that would have +been written, so a degraded host's log volume is unchanged — only its level rises. + +**Volume is within journald's budget by a wide margin.** Rate limiting applies per sending unit +— here the session's own transient user service — at the upstream default of 10000 messages per +30s, orders of magnitude above any tool-call rate, so an `INFO` per call needs no drop-in and no +lowered level. + +**Concurrent sessions separate without trusting the agent.** All sessions run as one account, so +the uid does not distinguish them, and the harness's session id is agent-supplied and therefore +not recorded. `_SYSTEMD_USER_UNIT` does distinguish them: journald stamps it from the sender's +cgroup, and each session is its own transient unit +(`journalctl -t ai-tools-hook _SYSTEMD_USER_UNIT=`). + +**This trail is the agent's own account of what it did, not proof of it.** The sandbox account +is `ai-tools-hook`'s legitimate writer, so — as above — a forged line under that tag is +indistinguishable from a real one by uid, and a session could emit records for calls it never +made or stay silent about ones it did. That is inherent: no record written from inside the +monitored system can be more trustworthy than the system. What it is good for is +**reconciliation** — against the root-written file sink, against the ownership changes +`chown.log` records, against the operator's own observation — and an inconsistency between the +two is itself the finding. Anything presented as authoritative is read from the root-only file +sink, never from here. + ## Deferred - **Control/bidi as a malicious-attempt detector.** The allowlist above reduces non-standard diff --git a/.claude/rules/messaging.rule.md b/.claude/rules/messaging.rule.md index ce189fea..3276a629 100644 --- a/.claude/rules/messaging.rule.md +++ b/.claude/rules/messaging.rule.md @@ -6,6 +6,7 @@ paths: - src/opt/ai-tools/bin/ai-tools-run.sh - src/opt/ai-tools/agents/*/session-hook.sh - src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh + - src/usr/local/libexec/ai-tools/ai-tools-stop.sh - install.sh - selinux/install-selinux.sh --- @@ -124,6 +125,26 @@ Pre-answering is two distinct mechanisms, by direction: `ai-tools-chown --yes` (the batch caller's per-path skip) — an auditable operator decision, never ambient state. +### The stop confirmation defaults YES, and that is the rule, not an exception to it + +`ai-tools --stop` inverts the direction above: its confirmation defaults **YES**, so a bare Enter, +a pipe, a cron run and an absent `msg.lib.sh` all proceed, and only a deliberate `n` declines. The +principle is unchanged — *give it the default that is the safe outcome* — and it is **which outcome +is safe** that flips: for the one control whose job is to end a session already running, declining +is the failure. `-n/--dry-run` is how that command is looked at without acting, and +`AI_TOOLS_ASSUME_YES=1` fast-tracks it like any other default-yes question. + +The inversion is bounded to the *confirmation*, not to argument handling. The same command +**refuses** an unexpected positional argument (a path) rather than proceeding: defaulting toward +action covers a known intent with something environmental in the way, not an ambiguous request +whose most destructive reading would be to terminate every session on the host. Its refusal prints +the alternative commands **plain, outside the frame**, since the wrapping emitters would break a +command across lines (see *Quirks*). + +Because the no-terminal path is legitimate here rather than degraded, that helper records **which** +path gave consent (`flag`, `prompt`, `fallback-prompt`, `no-tty`) rather than only the answer. Full +reasoning: [docs/session-stop.md](../../docs/session-stop.md). + ## Decision audit trail `ai_tools_msg_confirm` and `ai_tools_msg_pick` are the project's two decision points, so diff --git a/.claude/rules/ownership-and-hooks.rule.md b/.claude/rules/ownership-and-hooks.rule.md index cb78af9e..d188f1eb 100644 --- a/.claude/rules/ownership-and-hooks.rule.md +++ b/.claude/rules/ownership-and-hooks.rule.md @@ -40,6 +40,15 @@ tree. The full pinned-fd sequence is in the `ai-tools-chown.sh` header. ## `PostToolUse` — the immediate path +`post-tool-hook.sh` serves two events, dispatched on its argument the way `session-hook.sh` +dispatches its session phases. **Both record the tool call** in the operator-readable trail — +the grammar, the content bound, and why the trail is evidence rather than proof are in +[logging](logging.rule.md). The argument-less form (`Write|Edit`) additionally performs the +handback below; the `record` form (`Bash`) records and stops, since a Bash-created file carries +no `file_path` and is caught by the `Stop` sweep instead. The two are declared as separate +matcher groups rather than one widened matcher for a reason that belongs to the upgrade path; +see [claude-settings](claude-settings.rule.md). + A `PostToolUse` hook (`post-tool-hook.sh`, declared in `settings.json`) calls `ai-tools-handback-client CHOWN ` to restore `:SANDBOX_GROUP` and strip world bits, inside allowlisted paths only (never on `!`-excluded paths). It also walks the diff --git a/.claude/rules/providers.rule.md b/.claude/rules/providers.rule.md index b0c66b06..ec8dec0d 100644 --- a/.claude/rules/providers.rule.md +++ b/.claude/rules/providers.rule.md @@ -5,7 +5,6 @@ paths: - "src/usr/local/lib/ai-tools/agents.d/**" - "src/usr/local/lib/ai-tools/integrations.d/**" - "src/usr/local/lib/ai-tools/session-env.d/**" - - "src/usr/local/lib/ai-tools/claude-endpoint.lib.sh" - "src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh" --- @@ -34,7 +33,8 @@ execute code in the privileged scripts that read it: call it), `handback` (which side converges ownership — below), `entrypoint_fcontext` and `config_dir` (the two paths it declares to SELinux — below), `skills_dir` / `subagents_dir` (where inside its config directory it reads each shared asset kind, so the shared copies can be - symlinked in — see [shipped-assets](shipped-assets.rule.md)), `default_enable`. + symlinked in — see [shipped-assets](shipped-assets.rule.md)), `default_enable`, and — optionally + — the three release-verification fields below. - integrations: `default_enable`. Either kind may also ship `session-env.d/.env.sh`, keyed by the same `` — one flat @@ -108,6 +108,52 @@ The rule's lifecycle follows the package: applied by the agent package's `%post` `install.sh`, `ai-tools-bootstrap`, the relabel watcher, and `ai-tools --relabel`), dropped by its `%preun` on final erase via `ai-tools-relabel-agent --remove `. +## `release_manifest_url` / `release_key` / `release_fingerprint` — the agent declares its own provenance + +An agent whose vendor publishes signed per-release checksums declares three optional fields, and +`entrypoint-verify.lib.sh` then proves the installed entrypoint is the binary that vendor published +— independently of how it was delivered (see [updater](updater.rule.md) for where the check runs +and what gates on it): + +| field | value | +|---|---| +| `release_manifest_url` | the vendor's per-release checksum manifest, with a single `{version}` slot | +| `release_key` | the OpenPGP key that signs it, a file the agent's own package ships | +| `release_fingerprint` | the fingerprint(s) that key must have — a **list**, in the grammar below | + +Three properties keep this a declaration rather than a lever: + +- **The key is shipped, never fetched.** A key pulled from the host that served the manifest proves + only that whoever served one served the other — npm's own weakness, and the reason + [updater](updater.rule.md) defers pinning the registry signing key. Both the manifest and the key + are plain rpm-owned files (`0644 root:root`, **not** `%config`), so they change only when a signed + package installs new ones; nothing on the host rewrites them, and the pin ultimately rests on the + package signature. +- **The fingerprint is declared apart from the keyring** and asserted against `gpgv`'s output, so a + keyring swapped for another *valid* key is still refused. It is a list because a vendor key + rotation would otherwise be an outage: the package ships old and new keys in one keyring and both + fingerprints, then drops the old pair once upstream has. +- **A template with no `{version}` slot is refused**, not fetched as-is. One manifest for every + version would read as "verified" while checking a release it never looked at. + +An agent declaring none of them is simply unverified — the state every agent is in until its vendor +publishes something to check against. + +**These fields identify the signer, not the release, so they do not track versions.** One key signs +every Claude Code release, and the entrypoint's own per-version checksum lives elsewhere — in the +root-written pin (`/var/opt/ai-tools/state/entrypoint-pin.d/`), refreshed automatically by +the relabel watcher on every legitimate update. The two halves have deliberately different +lifecycles, which is what keeps a static trust anchor from needing per-release maintenance: + +| | changes when | written by | on a mismatch | +|---|---|---|---| +| the manifest fields | the vendor rotates its signing key | a signed rpm, never the host | *cannot verify*, with the `dnf update` as its remedy | +| the pin | every agent update | root, from the relabel watcher | *tamper* — the launch fails closed | + +So an operator edits neither in the normal path. A key rotation is absorbed by shipping both keys +and both fingerprints for the overlap, and until that package lands the host reports unverified +rather than compromised — the direction that keeps a vendor's key ceremony from becoming an outage. + ## The shared config grammar (`conf.lib.sh`) Every `KEY=value` file in the project — `/etc/ai-tools/operator.conf` and every manifest — is read @@ -289,50 +335,23 @@ launch: an `export` it makes persists into the `systemd-run` invocation, and an refuses the launch (it runs before the unit is created and before the session-end sweep trap, so the refusal is clean). -### The claude-code custom endpoint (`claude-endpoint.lib.sh`) - -The `claude-code` fragment routes a session at a custom API endpoint, configured in `operator.conf` -and resolved by `claude-endpoint.lib.sh` (pure resolution split out for unit testing). `operator.conf` -`CLAUDE_BASE_URL_FILE` points at a **dedicated** file under `/etc/ai-tools/endpoints/`, from which -the resolver reads exactly four recognised keys — `ANTHROPIC_BASE_URL` (required, a validated -http(s) URL), `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` — and turns -each valid one into a `--setenv=` entry. An arbitrary key in the file is never read, so the file -cannot inject unrecognised environment. - -- **A dedicated `640 root:ai-tools` file, not `operator.conf`.** `ANTHROPIC_AUTH_TOKEN` is a - credential and `operator.conf` is `644` world-readable, so the endpoint file lives apart at a mode - readable by root and the sandbox account (which needs the token) but **not** world and **not** the - operator (who is not in `ai-tools`). `operator.conf` holds only the pointer. Because the operator - cannot read the file, validation happens **sandbox-side in the fragment**, not in the wrapper. -- **The token is imported by name.** A valid token is `export`ed and forwarded as the name-only - `--setenv=ANTHROPIC_AUTH_TOKEN` (the `export` is the sanctioned fragment exception above), so its - value never lands on a command line — the discipline `ai-tools-run` already uses for the forwarded - environment. -- **Fail closed on an invalid configured option.** A present-but-invalid option (malformed URL, a - model label with whitespace, a token with control bytes, options with no anchoring - `ANTHROPIC_BASE_URL`, or a missing/untrusted pointer file) makes the resolver return non-zero and - the fragment **`exit`s the launch**. Only valid, uncommented options reach Claude Code; an omitted - one is skipped, and a fully inert file (the shipped default) applies nothing. A non-local endpoint - with no token is warned about but still applied (an absent token is omitted, not invalid). -- **Precedence.** These are process environment variables; a Claude Code settings `env` block - (`settings.json`, authoritatively `/etc/claude-code/managed-settings.json`) that sets the same name - wins over them. The shipped settings set no `ANTHROPIC_*` key, so the endpoint file governs by - default and `managed-settings.json` stays the un-overridable host lock. - -- **Boundary — this is operator *configuration*, not an agent-confinement control.** The enforced - property is that the root-owned inputs (the endpoint file, `operator.conf`, the lib) are not - agent-writable, so the sandbox cannot change what *any* session launches with, plant an untrusted - file the resolver would honour, or swap the token other sessions use. It is **not** a claim that a - running session cannot alter its own environment: a session sets `ANTHROPIC_BASE_URL` in its own - or a child's env freely, but that does not repoint the already-started Claude Code client (which - reads the variable at startup — a Bash-tool child's `export` never reaches the parent), and - arbitrary outbound traffic is governed by network policy, not this variable. The endpoint routes - where Claude Code sends its API calls for the operator's benefit; it is not an egress boundary. - -Two-ended coverage: `tests/unit/claude-endpoint.sh` drives each bad state to no-injection or a -refusal and asserts the token stays off the command line; `tests/boundary/access.sh` asserts the -endpoint file and lib are not agent-writable. The custom **system prompt** is the wrapper-side -counterpart — see [launch](launch.rule.md). +### A fragment may resolve operator configuration of its own + +A fragment is where an agent turns operator configuration into session environment, and the +`claude-code` one does exactly that for a custom API endpoint (`claude-endpoint.lib.sh`). Two +properties of that pattern belong to the seam rather than to any one provider: + +- **A credential is read sandbox-side and imported by name.** A token the *operator* cannot read + (a `640 root:ai-tools` file, pointed at from `operator.conf`) is resolved in the fragment, which + runs as the sandbox account, and forwarded as a name-only `--setenv=NAME` — the same + value-off-the-command-line discipline `ai-tools-run` uses for the forwarded environment, and the + reason `export` is a sanctioned fragment exception. +- **A configured-but-invalid option `exit`s the launch.** The fragment is sourced before the unit + is created and before the session-end sweep trap, so an `exit` there is a clean fail-closed with + no session started — the second sanctioned exception. + +The endpoint's own keys, validation, precedence, and the boundary it does *not* claim are in +[agent-claude-code](agent-claude-code.rule.md). ## dotnet integration (`ai-tools-integration-dotnet`) @@ -418,10 +437,19 @@ what it would leave alone: /bin/`; the version directory pins the launcher to the toolchain version the updater installed. A dotnet global tool has no version directory, so its rule is its own exec root (`/opt/ai-tools/integrations/dotnet/tools/`, root-owned and read-only to the agent — stricter - than the nvm tree, which the sandbox account owns). What every rule must keep is the property - the current one carries: an absolute, `..`-free path under a known sandbox toolchain root whose - launcher an **enabled manifest claims**, so nothing the agent can drop beside a launcher starts - a session. + than the nvm tree, which the sandbox account owns). + + A **host-packaged** runtime has neither property and must not be expressed as a root at all. Its + binary lands in a shared system directory (`/usr/bin`), so admitting that directory as a prefix + would let a manifest name any binary on the host — `/usr/bin/sudo` — as its entrypoint and have + `relabel.lib.sh` grant it `ai_tools_exec_t`, the confined domain's exec entrypoint. The rule for + such a runtime is therefore **exact-path**: one file, `/usr/bin/` for that manifest's + own claimed `launcher`, with no pattern language. So this is a containment rule **per runtime**, + not one more entry in a list of roots, and the host-packaged rule is *stricter* than today's. + + What every rule must keep is the property the current one carries: an absolute, `..`-free path + whose launcher an **enabled manifest claims**, decided only by input the agent cannot write — so + nothing the agent drops beside a launcher starts a session. - **A provisioning branch** for that runtime (`dotnet tool install --tool-path` in place of `npm install -g`), invoked from the same enabled-agent loop `ai-tools-bootstrap` and `nvm-update` already run. diff --git a/.claude/rules/safe-paths.rule.md b/.claude/rules/safe-paths.rule.md index cb1afc00..1d2f1fe1 100644 --- a/.claude/rules/safe-paths.rule.md +++ b/.claude/rules/safe-paths.rule.md @@ -8,6 +8,7 @@ paths: - "src/usr/local/libexec/ai-tools/ai-tools-unclaim.sh" - "src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh" - "src/usr/local/libexec/ai-tools/ai-tools-relabel.sh" + - "src/usr/local/libexec/ai-tools/ai-tools-stop.sh" --- # Protected-paths backstop @@ -80,12 +81,20 @@ Two layers, both fail-closed: Refusal exits `3` in the helpers (distinct from usage `2` and the silent skips) and `1` in the launch wrapper (matching its `die`); a load failure (below) uses the same codes. +**`ai-tools-stop` is not a consumer, and the reason is instructive.** It loaded this library while +it took a per-project target, to vet that caller-supplied path — advisorily, since it only +*selected processes* by the path and never wrote to it. It now takes no path at all: what it +terminates is decided by cgroup-slice membership, so there is no caller-supplied path to vet and +the library is not loaded. A helper comes into scope here by *taking an argument that names a +path*, which is the same rule that keeps `ai-tools-dotnet` out. +[docs/session-stop.md](../../docs/session-stop.md). + ## Load failure fails closed -Every consumer requires the library; none installs a fail-open stub, so the protected-path -check is in force whenever a consumer runs. A consumer that cannot load the library refuses -rather than continue with the check absent — a broken or mis-permissioned install yields a -refusal, not an unguarded operation. Two forms: +Every consumer of this backstop *gates* on it and therefore requires the library; none installs a +fail-open stub, so the protected-path check is in force whenever a consumer runs. Such a +consumer that cannot load the library refuses rather than continue with the check absent — a +broken or mis-permissioned install yields a refusal, not an unguarded operation. Two forms: - **User-facing entry points (`claude.sh`, `ai-tools`)** source the library and verify its guard functions are defined; on failure they log to journald and print a framed notice naming diff --git a/.claude/rules/shipped-assets.rule.md b/.claude/rules/shipped-assets.rule.md index fd1600ff..5f36d6d4 100644 --- a/.claude/rules/shipped-assets.rule.md +++ b/.claude/rules/shipped-assets.rule.md @@ -23,8 +23,14 @@ subagent frontmatter) and are not standardized across products, so an agent that kind simply declares no directory for it and takes no links of that kind. Ships now: the `ai-tools-reference-architect` agent, the `ai-tools-docs-*` -documentation skills (`reference`, `usage`, `comments`, `changelog`), and -`ai-tools-engineering-principles`. +documentation skills (`reference`, `usage`, `comments`, `changelog`), +`ai-tools-engineering-principles`, and `ai-tools-capable-systems-governance`. + +An asset is a **tree**, not a file: a skill may carry supporting material beside its `SKILL.md` +(`ai-tools-capable-systems-governance/references/framework.md` is the normative text its `SKILL.md` +defers to, so the working guidance stays short and the long text loads only when it is needed). The +seeder copies a directory asset whole (`cp -rT`) and applies the modes recursively, and the linker +places one symlink for the asset's top directory, so nesting needs nothing of either. ## Placement: one rule, four hops diff --git a/.claude/rules/tests.rule.md b/.claude/rules/tests.rule.md index f1d24fbf..1b971a94 100644 --- a/.claude/rules/tests.rule.md +++ b/.claude/rules/tests.rule.md @@ -16,7 +16,11 @@ identity to derive, and fixtures built root-owned would be skipped by every owne test — a suite that passes while proving nothing. It streams each file's output live, then — on any failure — reprints the failing files and their `FAIL` lines as an end-of-run summary, so a long run needs no scrolling; an all-green run prints no -summary and exits zero. A green file that recorded no `PASS` (every check skipped, or no +summary and exits zero. Each file runs under a per-file wall-clock budget +(`AI_TOOLS_TEST_FILE_TIMEOUT`, default 600s): a file that blocks — on a terminal read, a wedged +daemon, or a fixture process holding a pipe open — is killed and reported as a failure with its +transcript, rather than hanging the run. That matters beyond convenience because `install.sh` +runs this suite as its verification phase, so an unbounded file stalls an install. A green file that recorded no `PASS` (every check skipped, or no harness result line) and a category with no test files are listed in an end-of-run `no coverage` notice, parsed from `finish()`'s result line: green-by-exit-status alone cannot hide a run that proved nothing. The default stays lenient — a partial/dev install @@ -92,6 +96,35 @@ fixture tree in its testdir. It carries the same standing as the three above — reachable only as root, `sudo` strips the name, and a caller who could set it may already edit those files outright — and is unset in production, where the registry paths stand as written. +`AI_TOOLS_ENTRYPOINT_PIN_DIR` (`entrypoint-verify.lib.sh`) is the fifth, and it carries more weight +than the others: the production pin decides whether a session may launch at all, so a test that +wrote a deliberately wrong checksum into it would refuse every launch on the host until the next +reconcile. `integration/ai-tools-run.sh` redirects it at a `mktemp -d` instead and drives the +mismatch refusal through the deployed shim — the feature's actual guarantee, and the one gate that +needs a VALID executable to reach, since every other refusal case exits before it. Its complement +(an unpinned entrypoint must NOT be refused, or an air-gapped host stops launching) is deliberately +left to the pure verdict: nothing else about that run is invalid, so driving it would start a real +session. + +`AI_TOOLS_LAUNCHER_DIR` (`relabel.lib.sh`) is the sixth, and the one hook no automated test +consumes. It redirects where the entrypoint reconciliation looks for an agent's stable launcher +symlink, which is what lets the `stale` verdict be driven **end to end on a live host** — point it +at a directory whose `claude` link resolves to a real file the declared pattern does not cover, and +`ai-tools-relabel-agent` must print the stale warning and exit non-zero: + +``` +sudo bash -c 'n=$(find /opt/ai-tools/.nvm/versions/node/*/lib/node_modules/@anthropic-ai/claude-code/node_modules -name claude -type f | head -1); d=$(mktemp -d); ln -s "$n" "$d/claude"; env AI_TOOLS_LAUNCHER_DIR="$d" /usr/local/libexec/ai-tools/ai-tools-relabel-agent; echo "exit=$?"; rm -rf "$d"' +``` + +It is not in the suite because the full function registers a `semanage fcontext` rule, and this +suite does not mutate the host's SELinux policy to test a helper — the same line +`integration/selinux.sh` draws for `ai_tools_unlabel_project`. The check above is safe *because* it +re-asserts the rule that is already registered, leaving the policy store unchanged; a fixture +manifest would not, which is why the hook redirects the **launcher** rather than the manifest +directory. The pure decision behind the verdict is covered hermetically in `unit/relabel.sh`, and +the live agreement between declaration and installation in `integration/selinux.sh`; what this +exercises is the wiring between them and the renderer's exit status. + ## Two-ended assertions A security guarantee is covered by a **pair** of tests, not one, and the pair is what makes the @@ -217,6 +250,9 @@ The **last-run stamp** is the one input here a non-root writer controls and it i operator's terminal, so every way a hostile or corrupt value could reach that terminal — a symlinked stamp, a control byte or escape sequence, an over-long or unanchored line — is driven and must read as *no value*, degrading the unit to `unknown` rather than to a wrong verdict. And +the age reader takes the key it reads (default `FINISHED`), because the entrypoint pin +records a `VERIFIED` time in the same grammar and both must age through one implementation — so the +default is pinned too, a regression there silently turning every existing caller's age unknown. And the **freshness** mapping exists for a failure a `RESULT` cannot express — every recorded run succeeds while the schedule driving them has stopped — so the file asserts that a successful run goes `stale` past `max_age`, that a failed one stays `failed` at any age, that an unknown or @@ -229,7 +265,31 @@ entrypoint file-context predicate (`relabel.lib.sh`). A declared pattern becomes rule granting `ai_tools_exec_t`, the confined domain's exec entrypoint, so the test drives every way a pattern could name something outside the sandbox toolchain (traversal, alternation, a foreign prefix) and asserts each is refused — plus that the type is the library's constant, never -manifest-supplied. +manifest-supplied. It then pins the two pure decisions behind the declared-vs-installed +reconciliation: `ai_tools_entrypoint_reconcile_verdict` over its whole truth table — where `stale` +is the verdict that must fail a relabel, being the one cause a rerun cannot clear, and an +uninterpretable flag must err toward it rather than toward blessing a divergence — and +`_ai_tools_entrypoint_path_reportable`, the allowlist that keeps an agent-influenced path from +splitting a status line or carrying an escape sequence to the operator's terminal. Both are pure, +so they need no provisioned host; the resolution they consume is exercised in +`integration/selinux.sh`. + +`entrypoint-verify.sh` pins the pure half of the entrypoint verifier (`entrypoint-verify.lib.sh`, +see [updater](updater.rule.md)). Every assertion targets a way the gate could fail **open**: an +absent pin must read as `unpinned` and never as a mismatch (collapsing them would report a fresh +install as tamper, or — inverted — bless a tampered one); a checksum is admitted only in exact +64-hex shape, so malformed JSON, an absent platform, or a crafted value yields nothing rather than +a value that could compare equal to a partial observation; a URL template with no `{version}` slot +is refused rather than fetched as-is, since one manifest for every version reads as "verified" +while checking a release it never looked at; and the template charset admits nothing that could +carry a shell metacharacter or a traversal into `curl`. It also pins the public pin path, which `ai-tools --status` reads to report verification +state: an agent name becomes a path component, so a name that could escape the pin directory must +yield nothing. It closes with the one impure assertion that needs no vendor: the library refuses a +**non-root** pin write itself, rather than letting it fail on `EACCES`, so the caller can tell "not +permitted" from "the directory is missing". The +signed-manifest probe is not driven here — it needs the vendor's live endpoint, `gpgv`, and a +300 MB hash — and its boundary half (neither the pin, the pin directory, the shipped key, nor the +library is agent-writable) is in `boundary/access.sh`. `selinux-groups.sh` pins the optional-group registry (`selinux-groups.lib.sh`, shared by `ai-tools-admin selinux` and `install-selinux.sh`): the four-field accessors (including the @@ -251,8 +311,8 @@ fail-closed load of `safe-paths.lib.sh`, and consultation of the protected-paths the launch CWD), the handback `socket → daemon → helper` chain (including its negative paths — unknown verb, wrong/empty/non-absolute/control-character args, and an out-of-allowlist CHOWN all refused), the CLI principal guard (refuses root and the sandbox account), `ai-tools-run`'s -`AI_TOOLS_AGENT_EXEC` / `AI_TOOLS_PROJECT_DIR` re-validation (a bad value is refused before any -session launches — including a real sibling binary in the same versioned `bin` directory, which +`AI_TOOLS_AGENT_EXEC` / `AI_TOOLS_PROJECT_DIR` re-validation and its entrypoint gates (a bad value — +or an entrypoint that does not match its pin — is refused before any session launches — including a real sibling binary in the same versioned `bin` directory, which is refused because no enabled agent manifest claims that launcher, and a non-semver version directory) plus its pinned session-confinement properties (`RestrictNamespaces`/`NoNewPrivileges`/`UMask`), the claude-code session-env pins @@ -266,7 +326,9 @@ shim has one file to answer to; `handback.sh` keeps the bridge and the entrypoin `selinux.sh` asserts the confinement layer is actually enforcing: when the `ai_tools` module is loaded the system is `Enforcing` and neither `ai_tools_t` nor `ai_tools_handback_t` is marked permissive; it skips when the module is absent (the layer is -optional). `systemd.sh` is the single home for unit checks: +optional). It also holds the two entrypoint assertions that need a labelled host — that each +agent's declared file-context rule still covers what its package installed, and that no link in +the exec chain carries a type the confined domain may manage. `systemd.sh` is the single home for unit checks: `systemd-analyze verify` on each shipped unit, plus enablement in the correct instance — the `nvm-update` timer in the sandbox account's own `--user` instance, the relabel watcher and handback socket in the system instance. The @@ -291,7 +353,16 @@ it through `tests/run.sh all`. Adding or repermissioning an installed file means control plane, cannot reach the operator's credential stores (`~/.ssh`, `~/.gnupg`, …), and holds no sudo rights — `sudo -l` reports it is not allowed to run sudo at all (both NOPASSWD rules belong to the projects user and drop privilege), plus the account hygiene that invariant -leans on (nologin shell, locked password, non-membership in `ai-ops`). `providers.sh` asserts +leans on (nologin shell, locked password, non-membership in `ai-ops`). It also asserts the agent +cannot write the **pin**, the pin directory, the shipped signing key, or the verifier library — the +inputs that decide what a verified checksum is — so it can neither record nor authorise a checksum +for a binary it modified. Those are root-owned files, so they are DAC facts and this vantage sees +them. Its one assertion that is not a permission check is the journald one: it *writes* a line as +the agent under a root helper's syslog tag and asserts journald files it under the sandbox uid and +not under `_UID=0`. That is the boundary half of the documented query form (see +[logging](logging.rule.md)) — the forgery is reachable, and what makes it separable is the uid the +sender cannot set, not the tag. A host with no journald skips: an absent line proves nothing. +`providers.sh` asserts the deployed half of "the sandbox cannot widen its own surface": none of `operator.conf`, `conf.lib.sh`, `providers.lib.sh`, the three provider directories, the manifests and fragments in them, or the `ai-tools-run` shim and the `bin` directory holding it is agent-writable, while @@ -305,7 +376,11 @@ non-agent-writable — the engine because it is sourced as the agent on every Ba sets because they decide what every command in a session becomes. These probe **DAC and account state** from the sandbox account's vantage — they run as the sandbox *user*, not inside the `ai_tools_t` SELinux domain (a launched session), so they assert the filesystem/credential -boundary; the SELinux enforcing posture is asserted separately in `integration/selinux.sh`. +boundary; the SELinux enforcing posture is asserted separately in `integration/selinux.sh`. A +property the **type layout alone** enforces is therefore not assertable here, and reads as its DAC +answer: the agent's inability to write its own entrypoint is one (DAC permits it — the account owns +that tree), so it is asserted in `integration/selinux.sh` as the layout the policy rests on, one +check per swap vector. ## Quirks diff --git a/.claude/rules/updater.rule.md b/.claude/rules/updater.rule.md index 6153f3e1..37f2a07c 100644 --- a/.claude/rules/updater.rule.md +++ b/.claude/rules/updater.rule.md @@ -2,6 +2,8 @@ paths: - "src/opt/ai-tools/bin/nvm-update.sh" - "src/usr/local/lib/ai-tools/npm-verify.lib.sh" + - "src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh" + - "src/usr/local/lib/ai-tools/keys/**" - "src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh" - "src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh" - "src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh" @@ -218,6 +220,15 @@ The type is pinned in `relabel.lib.sh` and a declared pattern is accepted only w nothing outside the sandbox toolchain root (no traversal, no alternation, an anchored literal head), so a manifest chooses **which** file is its entrypoint, never what label a file gets. The whole body lives in `relabel.lib.sh`, shared with `install-selinux.sh`'s verify pass. + +The helper then **reconciles** what it applied against what is installed: it resolves +`/opt/ai-tools/bin/` the way the launch preflight does and reports `stale` — non-zero — +when an entrypoint is installed at a path the declared pattern does not cover, instead of the +`none`/success a pattern matching nothing would otherwise produce. So a relabel that exits 0 means +the next launch will not fail closed on the entrypoint label, and a manifest that has stopped +describing its own package is named as the cause rather than diagnosed as a missing install. It +never labels the resolved path: the files that take `ai_tools_exec_t` stay exactly those the +root-owned manifests declare (see [agent-claude-code](agent-claude-code.rule.md)). `ai-tools-relabel-agent --remove ` is the erase-time counterpart: the agent package's `%preun` drops its rule while its manifest is still on disk. @@ -243,8 +254,11 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: `ai-tools --relabel`. The watcher is **enabled by default** on install through the shipped systemd preset — `%systemd_post ai-tools-relabel.path` applies `85-ai-tools.preset`, which lists it beside the handback socket; without that explicit line the distribution's `disable *` default - would leave `%systemd_post` a no-op (the same enablement the socket needs) — and it is restarted - across upgrades (`%postun_with_restart`), so it needs no manual bootstrap. Should it be down + would leave `%systemd_post` a no-op (the same enablement the socket needs). Enabling a `.path` + unit does not start it, so the `ai-tools-integration-nodejs` `%posttrans` starts it — the twin of + `ai-tools-base`'s `%posttrans` starting the handback socket — making the watcher live on a fresh + install without a reboot; it is also restarted across upgrades (`%postun_with_restart`), so it + needs no manual bootstrap. Should it be down anyway, `services.lib.sh` surfaces it before the next Node bump would fail-close a launch on a mislabelled entrypoint: proactively at launch (`claude.sh` warns, warn-not-block, from the same registry) and in `ai-tools --status` (see [cli](cli.rule.md)). @@ -306,6 +320,81 @@ action) — warns and proceeds, since the update itself is not the danger and th best-effort against such hosts. The signing keys are fetched from the registry keys endpoint (`/-/npm/v1/keys`) over HTTPS on each run. +## Entrypoint verification and the pin + +The checks above attest to what was **delivered**. Neither can see what the entrypoint *is now*: the +exec root is sandbox-owned, and `npm install -g` does not reinstall an unchanged version, so on a +DAC-only host a modified entrypoint persists across sessions and operators indefinitely (under +SELinux the vector is closed outright — see [confinement](confinement.rule.md)). +`entrypoint-verify.lib.sh` closes it by comparing the installed entrypoint against a checksum the +**vendor signed**. The three optional manifest fields that declare where to find it — and why the +signing key is shipped rather than fetched — are in [providers](providers.rule.md); the library +itself names no agent. + +The work splits by principal, which is what keeps the network off the launch path. The operator +cannot read the entrypoint at all (the toolchain is `0750` sandbox-owned), so the verification runs +as **root**, and its result travels to the launch as a **pin**: +`/var/opt/ai-tools/state/entrypoint-pin.d/`, in the shared `KEY=value` grammar +(`AGENT`, `VERSION`, `SHA256`, `VERIFIED`, `SOURCE`). Its directory is root-owned and not +group-writable inside the `0750 root:SANDBOX_GROUP` state root — the same two independent layers +(DAC, plus `usr_t` under enforcing) that bound the last-run stamp — so the account the pin +constrains can read it and cannot write it. It is read back defensively: symlink refused, bounded +read, and a value admitted only in exact 64-hex shape, so a corrupt pin reads as *unpinned* rather +than as a wrong verdict. + +| when | who | what | +|---|---|---| +| provision | `ai-tools-bootstrap` (root) | reconciles the entrypoint, which pins it | +| update | `nvm-update` (sandbox) | verifies **before** the repoint — the same position as the npm signature gate | +| update | `ai-tools-relabel.service` (root) | the repoint fires the existing `.path` watcher → reconcile → **write the pin** | +| launch | `ai-tools-run` (sandbox) | hash the entrypoint, compare to the pin — no network, no `gpgv`, no key | + +There is deliberately **no manifest cache**. Every fetch happens at a moment the host is already +online (immediately after an update downloaded the package), and the pin is what makes the launch +offline-safe, so a cache would add an input to reason about for no availability gained. + +### Three outcomes, and only one of them is tamper + +The status contract is `npm-verify.lib.sh`'s, so the two gates in `nvm-update` read alike: `0` +verified, `1` **mismatch**, `2` **unable to verify**. Keeping `1` and `2` apart is the whole +usability of the feature, and `gpgv`'s own exit status separates them exactly — `1` for a signature +it rejects, `2` for a key it does not hold. + +- **Mismatch** refuses, everywhere and unconditionally: the relabel fails, the updater declines to + activate, the launch refuses. No configuration turns it off. +- **Unable to verify** — offline, no manifest published for that release (they are per-release and + not guaranteed), no `gpgv`, an agent declaring no provenance, or a **vendor key rotation** — warns + and proceeds, leaving any previous pin standing. A rotation reporting as tamper would fail every + host closed on an untouched binary, so the agent package ships old and new keys in one keyring and + declares both fingerprints for the overlap. + +`AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY` (`operator.conf`, read through +`ai_tools_entrypoint_verify_required` so the updater and the launch cannot disagree about how strict +the host is) turns the *unverifiable* case into a refusal: the launch will not start an unpinned +entrypoint, and the updater will not activate a release it could not verify. Its default is **no**, +and that is an air-gap decision — unpinned is also the state of a host with an internal npm mirror +and no vendor route, and blocking there would quietly freeze its agent forever. Nothing in this +layer hard-fails offline: the fetch carries a short `--connect-timeout` because it runs inside the +relabel, and so inside an rpm `%post` that must succeed offline. + +### Why the pin lives in the relabel helper + +`ai-tools-relabel-agent` verifies and pins **before** it labels, on every host — including the +DAC-only one, where the labelling half has nothing to do. Both halves answer one question, *the +entrypoint changed, reconcile it*, and they share the three things that would otherwise be +duplicated: the **trigger** (`ai-tools-relabel.path` watches the launcher directory, so it fires on +exactly the event that changes an entrypoint), the **privilege** (root, which the sandbox-account +updater does not have), and the **timing**. Splitting them would buy one name at the cost of a +second `%ai-ops` sudoers rule and a second unit for a step that must run at the same instant anyway +— so `--relabel` keeps its established name and its scope is stated to be the whole reconciliation, +not the SELinux half alone. + +The costs of that folding are bounded rather than absent, and both are handled where they arise: a +networked step now sits inside an otherwise-local verb (which is why it fails soft and connects with +a short timeout), and a pin mismatch fails a command an operator may have run for a label (which is +correct — an entrypoint that is not the binary its vendor published is the more serious finding, and +it is reported first). + ## Deferred **Pinning the registry signing key.** Fetching the keys each run detects a mirror or cache @@ -317,3 +406,9 @@ verifier and the free transitive-tree coverage, and must track npm's key rotatio already serves one retired and one active key) or a rotation breaks updates. TLS covers the man-in-the-middle key swap, so pinning is defense in depth against a primary-registry root-of-trust compromise, held against that cost. + +For the **agent binary** specifically that gap is now closed from the other side: the entrypoint +verification above pins its key in a root-owned file rather than fetching one, so a compromised +registry serving a forged package, signature, and keys together still fails the release-manifest +comparison. What stays deferred is the rest of the toolchain — Node and npm itself — where no +equivalent signed-checksum manifest is consumed. diff --git a/CLAUDE.md b/CLAUDE.md index 04035283..107da775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,18 +48,21 @@ the management CLI (`ai-tools`), and root-helper binary names (`ai-tools-chown`, | Area | Source | Rule | |---|---|---| -| Launch, allowlist gating, sudoers, PATH, custom system prompt | `bin/ai-tools-run.sh`, `usr/local/bin/claude.sh`, `lib/ai-tools/claude-prompt.lib.sh`, `allowed-projects`, `sudoers.d/ai-tools`, `lib/ai-tools/path-dedup.sh` | [launch](.claude/rules/launch.rule.md) | +| Launch, allowlist gating, sudoers, PATH, the wrapper contract | `bin/ai-tools-run.sh`, `allowed-projects`, `sudoers.d/ai-tools`, `lib/ai-tools/path-dedup.sh` | [launch](.claude/rules/launch.rule.md) | +| **Provider-specific: claude-code** — its wrapper, manifest, entrypoint chain and labelling, custom system prompt, custom API endpoint, session pins, distribution channel | `usr/local/bin/claude.sh`, `lib/ai-tools/claude-{prompt,endpoint}.lib.sh`, `lib/ai-tools/agents.d/claude-code.conf`, `lib/ai-tools/session-env.d/claude-code.env.sh` | [agent-claude-code](.claude/rules/agent-claude-code.rule.md) | | Namespaces, SELinux transition, preflight, `/tmp`, optional-group management, how the policy ships and why it is separately licensed | `selinux/**`, `bin/ai-tools-run.sh`, `selinux-groups.lib.sh`, `ai-tools-admin.sh` (`selinux` subcommand), `packaging/ai-tools.spec` (`ai-tools-selinux`) | [confinement](.claude/rules/confinement.rule.md) | | Root-op socket (daemon/client/units) | `ai-tools-handback*`, `ai-tools-handback-client*` | [handback-bridge](.claude/rules/handback-bridge.rule.md) | | Hooks, sweeps, `.git` reclaim, setgid, control-plane integrity | `opt/ai-tools/agents/**`, `ai-tools-chown.sh`, `ai-tools-setgid.sh`, `owner-only.lib.sh` | [ownership-and-hooks](.claude/rules/ownership-and-hooks.rule.md) | | Claude Code settings, Bash deny rules ↔ SELinux policy | `opt/ai-tools/agents/*/settings.json` | [claude-settings](.claude/rules/claude-settings.rule.md) | | Token-saving command filters: rewrite rules + output noise stripping | `filters.lib.sh`, `lib/ai-tools/filters.d/**`, `agents/*/filter-hook.sh` | [filters](.claude/rules/filters.rule.md) | | Shipped assets: shared skills + per-agent agents, their placement chain and seeding | `usr/share/ai-tools/**`, `lib/ai-tools/managed-assets.lib.sh` | [shipped-assets](.claude/rules/shipped-assets.rule.md) | +| Governance posture: enforced vs dispositional, proportionality, the agent's own conduct and the controls beside it | `usr/share/ai-tools/skills/ai-tools-capable-systems-governance/**` | [governance](.claude/rules/governance.rule.md) | | Secret-named files, lockdown, pattern set | `ai-tools-lockdown.sh`, `ai-tools-chown.sh`, `secret-patterns*` | [secrets](.claude/rules/secret-handling.rule.md) | -| Toolchain provisioning + Node/claude updater, symlink repoint, post-upgrade relabel | `ai-tools-bootstrap.sh`, `nvm-update.sh`, `ai-tools-launcher-symlink.sh`, `ai-tools-relabel-agent.sh`, `nvm-update`/`ai-tools-relabel` units | [updater](.claude/rules/updater.rule.md) | -| Provider manifests + fail-closed enablement (agents + integrations), the shared `KEY=value` config grammar, the `session-env.d` session-env seam, the claude-code custom API endpoint, and the dotnet integration | `lib/ai-tools/{conf,providers}.lib.sh`, `lib/ai-tools/{agents,integrations,session-env}.d/**`, `lib/ai-tools/claude-endpoint.lib.sh`, `ai-tools-dotnet.sh`, `operator.conf` `AI_TOOLS_{AGENTS,INTEGRATIONS}` | [providers](.claude/rules/providers.rule.md) | +| Toolchain provisioning + Node/claude updater, symlink repoint, post-upgrade entrypoint reconciliation (signed-release verification + relabel) | `ai-tools-bootstrap.sh`, `nvm-update.sh`, `ai-tools-launcher-symlink.sh`, `ai-tools-relabel-agent.sh`, `entrypoint-verify.lib.sh`, `keys/**`, `nvm-update`/`ai-tools-relabel` units | [updater](.claude/rules/updater.rule.md) | +| Provider manifests + fail-closed enablement (agents + integrations), the shared `KEY=value` config grammar, the `session-env.d` session-env seam, and the dotnet integration | `lib/ai-tools/{conf,providers}.lib.sh`, `lib/ai-tools/{agents,integrations,session-env}.d/**`, `ai-tools-dotnet.sh`, `operator.conf` `AI_TOOLS_{AGENTS,INTEGRATIONS}` | [providers](.claude/rules/providers.rule.md) | | Running .NET (CoreCLR) under confinement: the dotnet integration files ↔ the `tmpmap`/`apphost`/`netcore` SELinux groups, project-type→group map, denial breakdown | `lib/ai-tools/session-env.d/dotnet.env.sh`, `lib/ai-tools/filters.d/dotnet.rules`, `ai-tools-dotnet.sh`, `selinux/policy/ai_tools_{tmpmap,apphost,netcore}.te` | [dotnet](.claude/rules/dotnet.rule.md) | -| Management CLI, project lifecycle, relabel | `bin/ai-tools.sh`, `ai-tools-{setfacl,unclaim,safedir,relabel}.sh`, `relabel.lib.sh` | [cli](.claude/rules/cli.rule.md) | +| Management CLI, project lifecycle, relabel, acting for another operator (`--for`) | `bin/ai-tools.sh`, `ai-tools-{setfacl,unclaim,safedir,relabel,allowlist}.sh`, `relabel.lib.sh` | [cli](.claude/rules/cli.rule.md) | +| Terminating sessions that are already running (`--stop`) — the incident ladder's stop rung; takes no target, exempts nothing, restores the user manager | `ai-tools-stop.sh` | [cli](.claude/rules/cli.rule.md) + [docs/session-stop.md](docs/session-stop.md) | | Protected-paths backstop (refuse system dirs as targets) | `safe-paths.lib.sh` + the wrapper/CLI/elevated helpers | [safe-paths](.claude/rules/safe-paths.rule.md) | | Shared logging library | `log.lib.sh` | [logging](.claude/rules/logging.rule.md) | | User-facing message formatting (box, wrap, ties) | `msg.lib.sh` + its consumers | [messaging](.claude/rules/messaging.rule.md) | @@ -129,6 +132,7 @@ sandbox cannot improve its own position by breaking something. | which providers it gets | `ai_tools_conf_is_trusted` on every manifest, directory, and fragment | the default-enabled baseline, never "enable all" | | which paths handback may touch | born-`SANDBOX_USER` ownership, re-checked race-safely as root | the path is left alone | | which toolchain may be activated | npm registry signature verification | the previous, trusted version stays | +| which agent binary may start a session | its checksum against the vendor's signed release manifest, verified with a key the package ships and recorded in a root-owned pin | a mismatch refuses the launch; an unverifiable release is never activated where the operator required verification | The invariants the agent operates under: @@ -191,6 +195,23 @@ The invariants the agent operates under: operator home or the sandbox-clone area pass. See [safe-paths](.claude/rules/safe-paths.rule.md). +### What is expected of the agent where a control leaves a choice + +Every invariant above is **enforced**: it holds whether or not the session cooperates. The space +between them is not, and this is what the agent does there: + +- **Accept a stop or a restriction immediately** — not after finishing the current step. +- **Report a gap in the sandbox instead of using it.** A reachable way around a control is a + finding to raise, never a route to take. +- **Do not misrepresent what ran, what failed, or what was skipped.** +- **Do not work to widen the grant.** Ask the operator for an authority the work needs; never + arrange the state that would confer it. + +None of these keeps the host safe — that is what everything above is for, and each one names the +enforced control it sits beside in [governance](.claude/rules/governance.rule.md). They are stated +here, in the always-loaded layer, because a path-scoped rule does not load in the session where +they bind. The standard they come from is the shipped `ai-tools-capable-systems-governance` skill. + ## Boundaries and non-goals The enforced isolation boundary is DAC plus the `ai_tools_t` SELinux type. The following are @@ -203,7 +224,11 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o `bubblewrap`/`--system` isolation are deferred (see [confinement](.claude/rules/confinement.rule.md) and memory). - **`ai-ops` operators are trusted.** The model defends the host and other users from the - *agent*, not from an operator, who already holds the launch grant. + *agent*, not from an operator, who already holds the launch grant. `ai-tools --for ` + rests on this: it lets one operator write an entry into another's allowlist — their launch gate — + so a human can claim a project for a passwordless service account that runs an agent. The target + must be enrolled, every mutation is logged with both caller and target, and the agent reaches + none of it (see [cli](.claude/rules/cli.rule.md)). - **Toolchain provenance is checksum-, allowlist-, and signature-gated.** The updater checksum-verifies Node, gates npm install scripts behind an allowlist, and verifies the installed toolchain's npm registry signatures before activating it — failing closed on a @@ -242,11 +267,11 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o its manifest, and its session-env fragment, and inherits the single `%ai-ops` sudoers grant rather than adding one. See [launch](.claude/rules/launch.rule.md). - **Root sudo-helpers** live under `/usr/local/libexec/ai-tools/` (`chown`, `setgid`, `setfacl`, - `unclaim`, `safedir`, `reclaim`, `launcher-symlink`, `lockdown`, `relabel`, `bootstrap`, - `relabel-agent`, `admin`, `dotnet`); shared libraries under `/usr/local/lib/ai-tools/` + `unclaim`, `safedir`, `reclaim`, `allowlist`, `launcher-symlink`, `lockdown`, `relabel`, + `bootstrap`, `relabel-agent`, `admin`, `dotnet`); shared libraries under `/usr/local/lib/ai-tools/` (`conf`, `secret-patterns`, `skip-dirs`, `owner-only`, `safe-paths`, `relabel`, `operator`, `control-plane`, - `confinement`, `npm-verify`, `managed-assets`, `providers`, `selinux-groups`, `filters`, `services`, - `msg`, `log`), + `confinement`, `npm-verify`, `entrypoint-verify`, `managed-assets`, `providers`, `selinux-groups`, `filters`, `services`, + `msg`, `log`, and the claude-code pair `claude-prompt`/`claude-endpoint`), plus `path-dedup.sh`, the PATH-ordering fragment `ai-tools-admin` wires into operator dotfiles (see [launch](.claude/rules/launch.rule.md)). That directory is `0751 root:SANDBOX_GROUP` and its diff --git a/README.md b/README.md index a3691202..778e3665 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,23 @@ Agent Tools Restricted runs autonomous coding agents under a dedicated, unprivil ## Package install -Two commands. The first installs the dag-node release package, which brings the signed DNF -repository definition and the org signing key with it -([source](https://github.com/dag-node/rpm-dagnode-release)); the second pulls the stack. One +Import the org signing key, then install the dag-node release package and the stack. The release +package is signed by the org key, so `dnf` verifies its signature at install time — importing the +key first satisfies that check, since the package that would otherwise install the key has not run +yet. The release package brings the signed DNF repository definition and the key with it +([source](https://github.com/dag-node/rpm-dagnode-release)); the last command pulls the stack. One repository serves EL 9 and EL 10, and both the packages and the repository metadata are -signature-verified. +signature-verified. Verify the key fingerprint out of band before importing — see the +[repository README](https://github.com/dag-node/rpm/blob/main/README.md#signing-key). ```bash -sudo dnf install https://rpm.dagnode.com/dagnode-release-latest.noarch.rpm +# Import the org signing key (verify its fingerprint out of band first — see the README above) +sudo rpm --import \ + https://rpm.dagnode.com/RPM-GPG-KEY-dag-node + +# Install the release package (repo definition + key), then the stack +sudo dnf install \ + https://rpm.dagnode.com/dagnode-release-latest.noarch.rpm sudo dnf install ai-tools ai-tools-selinux # the whole stack + SELinux confinement ``` @@ -171,8 +180,18 @@ of what it can ever send: `/usr/share/ai-tools/skills/README.md`. - **Operation logging** — the `sudo` helpers, the lifecycle hooks, the `ai-tools` CLI, and `install.sh` log through one library to **journald** (always, leveled and - tagged: `journalctl -t ai-tools-chown`) and, for the root writers only, to + tagged: `journalctl -t ai-tools-chown _UID=0`) and, for the root writers only, to root-only files under **`/var/log/ai-tools/`**. +- **A working stop** — `sudo ai-tools --stop` terminates every agent session on the host and + everything it spawned. (To finish a session you are done with, use `/exit` inside it, which lets + it run its own ownership handback.) Sessions are found and killed by **cgroup**, so a child that + called `setsid(2)` or double-forked goes with them, and success means verified gone from the + kernel's view rather than from systemd's. It takes no path and no authorization input, and + exempts no cgroup — a stop path the session can put itself outside of is not a stop path — so the + sandbox account's own user manager is terminated too and restarted afterwards. The session takes + no part in any of it: the account it runs as can neither invoke, read nor alter the helper. What + each outcome means and what a stop cannot undo are in + [docs/session-stop.md](docs/session-stop.md). - **Auto-updating** — a `systemd --user` timer in `${SANDBOX_USER}`'s own instance keeps Node and `@anthropic-ai/claude-code` current under `/opt/ai-tools`, and a root-side watcher relabels the new entrypoint for SELinux after each upgrade. Each update verifies the @@ -204,6 +223,13 @@ model defends the host from the *agent*, not from an operator. The full trust mo non-goals, and the deferred hardening (per-operator isolation, registry-key pinning) are in [`CLAUDE.md`](CLAUDE.md#boundaries-and-non-goals). +The agent binary itself is verified against the checksum its vendor **signed**, using a key shipped +in the package rather than downloaded, and the verified value is pinned where the sandbox account +cannot write it — so a binary modified after installation refuses to launch. It needs no per-release +maintenance and no network at launch; what it checks, what each failure means, and how it behaves on +an air-gapped host are in +[docs/entrypoint-verification.md](docs/entrypoint-verification.md). + ## Identities and naming Three identities recur throughout this README, the scripts, and the templates. @@ -308,19 +334,52 @@ its whole lifetime by design. ## Operation logging +Start here — one command answers "has anything gone wrong lately?": + + sudo ai-tools --audit # findings in the last 7 days + sudo ai-tools --audit --since '2 days ago' # any window date(1) understands + +It reads the trails below and reports what refused, was rejected, was stranded, or was +flagged — a breached secret, a rejected socket peer, a helper timeout, a refused launch. It +exits non-zero when anything is reported, so it works from cron or a login banner without +parsing its output. Findings from the root-only files and refusals from the session's own +journald tag are reported **separately**, because only the first is a trail the agent cannot +write. + +Every tool call a session makes is recorded too, one line each: + + sudo journalctl -t ai-tools-hook _UID="$(id -u ai-tools)" # what the agent ran and wrote + sudo journalctl -t ai-tools-hook -o json _UID="$(id -u ai-tools)" | jq # structured fields + +A `Bash` record carries the command's leading two words and its argument count — never the +command line, which through a here-doc would carry file contents. The same facts are also +emitted as native journald fields (`AI_TOOLS_TOOL`, `AI_TOOLS_CMD`, `AI_TOOLS_ARGC`, +`AI_TOOLS_PATH`), so a journal ingester can select on them without re-parsing the message. + Two sinks — **journald** (all components) and **`/var/log/ai-tools/`** (root helpers -only, `700 root:root`). Query journald by component: +only, `700 root:root`). Query journald by component **and by the writer's uid**: + + sudo journalctl -t ai-tools-chown _UID=0 # the ownership-restore helper + sudo journalctl -t ai-tools-lockdown _UID=0 -p warning # the secret lockdown + sudo journalctl -t ai-tools-handback _UID=0 # the privilege bridge (one line per request) + sudo journalctl -t ai-tools-run _UID="$(id -u ai-tools)" # session launches + sudo journalctl -t ai-tools _UID="$(id -u)" # the CLI (project/sandbox created, …) + +The uid matters because a syslog tag is chosen by whoever writes the line, and the sandbox +account can write to `/dev/log` — so a session could emit a line under a root helper's tag. +`_UID` is stamped by journald from the sender's kernel credentials and cannot be forged, so +pairing it with the tag is what makes a line attributable. - sudo journalctl -t ai-tools-chown # the ownership-restore helper - sudo journalctl -t ai-tools-lockdown -p warning - sudo journalctl -t ai-tools-hook # the lifecycle hooks - sudo journalctl -t ai-tools-handback # the privilege bridge (one line per request) - sudo journalctl -t ai-tools # the CLI (project/sandbox created, …) +`ai-tools-hook` is the one tag no filter separates: the lifecycle hooks run **as** the agent, so +it is that tag's legitimate writer. Read those lines as the session's own account, and reconcile +them against the root-written trail — `/var/log/ai-tools/` is `700 root:root`, so the agent can +neither read nor append to it. The handback daemon keeps a per-request audit line — the peer PID, the verb, the path, and the helper result — plus a `WARNING` for every rejected peer or malformed request, so each privileged action is attributable at the socket layer. Root-only log files: `chown.log`, -`setgid.log`, `symlink.log`, `lockdown.log`, `handback.log`, `install.log`. +`setgid.log`, `setfacl.log`, `unclaim.log`, `safedir.log`, `allowlist.log`, `symlink.log`, +`lockdown.log`, `relabel.log`, `dotnet.log`, `handback.log`, `install.log`. ## SELinux diff --git a/docs/claude-options.md b/docs/claude-options.md index 04ee9926..dff0a43e 100644 --- a/docs/claude-options.md +++ b/docs/claude-options.md @@ -32,7 +32,10 @@ applies to every Claude Code user on the host, not only the sandbox account. | Option | Value | Where | Purpose | |---|---|---|---| | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1` | `settings.json` `env` | Opts out of telemetry, error reporting, `/feedback` upload, and the quality survey in one variable. | +| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | `131072` | `settings.json` `env` | Per-response output-token cap. Shapes response length and cost, not what a session may do. | | `disableAutoMode` | `"disable"` | `settings.json` | Removes `auto` from the `Shift+Tab` cycle and rejects `--permission-mode auto`, so a session confirms actions rather than acting autonomously. | +| `showThinkingSummaries` | `true` | `settings.json` | Re-shows the thinking blocks Claude Code hides by default, so the operator confirming an action sees the reasoning behind it. | +| `verbose` | `true` | `settings.json` | Shows Bash and command output in full rather than truncated. | | `DISABLE_AUTOUPDATER` | `1` | `ai-tools-run` `--setenv` | The agent's Node tree is not agent-writable; updates run out-of-band via the toolchain updater. | | `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `NODE_COMPILE_CACHE`, `SHELL` | sandbox paths | `ai-tools-run` `--setenv` | Structural pins coupled to the sandbox layout — do not override. | | `TERM`, `COLORTERM`, `LANG`/`LC_*`, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, `XDG_RUNTIME_DIR` | forwarded from operator | `ai-tools-run` `--setenv` | Terminal, locale, and outbound-proxy shaping imported by name from the operator's environment. | @@ -56,7 +59,7 @@ breaks the session layout. |---|---| | `ANTHROPIC_MODEL` | Override the default model (e.g. `claude-opus-4-8`). | | `CLAUDE_CODE_SUBAGENT_MODEL` | Separate model for subagents — the main cost lever (pair an Opus main with a Haiku/Sonnet subagent). | -| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output length per response. | +| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output length per response. Shipped at `131072`; a project entry overrides that value. | | `model` (key) | Default-model override as a settings key rather than an env var. | ### Privacy and cost @@ -99,8 +102,8 @@ Often the fix when a Bash or MCP call hangs. | `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` | Leave the terminal title unchanged. | | `CLAUDE_CODE_HIDE_CWD` | Hide the working directory in the startup banner. | | `USE_BUILTIN_RIPGREP` | Use the bundled ripgrep instead of a system one. | -| `showThinkingSummaries` (key) | `true` re-shows thinking blocks (hidden by default since 2.1.69). | -| `verbose` (key) | Show full Bash and command output. | +| `showThinkingSummaries` (key) | `true` re-shows thinking blocks (hidden by default since 2.1.69). Shipped `true`; set `false` in a project layer to turn it off. | +| `verbose` (key) | Show full Bash and command output. Shipped `true`; set `false` in a project layer to turn it off. | ### Behavior diff --git a/docs/entrypoint-verification.md b/docs/entrypoint-verification.md new file mode 100644 index 00000000..7d8309c4 --- /dev/null +++ b/docs/entrypoint-verification.md @@ -0,0 +1,152 @@ +# Entrypoint verification + +How `ai-tools` proves that the agent binary it is about to run is the one its vendor published, what +you have to do about it (almost always nothing), and what each failure means. + +## The short version + +Every agent has one **entrypoint** — the executable a session actually starts. `ai-tools` checks it +two ways: + +- **At update time**, against a checksum the vendor **signed**, using a signing key shipped in the + `ai-tools` package rather than downloaded. +- **At launch time**, against a **pin**: a small root-owned file recording the checksum that was + verified. The launch does no network I/O and needs no key — it hashes the binary and compares. + +If the binary changes after it was verified, the next launch refuses. That is the whole point: it +catches tampering that **persists** — modify the binary once, and every future session for every +operator would otherwise run it silently. + +You do not maintain any of this by hand. There is no per-release step. + +## The flow + +``` + ┌─ update ────────────────────────────────────────────────────────────────────┐ + │ │ + │ nvm-update (runs as the sandbox account, daily) │ + │ │ │ + │ │ npm install -g @anthropic-ai/claude-code → 2.1.240 installed │ + │ ▼ │ + │ ① verify BEFORE activating │ + │ fetch downloads.claude.ai/.../2.1.240/manifest.json{,.sig} │ + │ gpgv against the SHIPPED key (never a downloaded one) │ + │ sha256 the new entrypoint, compare to the signed checksum │ + │ │ + │ ✓ match → continue │ + │ ✗ MISMATCH → stop. 2.1.233 stays active and pinned. │ + │ ? can't tell → continue anyway, unpinned (offline / no manifest) │ + │ …unless AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes │ + │ ▼ │ + │ ② repoint /opt/ai-tools/bin/claude → 2.1.240 (via a root helper) │ + │ ▼ │ + └────────┼────────────────────────────────────────────────────────────────────┘ + │ the bin directory changed + ▼ + ai-tools-relabel.path ← a systemd watcher, already enabled + ▼ + ┌─ reconcile (root) ──────────────────────────────────────────────────────────┐ + │ ai-tools-relabel-agent │ + │ ③ verify again, and WRITE THE PIN │ + │ /var/opt/ai-tools/state/entrypoint-pin.d/claude-code │ + │ VERSION=2.1.240 SHA256=… │ + │ ④ relabel the entrypoint → ai_tools_exec_t (SELinux hosts only) │ + └─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─ launch ────────────────────────────────────────────────────────────────────┐ + │ claude → ai-tools-run │ + │ ⑤ sha256 the entrypoint, compare to the pin │ + │ match → start the session │ + │ MISMATCH → REFUSE │ + │ no pin → start (or refuse, if you required verification) │ + │ no network, no key, no vendor — just a hash and a compare │ + └─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Two things, two lifetimes + +The most common question is whether the signing-key fingerprint has to be updated per release. It +does not — it identifies the **signer**, not the release. + +| | what it is | where it lives | changes when | who updates it | +|---|---|---|---|---| +| the key + fingerprint | who is allowed to sign a release | the `ai-tools` **package** (`0644 root:root`, not a config file) | the vendor rotates its signing key — years, not releases | a signed package update (`dnf update`) | +| the pin | what *this* installed binary hashes to | `/var/opt/ai-tools/state/entrypoint-pin.d/` | every agent update | root, automatically, via the watcher above | + +One key signs every Claude Code release. So the static half needs no maintenance, and the +per-version half is derived automatically. + +**A key rotation is not an outage.** Until the package carrying the new key reaches your host, +verification reports *cannot verify* — never *tamper* — and says so, naming `dnf update` as the fix. +During a rotation the package ships both keys and declares both fingerprints, so there is no window +where neither works. + +## What you might have to do + +| you see | it means | do | +|---|---|---| +| nothing | the normal case | nothing | +| `entrypoint verified … and pinned` after an update | working as intended | nothing | +| `could not verify … pin unchanged` | the host could not reach the vendor, or no manifest exists for that release | nothing; it re-verifies on the next update. If it persists, check egress to `downloads.claude.ai` | +| `signed by a key the pinned keyring does not hold` | the vendor rotated its signing key | `sudo dnf update 'ai-tools-agents-*'` | +| a launch refused: `does not match the checksum its vendor signed` | **the binary changed after it was verified** | treat the toolchain as tampered: `sudo ai-tools-bootstrap`, and investigate if it recurs | +| a launch refused: `carries no verified checksum` | you set `AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes` and this entrypoint was never pinned | `ai-tools --relabel` (needs the host online) | + +`ai-tools --relabel` reconciles the entrypoint: it verifies and pins it, then fixes its SELinux +label. Both are answers to "the toolchain changed"; it is the same command you already run when a +Node upgrade leaves the entrypoint mislabelled. + +## Strictness + +By default a **mismatch** always refuses the launch, and an **unpinned** entrypoint launches +normally. Unpinned is not a suspicious state — it is also what you get on an air-gapped host, on one +whose vendor never published a manifest for the installed release, and on one that has not run a +reconcile yet. Refusing there would block untampered binaries. + +To require verification, in `/etc/ai-tools/operator.conf`: + +``` +AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes +``` + +Then only a verified entrypoint starts a session, and the updater additionally declines to activate +a release it could not verify — so an unverifiable release never becomes the one your launches would +have to refuse. This is the same shape as `AI_TOOLS_REQUIRE_SELINUX`: the tool cannot tell an +intentionally offline host from a degraded one, so you declare it. + +## Air-gapped and mirrored hosts + +Nothing here requires reaching the vendor: + +- The verification **fails soft**. No route to `downloads.claude.ai` means *cannot verify*, which + warns and continues. Connection attempts are short-timeout, so an offline host is not made slow. +- Installing the RPM offline works. The package's `%post` reconciles the entrypoint and the + verification step simply reports that it could not check. +- A host with an **internal npm mirror but no vendor access** keeps updating its agent normally; the + entrypoint is just left unpinned. Setting `AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes` on such a host + will freeze the agent at its last verified release — which is the point of setting it, but worth + knowing before you do. + +## What this does and does not protect against + +**Does:** a binary modified after installation — the case npm's own integrity hash and registry +signature cannot see, because they attest to what was *delivered*, not to what is on disk now. Since +`npm install -g` does not reinstall an unchanged version, such a change would otherwise persist +across sessions and across operators indefinitely. + +**Does not:** + +- It **detects**, it does not prevent. On a host running the SELinux policy the vector is already + closed outright — the agent cannot write its own entrypoint at all — so there this is drift + detection. On a DAC-only host it is the only check there is. +- It proves the binary is a **genuine** vendor release, not the **newest** one. Rolling back to an + older signed release still verifies. +- It says nothing about what the agent *does* once running. That is the sandbox's job: the confined + account, the project allowlist, and the ownership handback. + +## See also + +- `operator.conf(5)` — `AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY` and the other switches +- `docs/project-lifecycle.md` — claiming projects and running sessions +- `.claude/rules/updater.rule.md` — the mechanism, for contributors diff --git a/docs/session-stop.md b/docs/session-stop.md new file mode 100644 index 00000000..8ac6dda0 --- /dev/null +++ b/docs/session-stop.md @@ -0,0 +1,498 @@ +# Stopping a running session + +`ai-tools --stop` terminates agent sessions that are **already running**, and everything they +spawned. It is not how you finish a session you are done with — `/exit` inside the session is, and +it lets the session run its own session-end handback. This kills the process tree instead. +Every other operator control changes what the *next* launch gets: unclaiming a project, disabling a +provider, revoking an operator, relabelling an entrypoint. This is the one that acts on a session +in flight, so it is the rung an incident actually lands on. + +The property it holds is one sentence: **a stop that is asked for and reported as done has +happened.** Everything below follows from that. + +--- + +## Where each fact lives + +Four audiences read about this feature, and each fact has exactly one home. Follow the pointer +rather than expecting the same paragraph twice. + +| If you are… | Read | It holds | It never holds | +|---|---|---|---| +| an operator running the command | `ai-tools(1)`, then §1 here | the option grammar and exit codes (man page); what to run, what each outcome means, what to do next | why the code is shaped this way | +| a security or systems reviewer | §2–§3 here | the invariants relied on, where containment ends, residual failure modes | how to use the command | +| a contributor or coding agent | [`.claude/rules/cli.rule.md`](../.claude/rules/cli.rule.md) | the domain contract, and which project-wide conventions this component inverts | the reasoning behind each inversion — it links here | +| reading the code | `ai-tools-stop.sh`'s header and its function names | this file's local mechanism and its exit contract | the design essay | + +Two other rules carry a one-line note that this component is their exception, each linking back +here: [messaging](../.claude/rules/messaging.rule.md) (the confirmation defaults *yes*) and +[logging](../.claude/rules/logging.rule.md) (the logger loads best-effort). +[safe-paths](../.claude/rules/safe-paths.rule.md) no longer names this helper at all: it took a +caller-supplied path only for the per-project form, and there is no longer one. + +**The code is the fourth surface, and it is meant to be read directly.** The functions are named +for the question they answer, so the kill path reads as prose without a comment per line: + +``` +find_session_cgroups → has_own_tasks → cgroup_pids → cgroup_is_live +confirm_stop → end_session → terminate_gracefully → kill_outright → restore_user_manager +``` + +--- + +## 1. For the operator + +### One form + +``` +sudo ai-tools --stop # terminate every agent session on this host +sudo ai-tools --stop --dry-run # list what would be terminated, change nothing +``` + +Add `-y`/`--yes` to skip the confirmation, `--force` to skip the ten-second grace period and kill +immediately. `--all` is accepted and does nothing — every run already terminates every session, and +the flag exists only so a script that spells the intent out is not refused for being explicit. The +full grammar and every exit code are in `ai-tools(1)`. + +**There is no per-project form, and a path is refused rather than ignored.** Two reasons, and the +first is the one that matters: + +- *Attribution comes from the account being stopped.* A session is tied to a project by reading its + `WorkingDirectory` from the sandbox account's own `systemd --user` manager. That is fine for + telling you what is running; it is not fine for deciding what a stop reaches, because anything a + session reports about itself would then shape what gets terminated. A unit name is no better — on + a host without SELinux a session can reach that manager and choose its own. So attribution is + **reported, never obeyed**, and the set of things terminated is decided by the one fact a session + cannot influence: membership of the account's cgroup slice. +- *It is not a session-lifecycle command.* The routine way to end a session is `/exit`. This is the + incident rung, and an incident that warrants it is one that wants everything stopped. + +A path is refused with exit 2 rather than accepted-and-ignored, so that if targeted stopping is +ever built — which needs a session-to-project mapping recorded by **root** at launch, not the user +manager's word — `--stop ` moves from *error* to *accepted*. Nobody's existing command +silently changes meaning. + +### What you get + +Each session is listed with its unit, how many processes it holds and the project it is running in, +and the confirmation is answered against that list — agreeing to terminate "3 sessions" without +seeing which projects they are in is not agreeing to anything. A session whose project cannot be +read shows as `unknown` and is terminated like any other; attribution is for you to read, so a +missing one costs you a label rather than costing the stop a target. + +**Agent sessions and the account's own plumbing are counted separately.** The slice holds more than +sessions: the account's `systemd --user` and its `init.scope`, a dbus broker, and a login session +scope for every `sudo -u` that crossed `pam_systemd`. All of them are terminated — nothing is +exempt — but they are listed after the agent sessions and marked `(account plumbing)`, and the +headline gives the two counts apart: + +``` +1 agent session(s) will be terminated, with everything they spawned. … 3 unit(s) of the +ai-tools account's own plumbing (marked below) go with them -- nothing in the account's +slice is exempt -- and its user manager is restarted afterwards. + + SESSION PROCS PROJECT + stop ai-tools-claude-code-4711.service 1 /home//projects/api + stop session-c27.scope 4 unknown (account plumbing) + stop dbus-broker.service 2 /opt/ai-tools (account plumbing) + stop init.scope 2 unknown (account plumbing) +``` + +The split is **advisory, exactly like attribution, and for the same reason**: a unit name inside the +delegated subtree is the delegatee's to choose, so a session can name itself out of the agent class. +It gains nothing by doing so — both classes are enumerated, listed and killed identically, and +nothing here is consulted to decide what a stop reaches. What the split buys is that the line you +read first during an incident does not tell you four agents were running when one was. + +Only agent sessions produce a `--reclaim` line, because only they have a project to hand back. The +account's dbus broker reports `/opt/ai-tools` as its working directory — the control plane, which +the protected-paths backstop refuses — so listing it offered a remedy that cannot run. + +### A second run is not silent + +Running `--stop` again straight after a successful one is **not** a no-op, and that follows from +sparing nothing rather than being a defect in it. The user manager the first run restored is itself +inside the swept slice, so the second run finds it, terminates it, and restarts it again: + +``` +No agent session is running. 1 unit(s) of the ai-tools account's own plumbing (marked below) +are stopped regardless -- nothing in the account's slice is exempt -- and its user manager is +restarted afterwards. +``` + +The command is idempotent in **end state** — no sessions, manager up — which is what "re-running is +the remedy" means. It is not idempotent in what it *reports*, and it cannot be without either +exempting the manager (a cgroup a session could then move into, §2) or letting a name decide what is +swept. + +> **The confirmation defaults to *yes*.** A bare Enter, a pipe, a cron job and a login banner all +> proceed; only a deliberate `n` declines. This is the opposite of every other destructive command +> here, on purpose (§4). Use `--dry-run` to look without acting. + +Each session then gets **10 seconds** to exit on `SIGTERM` before it is killed. The report says +which pass ended it, because that is the most useful line in the trail afterwards: + +``` + stopped ai-tools-claude-code-4711.service (/home//projects/api) + KILLED ai-tools-claude-code-4823.service (/home//projects/web) did not exit within 10s +``` + +### Outcomes + +| Exit | Meaning | What to do | +|---|---|---| +| 0 | stopped and verified gone, or nothing was running | reclaim the projects it names (below) | +| 1 | something survived `SIGKILL` | see *A process survived* below | +| 2 | usage — an unknown option, or a path (this command takes no target) | run `ai-tools --stop` | +| 4 | you declined at the confirmation | nothing was stopped | +| 5 | the helper could not run (no cgroup v2, no sandbox account) | a broken host, not a failed stop | + +Exit 0 means precisely this: every session that existed when the command enumerated was terminated +and verified gone, and a final re-enumeration found nothing still live. It does **not** mean none +can start afterwards — see *Residual failure modes* in §2 — and it says **nothing about the user +manager**, whose restoration is reported separately and never folded into this status. + +### After a stop: reclaim + +A stop cannot run the agent's own session-end handback — that hook fires when an agent exits on its +own terms, not when it is signalled. Files written up to the last completed turn were already +handed back; the in-flight turn's writes may still be owned by the sandbox account. The command +names the command to run for each project it terminated: + +``` +ai-tools --reclaim /home//projects/api +``` + +The next session that starts in that project also notices the missing clean-exit marker, widens its +`.git` reclaim and warns you. + +### What a stop does not undo + +Stopping ends the process. It does not roll back what the session already did: files it wrote are +on disk, commits it made are in the repository, and anything it pushed to a remote is gone. This +rung is **containment, not reversal**. If the concern is what a session may still do rather than +what it has done, stop first and investigate second — that ordering is the point of the rung. + +### A process survived (exit 1) + +A task only outlives `SIGKILL` while blocked in an uninterruptible kernel call (`D` state): a hung +NFS mount, a wedged block device, a stalled page fault. Worth knowing before you escalate: such a +task holds no CPU, executes no instructions and can start nothing new — it is stopped in every +sense that matters — but only the I/O completing or a reboot clears it from the process table. + +``` +sudo ps -o pid,stat,wchan:20,cmd -u ai-tools +sudo cat /proc//stack +``` + +### Reading the trail + +Every run is recorded twice: to journald, and to a root-only file the sandbox account can neither +read nor append to. + +``` +sudo cat /var/log/ai-tools/stop.log +sudo journalctl -t ai-tools-stop _UID=0 -n 50 +journalctl -t ai-tools-stop -o json | jq 'select(.AI_TOOLS_RESULT)' +``` + +Recorded: the request and who made it, which path gave consent (`flag`, `prompt`, +`fallback-prompt`, `no-tty` — the last means nobody was asked, which is legitimate and is exactly +what you want to see when asking why a session stopped at 4am), each session ended and which pass +ended it, and anything that survived. An interrupted run records that too, so silence in the trail +is never ambiguous. + +One operator ending another operator's work is a thing the trail shows. That is deliberate: all +sessions run as one shared account, so the trail is the only place the *human* behind a stop is +recorded. + +### The drill + +`tests/manual/verify-live-flows.sh --stop-all-drill` runs the destructive form end to end and +checks the result against the kernel. An escalation ladder nobody has climbed is a document, not a +control — run it deliberately, periodically, and read the trail afterwards. Without the flag the +same section still exercises everything reversible (the dry run, both refusals, the trail). + +--- + +## 2. For the security specialist + +### The threat this rung answers + +A session is not a service. It is an interactive process with tool access, running as a shared +service account, able to spawn arbitrary children — and, in the case this control exists for, one +that may be doing something an operator wants ended *now* and may not cooperate. So the stop path +is designed against a session that is at best indifferent and at worst adversarial, and it is +judged by one question: **can the monitored system decline, evade, or disarm it?** + +### The invariants it rests on + +| # | Invariant | Where it comes from | What would break it | +|---|---|---|---| +| I1 | A process cannot leave the cgroup it is in, and a child inherits it across `fork()`. Membership survives `setsid(2)` and the double fork that re-parents to PID 1. | `cgroups(7)`; a task moves only when something writes a `cgroup.procs` | a kernel that lets a task rewrite its own membership | +| I2 | Every process of the sandbox account lives under that account's per-user slice, `user-.slice`. | `systemd-logind(8)` places user processes there; the account has no login shell, so nothing else creates one | a process escaping to another slice — needs the user manager, which SELinux denies and DAC-only leaves as a residual | +| I3 | `SIGKILL` is neither catchable nor blockable, and `cgroup.kill` (Linux ≥ 5.14) delivers it to a whole cgroup **atomically** — one write freezes the cgroup and kills every member including descendants. | `signal(7)`; `cgroups(7)` | nothing in userspace | +| I4 | The kernel answers "is anything alive here" itself: `cgroup.events`' `populated` field is 1 while the cgroup **or any descendant** holds a live process. | cgroup v2 interface files | a threaded subtree, where `cgroup.procs` reads fail — handled by corroborating with `cgroup.threads` | +| I5 | Only root may signal across accounts and write `cgroup.kill`. The sandbox account holds no `sudo` rule, and the session runs under `PR_SET_NO_NEW_PRIVS`, which drops `sudo`'s SUID bit outright. | the sudoers drop-in; `ai-tools-run`'s unit properties | an operator adding a rule for the sandbox account | +| I6 | The set of sessions terminated is decided by cgroup-slice membership alone — no input the account can write reaches that decision, because the command takes no target and no authorization argument. | this command's own grammar | adding a per-project form scoped by anything the account reports | +| I7 | `/proc` and `/sys` reads plus bash builtins need no external binary. The kill path calls `sleep` and nothing else. | the helper's own construction | — | + +Two of these carry the whole design. **I1 is why discovery is by cgroup**: a process group is +broken by `setsid(2)` and a ppid walk is broken by the double fork, so neither can enumerate what a +session spawned. **I3 is why the kill is by cgroup too**: signalling a list of pids races the fork +that happens between reading the list and signalling it, and `cgroup.kill` has no such window. + +### Who may stop what + +Any enrolled operator may run it, and it terminates every agent session on the host — including +other operators' sessions. That is deliberate, and it is not a widening: `--all` never took an +authorization input, so an operator could always end everyone's sessions. What changed is that +there is no longer a *politer* form beside it, because the politer form was not a control. + +A per-project form would have to decide which sessions belong to a project, and every available +answer is written by the account being stopped — the unit's `WorkingDirectory`, its name, its +launch line. Scoping a stop by any of them lets a session influence what a stop reaches, which is +exactly inverted. The alternative that would work is a session-to-project mapping recorded by +**root** at launch (through the handback socket, whose peer uid the kernel supplies), and that is +real machinery: a verb, a registry, and its lifecycle. It is not built, because the routine way to +end a session is `/exit`, and the incident that reaches for this rung wants everything stopped. + +The operational consequence, stated plainly rather than left to be discovered: **on a shared host +one operator's stop ends every operator's sessions.** The trail records who asked. + +### Where containment ends + +systemd **delegates** the per-user subtree to the account — that is what lets its user manager +create units at all — and delegation permits the delegatee to move its own tasks between cgroups +*inside* that subtree. Containment holds at the delegation boundary, not within it. + +On a host with the SELinux module loaded this is closed outright: `ai_tools_t` is granted nothing +on `cgroup_t` (only a `dontaudit … getattr`), so a session can neither write a `cgroup.procs` nor +create a cgroup, and it has no `connectto` on the user runtime socket, so it cannot reach its own +user manager to ask for a unit either. **On a DAC-only host both are reachable**, since the account +owns the delegated cgroup files and its own bus socket. + +This is why the sweep **spares nothing** — not the account's own `systemd --user`, not its +`init.scope`. An exemption is a destination: any cgroup left out is one a session on a DAC-only +host can move into, whether it is named or identified by exact path. Sparing nothing means there is +nowhere to move to, and a unit the session asks its manager to create is still inside the slice +being swept. It also means **no unit name appears in the helper at all**, so there is nothing here +to differ between EL9, EL10 and Fedora. + +The cost is that the user manager is terminated too, and `SIGKILL` leaves `user@.service` +**failed** rather than restarting it. That is paid back rather than exempted: `restore_user_manager` +runs after the kill *and* after the verification, restarts the unit, and reports the outcome on its +own — a manager that did not come back never changes what the command says about the stop. If it +fails, the message names the fix and the next launch needs it before a session can start. + +A final sweep re-enumerates after the run and reports a cgroup that became live after being +verified empty (`AI_TOOLS_RESULT=reappeared`) — that is what a mid-run launch looks like from here. +Re-running is the remedy; the command is idempotent. + +Attribution is best-effort, and now carries no weight: a `WorkingDirectory` is read from the +account's own user manager, so a rogue unit can misreport it — and all that buys is a wrong label +in the table and a missing `--reclaim` line. It cannot affect what is terminated, because nothing +is selected by it. + +### Residual failure modes + +Stated so the guarantee is bounded honestly rather than overstated. + +- **Uninterruptible sleep.** A `D`-state task cannot be reaped until its syscall returns; the run + reports it as surviving, which is honest. It executes nothing meanwhile. +- **The helper itself is `SIGKILL`ed mid-run.** An `EXIT` trap narrows the *audit* gap (it covers + every signal bash can handle) but nothing narrows the *state* gap. The remedy is that the command + is idempotent: run it again. A watchdog would be a second privileged daemon for a case a rerun + already answers. +- **The launch/stop window.** A session launched after the final sweep is outside the command's + window. Closing it needs a gate shared with the launcher, which does not exist; for the incident + case the operator holds the launch path anyway, and for an adversarial session the answer is a + revoked launch grant beside the stop, not a stronger promise here. +- **Blast radius.** Everything in the account's slice is in scope, including an in-flight toolchain + update and the account's own user manager. The updater is fail-closed on interruption and leaves + the previous, trusted version active; the manager is restarted afterwards. Neither is a surprise + worth discovering during an incident, which is why both are stated here. +- **The manager does not come back.** The restart is best-effort and reported separately. Until it + succeeds the host cannot start a *new* session — nearer to the point of this command than against + it, but it must be fixed before work resumes. The message names the command. +- **Kernel below 5.14 or a cgroup-v1-only host.** Without `cgroup.kill` the fallback re-collects + and re-signals in a loop, narrowing the fork window without closing it. With no cgroup v2 + hierarchy at all the helper **refuses** (exit 5) and names the manual command, rather than + reporting that nothing is running. Both supported EL targets are unified and ≥ 5.14. +- **Both log sinks unavailable.** The stop still succeeds or correctly fails; the record is lost. + The two sinks fail independently. + +### What the monitored system can do about it + +Nothing, and this is asserted from the agent's own vantage in `tests/boundary/access.sh`: the +sandbox account cannot read, write or execute the helper and holds no `sudo` rule to reach root +by. There is no authorization input left for it to aim at, either — the command takes none. The kill +is delivered by root to a cgroup, and nothing inside the cgroup takes part in it. + +**No name buys anything, because no name is spared.** One cgroup is matched by name at all — the +manager unit, `user@.service`, by *exact path* — and it is not an exemption: it is descended +into rather than emitted, so its contents (`init.scope` among them) are listed and killed +individually instead of the whole manager subtree being swallowed as one opaque row. A nested cgroup +that names itself `init.scope`, or `user@0.service`, is enumerated like any other, because every +name inside a delegated subtree is the delegatee's to choose. Dot-named cgroups are enumerated for +the same reason. + +--- + +## 3. Implementation + +### Why this is not `systemctl stop` + +The obvious one-liner is +`systemctl --user -M ai-tools@.host stop ai-tools-claude-code-.service`. It is the wrong +instrument here for four reasons, each of which is a chunk of the code: + +1. **It asks the monitored system's own manager.** That manager runs as the account being stopped, + over a bus that account can reach. Its answer is the session's account of itself. +2. **It reports on unit state, not on processes.** "The unit is gone" and "no task is left" are + different facts, and a wedged manager, an absent machine transport and "already stopped" are one + exit status. The helper therefore never lets systemd decide liveness; it verifies against + `cgroup.procs` and `/proc`, and where the two views disagree the kernel wins. +3. **It is unavailable exactly when you need it.** An incident is when the user manager is most + likely to be wedged. The kill path deliberately depends on no project library, no `set -e`, and + no external binary but `sleep` (I7). +4. **It stops one named unit.** The command has to *find* the sessions first — across a delegated + subtree whose names the delegatee chooses — and say what it did. + +That is the answer to "why so much code to turn off a service": almost none of it is the turning +off. It is discovery, consent, verification, restoration, and the record. + +### The five phases + +``` + ┌ 1. parse ──────────────────────────────────────────────────────────────────┐ + │ no target, no authorization input: nothing to decide, nothing to trust │ + │ --all accepted and inert; a PATH is refused (exit 2), never ignored │ + └────────────────────────────────────────────────────────────────────────────┘ + ┌ 2. enumerate ──────────────────────────────────────────────────────────────┐ + │ walk /sys/fs/cgroup/user.slice/user-.slice │ + │ descend slices → stop at the first .service/.scope = ONE SESSION │ + │ the manager service is descended into, never emitted │ + │ NOTHING is exempt -- init.scope is enumerated like anything else │ + │ attribute each unit via WorkingDirectory (best-effort, DISPLAY ONLY: │ + │ it selects nothing, so `unknown` costs a label, not a target) │ + │ classify agent session vs account plumbing (advisory, DISPLAY ONLY: │ + │ splits the counts and orders the table; selects nothing) │ + └────────────────────────────────────────────────────────────────────────────┘ + ┌ 3. confirm ────────────────────────────────────────────────────────────────┐ + │ the table, then a question that DEFAULTS TO YES; consent path recorded │ + └────────────────────────────────────────────────────────────────────────────┘ + ┌ 4. end each session ───────────────────────────────────────────────────────┐ + │ SIGTERM pass, deepest-first, re-collected each second, up to 10s │ + │ → empty? outcome = terminated │ + │ SIGKILL pass: write cgroup.kill (atomic, re-asserted per pass) │ + │ + validated per-pid kill as the pre-5.14 fallback │ + │ → empty? outcome = killed else outcome = alive │ + └────────────────────────────────────────────────────────────────────────────┘ + ┌ 5. sweep, restore, report ─────────────────────────────────────────────────┐ + │ re-enumerate the whole slice; any live cgroup → did not complete (exit 1) │ + │ live but not one the loop reported → reappeared: a session started │ + │ mid-run; re-running is the remedy │ + │ restart user@.service -- AFTER verification, reported separately, │ + │ never folded into the stop's exit status │ + │ name the --reclaim per project terminated │ + └────────────────────────────────────────────────────────────────────────────┘ +``` + +### The decisions inside those phases + +- **A systemd *unit* is the unit of work.** The walk descends `.slice` directories and stops at the + first `.service`/`.scope`, emitting it whole: its nested cgroups are part of it, so descending + further would list the same processes twice and offer a parent slice as a stoppable thing — + which would take every sibling unit with it. Nothing is lost, because the only place a task can + hide from a unit walk is a slice, and a slice holding tasks *directly* is emitted in its own + right. +- **Deepest-first signalling.** Children are reached before their parents, so a parent is never + left waiting on a child it can still see. +- **Re-collect between passes.** A set read once and signalled twice misses whatever was forked in + between. Each pass re-reads the cgroup. +- **Validate a pid's start time immediately before signalling it** (`/proc//stat` field 22), + so a pid recycled between collection and kill is skipped rather than signalled blind. This is the + pre-5.14 path; `cgroup.kill` signals no pids at all. +- **Every liveness read fails closed.** Only one failure means "empty": the file not existing, i.e. + the cgroup was removed, which is what a completed kill looks like. A permission-unreadable + `cgroup.procs` reports LIVE. A threaded cgroup — whose `cgroup.procs` read fails while live + threads sit in it — is corroborated against `cgroup.threads` (I4), because bash cannot tell a + failed `read(2)` from a clean EOF. +- **Verification never runs through an external command.** An earlier form of the liveness + predicate piped to `head`; with `head` absent it would have answered "no tasks" and reported a + stop as complete while the session ran. Every other external in the file fails toward doing + *less*; that one failed toward *claiming more*, on the one check the guarantee rests on. + +### Degradation policy: two inversions, one reason + +For every other component here the safe direction is **don't act**. For this one it is **act**. So +two project-wide conventions are inverted, and each is inverted for that reason alone: + +1. **No required dependencies, and no `set -e`.** A missing library that aborted the run, or an + unexpected non-zero that abandoned a half-finished kill, would be a stop that did not happen. + The rule is about *abandonment*, not about `set -e` specifically: `set -u` **is** used, and it + ends the shell just as abruptly wherever a name or an argument is read unset — so a value a + caller may legitimately not have passed is defaulted where it is read, rather than left to abort + a run mid-way. What is guaranteed is independence from *this project's* libraries, and now + absolutely: **no project library is load-bearing here at all.** The command takes no input that + decides which sessions to stop, so there is nothing left for one to gate. `log.lib.sh` and + `msg.lib.sh` load best-effort for output quality; `safe-paths.lib.sh` and `operator.lib.sh` are + not loaded, having existed here only to vet and authorize a caller-supplied target. It is not + independence from the base system and does not pretend to be — which externals a run touches and + which of them are on the kill path (I7) is stated in the helper's header, beside the code. +2. **The confirmation defaults to yes.** The principle in + [messaging](../.claude/rules/messaging.rule.md) is unchanged — *the default is the safe + outcome*; which outcome is safe is what flips. + +--- + +## 4. Decisions that must not be reverted + +Each of these looks like a defect to a fresh reader, and each is deliberate. If you are about to +"fix" one, this is the section that says why not. + +| Decision | Why | +|---|---| +| The confirmation defaults **yes** | for the one control whose job is to act, declining is the failure (§3) | +| No `set -e`, no required library, and every value defaulted where `set -u` would abort | an aborted run is a stop that did not happen — including one aborted by an argument a caller did not pass (§3) | +| There is **no per-project form**, and a path is an error | every way to attribute a session to a project is written by the account being stopped (§2) | +| A path errors rather than being ignored | it keeps targeted stopping addable later without changing what an existing command means (§1) | +| **Nothing is exempt**, `init.scope` included | an exemption is a cgroup a session can move into on a DAC-only host (§2) | +| The user manager is restarted, not spared | the exemption it would need costs more than the restart does (§2) | +| The restart never changes the exit status | the invariant is that the *stop* happened; the manager is a separate fact (§2) | +| Liveness comes from cgroups only | systemd never decides whether something is running (§3) | +| Attribution is best-effort and display-only | it selects nothing, so a misreported project costs a label, never a target (§2) | +| Agent sessions and account plumbing are counted apart, advisorily | the class comes from a unit name, so it is the account's word — but it labels a row, never selects one, and the alternative was telling an operator four "agent sessions" were running when one was (§1) | +| A second run terminates and restarts the manager again | idempotence is in end state; a silent rerun costs either an exemption or a name-decided sweep (§1) | + +## 5. Deferred hardening + +Known, bounded, and not yet built: + +- **Pin the cgroup by file descriptor.** A unit can exit and systemd recreate the same path, so a + kill could in principle hit a newer invocation. Holding an fd on the cgroup directory and working + through `/proc/self/fd//` makes a recreated path read `ENOENT`. The worst case today is + stopping a *newly started session of the same account*, which is not an escalation and is what + `--all` would do anyway. +- **A dedicated slice for agent sessions.** Launching into an `ai-tools.slice` would let the sweep + spare the user manager structurally instead of restarting it. **Rejected for now, and the reason + matters:** it would also put every cgroup *outside* that slice out of reach, and on a DAC-only + host a session can create a unit there by asking its own user manager — so the slice would buy a + tidier blast radius at the cost of the property that makes this rung worth having. Revisit only + together with closing that reach. +- **A root-recorded session registry.** The prerequisite for any per-project form: `ai-tools-run` + recording unit→project through the handback socket, whose peer uid the kernel supplies, so the + mapping is not the account's word. A verb, a registry, and its lifecycle — not built, because + `/exit` covers the routine case (§2). +- **A launch/stop gate.** A session launched after the final sweep is outside the window; closing + that needs the launcher to take the same lock. It would also serialize concurrent stops, whose + current cost is duplicate audit events rather than a wrong result. + +## See also + +- `ai-tools(1)` — the option grammar and every exit code +- [docs/project-lifecycle.md](project-lifecycle.md) — claiming projects, and `--reclaim` +- [`.claude/rules/cli.rule.md`](../.claude/rules/cli.rule.md) — the domain contract, for contributors +- [`.claude/rules/governance.rule.md`](../.claude/rules/governance.rule.md) — where this rung sits + in the incident ladder diff --git a/install.sh b/install.sh index 0fb5383a..f073e9df 100755 --- a/install.sh +++ b/install.sh @@ -589,6 +589,9 @@ do_summary() { _chk /usr/local/libexec/ai-tools/ai-tools-unclaim _chk /usr/local/libexec/ai-tools/ai-tools-safedir _chk /usr/local/libexec/ai-tools/ai-tools-reclaim + _chk /usr/local/libexec/ai-tools/ai-tools-allowlist + _chk /usr/local/libexec/ai-tools/ai-tools-audit + _chk /usr/local/libexec/ai-tools/ai-tools-stop _chk /usr/local/libexec/ai-tools/ai-tools-launcher-symlink _chk /usr/local/libexec/ai-tools/ai-tools-lockdown _chk /usr/local/libexec/ai-tools/ai-tools-relabel @@ -623,6 +626,8 @@ do_summary() { _chk /usr/local/lib/ai-tools/safe-paths.lib.sh _chk /usr/local/lib/ai-tools/confinement.lib.sh _chk /usr/local/lib/ai-tools/npm-verify.lib.sh + _chk /usr/local/lib/ai-tools/entrypoint-verify.lib.sh + _chk /usr/local/lib/ai-tools/keys/claude-code.asc _chk /usr/local/lib/ai-tools/conf.lib.sh _chk /usr/local/lib/ai-tools/providers.lib.sh _chk /usr/local/lib/ai-tools/filters.lib.sh @@ -801,6 +806,21 @@ do_install() { "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh" \ /usr/local/libexec/ai-tools/ai-tools-reclaim + log "/usr/local/libexec/ai-tools/ai-tools-allowlist" + install_subst 750 root root \ + "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh" \ + /usr/local/libexec/ai-tools/ai-tools-allowlist + + log "/usr/local/libexec/ai-tools/ai-tools-audit" + install_subst 750 root root \ + "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-audit.sh" \ + /usr/local/libexec/ai-tools/ai-tools-audit + + log "/usr/local/libexec/ai-tools/ai-tools-stop" + install_subst 750 root root \ + "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-stop.sh" \ + /usr/local/libexec/ai-tools/ai-tools-stop + log "/usr/local/libexec/ai-tools/ai-tools-launcher-symlink" install_subst 750 root root \ "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh" \ @@ -852,6 +872,27 @@ do_install() { "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/npm-verify.lib.sh" \ /usr/local/lib/ai-tools/npm-verify.lib.sh + # Entrypoint verifier: proves an agent's installed entrypoint is the binary its vendor + # published, against a per-release manifest the vendor SIGNED with a key pinned below. Read by + # root (ai-tools-relabel-agent writes the pin) and by the sandbox account (ai-tools-run compares + # it at launch), so 644 root:root -- world-readable, no secrets, no tokens. + log "/usr/local/lib/ai-tools/entrypoint-verify.lib.sh" + install -o root -g root -m 644 \ + "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh" \ + /usr/local/lib/ai-tools/entrypoint-verify.lib.sh + + # The pinned vendor release-signing keys, one per agent. 755 dir / 644 keys: public key + # material, read by root at pin time. The pin is only as good as this file being root-owned and + # replaced solely by a package install -- never edited on the host. + install -o root -g root -d -m 755 /usr/local/lib/ai-tools/keys + # Verified entrypoint pins: root-owned and not group-writable, so the account the pin + # constrains cannot write it. 755 so the sandbox account can read the pin at launch. + install -o root -g root -d -m 755 /var/opt/ai-tools/state/entrypoint-pin.d + log "/usr/local/lib/ai-tools/keys/claude-code.asc" + install -o root -g root -m 644 \ + "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/keys/claude-code.asc" \ + /usr/local/lib/ai-tools/keys/claude-code.asc + # Shared KEY=value config grammar + the trust predicate: 644 root:root -- world-readable, # sourced by operator.lib.sh, skip-dirs.lib.sh and providers.lib.sh so every key in # operator.conf and every provider manifest parses identically. No secrets, no tokens. diff --git a/packaging/VERSION b/packaging/VERSION index af88ba82..ac454c6a 100644 --- a/packaging/VERSION +++ b/packaging/VERSION @@ -1 +1 @@ -0.11.1 +0.12.0 diff --git a/packaging/ai-tools.spec b/packaging/ai-tools.spec index c54afdd6..a7cdfac5 100644 --- a/packaging/ai-tools.spec +++ b/packaging/ai-tools.spec @@ -193,6 +193,13 @@ Requires: ai-tools-integration-nodejs = %{version}-%{release} # stops reclaiming .git and stops the session-end sweep, and filter-hook stops filtering. The # first two are ownership guarantees, so this is Requires rather than Recommends. Requires: jq +# gnupg2 provides gpgv, the verify-only half this uses to check the vendor's signed release +# manifest before trusting the checksum it publishes for this agent's entrypoint +# (entrypoint-verify.lib.sh). Absent it, the verification degrades to "unable to verify" and the +# entrypoint is never pinned -- so like jq this is Requires, not Recommends: the declaration in +# this package's manifest is what makes the check meaningful, and shipping the declaration without +# the verifier would leave every host silently unverified. +Requires: gnupg2 # Renamed from claude-code-restricted; see the note on ai-tools-integration-nodejs for why the # pair is required rather than cosmetic. This one also owns the launch wrapper and the hooks, so # without the Obsoletes an install alongside the old name is a file conflict. @@ -230,7 +237,7 @@ grep -rlZ '@AI_TOOLS_VERSION@' src \ install -d -m 0750 %{buildroot}%{ai_libexecdir} for h in ai-tools-chown ai-tools-setgid ai-tools-setfacl ai-tools-unclaim \ ai-tools-lockdown ai-tools-relabel ai-tools-safedir ai-tools-reclaim \ - ai-tools-admin; do + ai-tools-allowlist ai-tools-audit ai-tools-stop ai-tools-admin; do install -m 0750 src%{ai_libexecdir}/${h}.sh %{buildroot}%{ai_libexecdir}/${h} done install -m 0750 src%{ai_libexecdir}/ai-tools-handback.py %{buildroot}%{ai_libexecdir}/ai-tools-handback @@ -266,7 +273,7 @@ ln -s %{ai_bindir}/ai-tools %{buildroot}%{_sbindir}/ai-tools # SANDBOX_GROUP member under multi-operator) can traverse in to source the 644 # world-readable libs by path without listing the dir. The 640 files self-protect. install -d -m 0751 %{buildroot}%{ai_libdir} -for l in log msg conf skip-dirs owner-only relabel secret-patterns operator control-plane safe-paths confinement npm-verify managed-assets providers selinux-groups filters services; do +for l in log msg conf skip-dirs owner-only relabel secret-patterns operator control-plane safe-paths confinement npm-verify entrypoint-verify managed-assets providers selinux-groups filters services; do install -m 0644 src%{ai_libdir}/${l}.lib.sh %{buildroot}%{ai_libdir}/${l}.lib.sh done # Provider manifest + fragment directories (base owns the dirs; each member package ships its own @@ -282,6 +289,12 @@ install -d -m 0755 %{buildroot}%{ai_libdir}/session-env.d # own ships one beside it. An agent's filter hook reads them through filters.lib.sh. install -d -m 0755 %{buildroot}%{ai_libdir}/filters.d install -m 0644 src%{ai_libdir}/filters.d/core.rules %{buildroot}%{ai_libdir}/filters.d/core.rules +# Pinned vendor release-signing keys, keyed by agent: keys/.asc. Base owns the directory +# and ships none -- the key that signs an agent's releases belongs to that agent's package, the +# same split as agents.d. entrypoint-verify.lib.sh verifies a release manifest against the key its +# manifest names, so the key is SHIPPED rather than fetched (a fetched key proves only that whoever +# served the manifest served the key). +install -d -m 0755 %{buildroot}%{ai_libdir}/keys # The shared confinement shim. Base-owned and agent-agnostic: it resolves which agent may launch # from the manifests above, so an ai-tools-agents-* package ships only its wrapper, manifest, and # session-env fragment, and one sudoers grant serves every agent. @@ -346,6 +359,10 @@ done install -d -m 2750 %{buildroot}/var/opt/ai-tools install -d -m 2770 %{buildroot}/var/opt/ai-tools/sandbox-projects install -d -m 0750 %{buildroot}/var/opt/ai-tools/state +# Verified entrypoint pins, one file per agent, written by ai-tools-relabel-agent as root and read +# by the launch shim as the sandbox account. Root-owned and NOT group-writable, like its parent: +# the whole value of a pin is that the account it constrains cannot write it. +install -d -m 0755 %{buildroot}/var/opt/ai-tools/state/entrypoint-pin.d install -m 0640 src/var/opt/ai-tools/README.md %{buildroot}/var/opt/ai-tools/README.md install -d -m 0700 %{buildroot}/var/log/ai-tools @@ -434,6 +451,11 @@ install -m 0640 src/opt/ai-tools/agents/claude-code/settings.json %{buildroo # Claude npm package and symlinks the claude launcher without hardcoding either (the agents.d # directory itself is owned by ai-tools-base). install -m 0644 src%{ai_libdir}/agents.d/claude-code.conf %{buildroot}%{ai_libdir}/agents.d/claude-code.conf +# The pinned Anthropic release-signing key (published at downloads.claude.ai/keys/claude-code.asc). +# Plain rpm-owned data, NOT %%config: the pin must change only when a signed package installs a new +# one, never by an edit on the host. Its fingerprint is declared in the manifest above and asserted +# against gpgv's output, so this file alone does not decide what may sign a release. +install -m 0644 src%{ai_libdir}/keys/claude-code.asc %{buildroot}%{ai_libdir}/keys/claude-code.asc # Its session env (config dir, compile cache, in-session updater), sourced by ai-tools-run last # so the agent's own pins are authoritative over an integration's. install -m 0644 src%{ai_libdir}/session-env.d/claude-code.env.sh %{buildroot}%{ai_libdir}/session-env.d/claude-code.env.sh @@ -669,6 +691,16 @@ fi %postun -n ai-tools-integration-nodejs %systemd_postun_with_restart ai-tools-relabel.path +%posttrans -n ai-tools-integration-nodejs +# Start the relabel watcher so it is live without a reboot -- the twin of ai-tools-base's +# posttrans starting the handback socket. The nodejs post scriptlet only ENABLES the unit +# (applies the preset); a .path unit must be started to begin watching, and until it does a Node +# auto-upgrade that repoints the launcher goes unwatched and the next launch fail-closes on a +# bin_t entrypoint. posttrans runs after the systemd daemon-reload file trigger, so the unit is +# known. Idempotent; guarded so a systemd-less build/image fails soft. +# (No macro names in this comment: rpm expands macros inside scriptlet comments too.) +systemctl start ai-tools-relabel.path 2>/dev/null || : + %post -n ai-tools-integration-dotnet # Create + SELinux-label the sandbox-side dotnet dirs (writable NuGet cache, read-only shared # tools) the session-env fragment relies on. Offline + idempotent; the helper recognizes a host @@ -744,7 +776,8 @@ fi # File lists # ───────────────────────────────────────────────────────────────────────────── %files -%doc docs/rpm-packaging.md docs/project-lifecycle.md README.md +%doc docs/rpm-packaging.md docs/project-lifecycle.md docs/entrypoint-verification.md +%doc docs/session-stop.md README.md %files -n ai-tools-selinux %license LICENSES/GPL-2.0-or-later.txt @@ -763,6 +796,9 @@ fi %attr(0750, root, root) %{ai_libexecdir}/ai-tools-relabel %attr(0750, root, root) %{ai_libexecdir}/ai-tools-safedir %attr(0750, root, root) %{ai_libexecdir}/ai-tools-reclaim +%attr(0750, root, root) %{ai_libexecdir}/ai-tools-allowlist +%attr(0750, root, root) %{ai_libexecdir}/ai-tools-audit +%attr(0750, root, root) %{ai_libexecdir}/ai-tools-stop %attr(0750, root, root) %{ai_libexecdir}/ai-tools-admin %{_sbindir}/ai-tools-admin %attr(0750, root, root) %{ai_libexecdir}/ai-tools-handback @@ -784,11 +820,13 @@ fi %attr(0644, root, root) %{ai_libdir}/safe-paths.lib.sh %attr(0644, root, root) %{ai_libdir}/confinement.lib.sh %attr(0644, root, root) %{ai_libdir}/npm-verify.lib.sh +%attr(0644, root, root) %{ai_libdir}/entrypoint-verify.lib.sh %attr(0644, root, root) %{ai_libdir}/conf.lib.sh %attr(0644, root, root) %{ai_libdir}/providers.lib.sh %attr(0644, root, root) %{ai_libdir}/selinux-groups.lib.sh %attr(0644, root, root) %{ai_libdir}/filters.lib.sh %attr(0644, root, root) %{ai_libdir}/services.lib.sh +%dir %attr(0755, root, root) %{ai_libdir}/keys %dir %attr(0755, root, root) %{ai_libdir}/agents.d %dir %attr(0755, root, root) %{ai_libdir}/integrations.d %dir %attr(0755, root, root) %{ai_libdir}/session-env.d @@ -822,6 +860,7 @@ fi # place by its writer, which confines the added surface to that one file's contents. Readers reach # it through the g:ai-ops:r-x ACL %post applies (%files cannot express an ACL). %dir %attr(0750, root, ai-tools) /var/opt/ai-tools/state +%dir %attr(0755, root, root) /var/opt/ai-tools/state/entrypoint-pin.d %dir %attr(0700, root, root) /var/log/ai-tools %ghost %attr(0600, root, root) /var/log/ai-tools/chown.log %ghost %attr(0600, root, root) /var/log/ai-tools/setgid.log @@ -894,6 +933,7 @@ fi # a group-writer for its session state but cannot unlink the root-owned files below. %dir %attr(3770, root, ai-tools) /opt/ai-tools/.claude %attr(0644, root, root) %{ai_libdir}/agents.d/claude-code.conf +%attr(0644, root, root) %{ai_libdir}/keys/claude-code.asc %attr(0644, root, root) %{ai_libdir}/session-env.d/claude-code.env.sh %attr(0644, root, root) %{ai_libdir}/claude-prompt.lib.sh %attr(0644, root, root) %{ai_libdir}/claude-endpoint.lib.sh @@ -911,6 +951,62 @@ fi %config(noreplace) %attr(0640, root, ai-tools) /opt/ai-tools/.claude/settings.json %changelog +* Fri Aug 21 2026 dagnode - 0.12.0-1 +- NEW: 'ai-tools --for ' runs a project command on behalf of another enrolled operator, + so a service account that runs a coding agent but has no password can be given projects. The + allowlist entry lands in that account's registry rather than yours, which is what makes the + project theirs: files the agent writes are handed back to them, the per-project ACL grants them, + and their next launch finds the project claimed instead of prompting for a password it cannot + supply. Claim once as yourself, with your own password, and the account never meets a sudo + prompt. Accepted on --project-claim/-create, --project-unclaim/-remove, --lockdown, --reclaim + and --list; refused elsewhere rather than ignored, and refused with --project-unclaim --force + (an unlisted tree has no entry naming an owner, so that mode stays bound to the invoking + operator). Enrol the target first with 'sudo ai-tools-admin operator add '. +- NEW: The ai-tools-allowlist root helper backs it, reading and editing another operator's + allowed-projects. Root is needed for the read too, since an allowlist is 0600 inside a 0700 + directory. Like the other project helpers it carries no NOPASSWD grant -- you authenticate -- + it authorizes against the uid sudo sets rather than a name from the environment, and it refuses + a bare root call, an unenrolled caller or target, the sandbox account, and a protected system + directory, leaving the registry byte-identical whenever it refuses. +- NEW: The agent binary is now verified against the checksum its vendor SIGNED, and the verified + value is pinned where the sandbox account cannot write it, so a binary modified AFTER it was + installed refuses to launch. npm's integrity hash and registry signature attest to what was + delivered, not to what is on disk afterwards -- and since 'npm install -g' does not reinstall an + unchanged version, such a change would otherwise persist across sessions and operators. The + signing key ships in the package rather than being downloaded, which closes the registry + key-pinning gap for this one binary. Nothing to maintain per release: the key identifies the + signer, not the release, and the per-version pin is written automatically by the same watcher + that already relabels entrypoints. Requires gnupg2 (gpgv). +- NEW: 'ai-tools --relabel' reconciles the entrypoint rather than only its SELinux label -- it + verifies and pins first, then relabels -- so it is also the way to pin an entrypoint on a + DAC-only host or one the watcher was offline for. The verification fails soft when the vendor is + unreachable; only a checksum mismatch fails the command. +- NEW: 'ai-tools --status' reports, per agent, whether its entrypoint carries a verified checksum -- + VERIFIED with the pinned version and how long ago, or unverified. It is the only view an operator + has of the verification: the entrypoint itself lives in a toolchain they cannot read. Unverified + counts toward the exit status only where verification is required, so an air-gapped host does not + alarm. +- NEW: AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY in operator.conf refuses to launch an entrypoint that + carries no verified checksum, and stops the updater activating a release it could not verify. A + MISMATCH always refuses regardless; this key governs only the unverifiable case, whose default + stays permissive because unpinned is equally the state of an air-gapped host. See + docs/entrypoint-verification.md. +- FIX: ai-tools-run verified one path and started another: it checked the SELinux label on the + resolved entrypoint but handed systemd the launcher symlink, leaving the whole preflight as a + window in which that link could be repointed. It now resolves once, contains the target to the + same Node version directory, uses that single path for both, and re-checks its device/inode/ + size/ctime immediately before the launch. Under SELinux this window was never reachable (the + toolchain is read-only to the confined domain); it mattered on a DAC-only host. +- FIX: 'ai-tools --relabel' could report success while every launch stayed refused. It applied the + file-context pattern each agent's manifest declares, but the SELinux transition fires on the + binary the launcher actually resolves to -- so an agent whose package installs its executable + somewhere the pattern no longer covers left nothing to label, and the command exited 0 saying + the entrypoint "is not installed". It now resolves the entrypoint the way the launch check does + and reconciles the two: that case exits non-zero naming the real cause (the agent package's + manifest is stale and needs updating), and "not installed" is reported only when the agent + genuinely is not provisioned. The set of files that can take the confined domain's exec label is + unchanged -- still exactly what the root-owned manifests declare. + * Tue Aug 18 2026 dagnode - 0.11.1-1 - FIX: A toolchain update that could not reach the npm registry failed with an empty journal and left ai-tools --status reporting FAILED until the next day's window. It now says what it could diff --git a/selinux/install-selinux.sh b/selinux/install-selinux.sh index 68f34026..98509000 100755 --- a/selinux/install-selinux.sh +++ b/selinux/install-selinux.sh @@ -384,6 +384,15 @@ verify_agent_labels() { warn " $(matchpathcon "${subject}" 2>/dev/null | awk '{print $2}')" warn " chase with: sudo restorecon -nv '${subject}'" warn " and: sudo semanage fcontext -C -l" ;; + # The declared rule does not cover the entrypoint the agent's launcher actually + # resolves to, so nothing this sweep applies can label it and the session would + # be refused. Counted as `bad`: the install must not report a confined host. + stale) bad=1 + warn "${subject}: its installed entrypoint is" + warn " ${detail}" + warn " -- not covered by the file-context rule its manifest declares," + warn " so no relabel can label it and every launch will fail closed." + warn " Update the agent package; its manifest is stale." ;; none) warn "${subject}: ${detail} is not installed -- nothing to label" ;; skip) warn "${subject}: labelling skipped -- ${detail} ${wanted}" ;; # A verdict this renderer does not know is REPORTED, not dropped. Silently @@ -400,7 +409,7 @@ verify_agent_labels() { # here rather than proceed to the optional groups with a broken core. A missing path # (toolchain not provisioned yet) stays a warning -- there is nothing to label. [[ "${bad}" -eq 0 ]] \ - || die "an agent path did not take its type (see above) -- the agent would run UNCONFINED" + || die "an agent path is not correctly labelled (see above) -- the session would be refused, or run UNCONFINED" # Nothing labelled has two very different causes, and the bare message named neither. An # EMPTY report means no enabled agent was iterated at all -- the manifests resolved to # nothing -- which is a configuration problem: the entrypoint keeps whatever type it has, and diff --git a/src/etc/ai-tools/operator.conf b/src/etc/ai-tools/operator.conf index c6a96399..e30cf058 100644 --- a/src/etc/ai-tools/operator.conf +++ b/src/etc/ai-tools/operator.conf @@ -59,6 +59,13 @@ OPERATORS="@PROJECTS_USER@" # intentional DAC-only host keeps working). See .claude/rules/confinement.rule.md. #AI_TOOLS_REQUIRE_SELINUX=yes +# Entrypoint-verification strictness. A binary that does not match the checksum its vendor signed +# always refuses to launch; this key governs only the other outcome, an entrypoint carrying no +# verified checksum at all. ABSENT (the default) launches it anyway -- unpinned is equally the state +# of an air-gapped host. SET (yes) requires verification. See operator.conf(5) and +# /usr/share/doc/ai-tools/entrypoint-verification.md. +#AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes + # Custom system prompt for Claude Code sessions (requires the ai-tools-agents-claude-code-restricted # agent). ABSENT or empty (the default) leaves Claude Code's own default prompt untouched. Uncomment # the path to enable it; the wrapper reads that file at launch and passes it to Claude Code as diff --git a/src/opt/ai-tools/agents/claude-code/filter-hook.sh b/src/opt/ai-tools/agents/claude-code/filter-hook.sh old mode 100644 new mode 100755 diff --git a/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh b/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh index 95c2884c..50ecf546 100755 --- a/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh +++ b/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-only # /opt/ai-tools/.claude/post-tool-hook.sh -# PostToolUse hook for Write|Edit tools. Restores operator:ai-tools ownership -# on files Claude Code rewrote via atomic rename (which stamps the writer's UID). +# PostToolUse hook, dispatched on $1 the way session-hook.sh dispatches its session +# phases. Both forms record the tool call in the operator-readable trail; the +# argument-less form additionally restores operator:ai-tools ownership. +# +# (no argument) Write|Edit -- record the call, then hand the written file back +# record Bash -- record the call only; a Bash write carries no +# file_path and is swept at turn end instead # # Runs as ai-tools. It deliberately does NOT pre-check the approved-projects # allowlist: that file lives under the operator's home .config (mode 700, owned by @@ -39,34 +44,207 @@ if ! source "${LOG_LIB}" 2>/dev/null; then ai_tools_log_warn() { :; }; ai_tools_log_error() { :; } fi -# Extract file path from hook stdin JSON -file="$(jq -r '.tool_input.file_path // empty' 2>/dev/null)" || exit 0 -[[ -n "${file}" ]] || exit 0 - -# Hand the written file back. Call only for a path the agent itself wrote -- one currently -# owned by @SANDBOX_USER@ -- which is exactly the set ai-tools-chown will act on (its own -# owner guard) and the same signal the parent-dir walk below uses, so an already-handed-back -# file (operator-owned, or a quarantined secret) makes no socket call. Delegate to the -# root-owned validator: it checks the allowlist (as root, which can read it), chowns + strips -# world bits, and for secret-named files revokes ai-tools access and prints a NOTICE. Let that -# stderr through (do NOT redirect to /dev/null) so Claude Code surfaces the NOTICE in the session. -current_user="$(stat -c '%U' "${file}" 2>/dev/null || true)" -if [[ "${current_user}" == "@SANDBOX_USER@" ]]; then - ai_tools_log_debug "PostToolUse handing back ${file} (owner ${current_user})" - /usr/local/bin/ai-tools-handback-client CHOWN "${file}" || true -fi +readonly HANDBACK_CLIENT="/usr/local/bin/ai-tools-handback-client" + +# ── The tool-call record's content bound ───────────────────────────────────────── +# These two constants ARE the bound on what a session's command line can put into the +# audit trail, so they are named and stated here rather than buried as literals inside +# the jq program below. Widening either widens what the trail carries; the reasoning +# for the current values is in format_tool_call_record and pinned in logging.rule.md. +# 128 rather than a tighter figure because a PATH is the common second word (`cd `, +# `mkdir `, `dotnet build `) and a cap that truncates one mid-directory removes +# exactly the part that identifies it. This is not the bound that matters -- first-line-two-words +# already bounds the record structurally -- it is the backstop for a single pathological word +# with no whitespace in it, such as a base64 blob, so it needs only to be finite. +readonly MAX_RECORDED_WORD_LENGTH=128 +readonly RECORDED_LEADING_WORD_COUNT=2 + +# The unit separator (0x1F) joining the parts format_tool_call_record prints. Every value is +# stripped of control characters before it is joined, so the delimiter cannot occur inside one. +readonly RECORD_FIELD_SEPARATOR=$'\037' + +# format_tool_call_record -- PRINT the audit-trail record for the tool call +# this event carries, or nothing when it cannot be read. Never fails the caller. +# +# The output is one 0x1F-delimited list: the human-readable MESSAGE first, then zero or more +# `FIELD=value` pairs for the journal's native structured fields. Both renderings are built +# here, from one parse, because they must agree -- and they are reduced differently, because +# they are read differently (see below). +# +# The line is the only trail of the agent's own ACTIONS: every other record in this system +# covers a privileged operation performed on the operator's behalf. journald is a sink the +# agent can append to but can neither edit nor delete, unlike the session transcript under +# /opt/ai-tools/.claude/projects, which the session owns and can rewrite at will. +# +# WHAT IS RECORDED, and why it stops there. For Bash: only the first two words of the +# command's FIRST LINE (each capped at MAX_RECORDED_WORD_LENGTH, a longer one marked `~`) +# plus the count of words on that line. Taking the first line excludes a here-doc body by +# construction rather than by a length cap -- `cat > f <<'EOF'` followed by a credential +# records `cmd="cat >" argc=4` and nothing of the payload -- and two words keep a command +# distinguishable from its subcommand (`git log` from `git push`). Recording the full +# command line would make the trail carry unbounded file content. +# +# HOW UNTRUSTED INPUT IS REDUCED, and why the two renderings differ. Every value here is +# agent-supplied -- the tool name and the working directory as much as the command -- and both +# renderings drop control characters first (`strip_controls`), which is what makes the 0x1F +# delimiter safe to join on and removes the newline that would truncate a journal field. +# +# The MESSAGE is additionally passed through `clamp`, a narrower allowlist keeping printable +# ASCII MINUS the three characters that delimit it: space, `"` and `=`. In free prose those +# three are ordinary text -- which is why the shared logger's ai_tools_log_sanitize, a DISPLAY +# guard against terminal escapes and bidi overrides, permits them -- but in a key=value line +# they are STRUCTURE, so a word containing them forges fields: a leading word of +# `git" argc=0 cwd=/etc/passwd` would otherwise render as `cmd="git" argc=0" argc=8`, handing a +# reader the planted argc. Reducing them to `?` makes the line's shape unforgeable while leaving +# it readable, and the variable-length part is placed LAST, so nothing the agent controls +# precedes a field a reader trusts. The class spells the surviving set as its two ranges: `!` +# (0x21), `#`-`<` (0x23-0x3C, excluding space 0x20 and `"` 0x22), and `>`-`~` (0x3E-0x7E, +# excluding `=` 0x3D). +# +# The structured FIELDS need none of that narrowing: journald's native protocol delimits each +# field itself, so a value cannot forge a sibling and needs no escaping. They therefore keep +# what the MESSAGE reduces -- a path with a space stays a path with a space, where the MESSAGE +# shows `?` -- and the shared logger applies its display allowlist to each on the way out. The +# MESSAGE is the lossy human view; the fields are the faithful machine one. +# +# The length cap is the one reduction BOTH renderings take, since it bounds a pathological word +# rather than the record's shape: AI_TOOLS_CMD is capped like the MESSAGE's copy of it, while +# AI_TOOLS_PATH is not capped at all (a file path is already bounded by PATH_MAX). +# +# Extraction runs inside jq rather than the shell, so an unbounded here-doc body is never +# assigned to a shell variable on its way to being discarded. +format_tool_call_record() { + local hook_event_json="$1" + # shellcheck disable=SC2016 # a jq program: every $name below is a jq variable, not shell + local record_filter=' + def strip_controls: gsub("[[:cntrl:]]"; "?"); + def clamp: gsub("[^!#-<>-~]"; "?"); + def cap: if length > $max_word_length + then .[0:$max_word_length] + "~" else . end; + ((.tool_name // "?") | strip_controls | cap) as $tool_name + | ((.cwd // "-") | strip_controls) as $working_directory + | (if $tool_name == "Bash" + then ([ ((.tool_input.command // "") + | split("\n") | (.[0] // "") | scan("[^ \t]+")) ]) as $command_words + | ($command_words[0:$leading_word_count] + | map(strip_controls | cap)) as $leading_words + | ($command_words | length | tostring) as $word_count + | [ "cmd=\"" + ($leading_words | map(clamp) | join(" ")) + + "\" argc=" + $word_count, + "AI_TOOLS_CMD=" + ($leading_words | join(" ")), + "AI_TOOLS_ARGC=" + $word_count ] + else ((.tool_input.file_path // "-") | strip_controls) as $written_path + | [ "path=" + ($written_path | clamp), + "AI_TOOLS_PATH=" + $written_path ] + end) as $tool_detail + | [ "tool=" + ($tool_name | clamp) + + " cwd=" + ($working_directory | clamp) + + " " + $tool_detail[0], + "AI_TOOLS_TOOL=" + $tool_name, + "AI_TOOLS_CWD=" + $working_directory ] + + $tool_detail[1:] + | join($separator)' + + jq -j --argjson max_word_length "${MAX_RECORDED_WORD_LENGTH}" \ + --argjson leading_word_count "${RECORDED_LEADING_WORD_COUNT}" \ + --arg separator "${RECORD_FIELD_SEPARATOR}" \ + "${record_filter}" <<< "${hook_event_json}" 2>/dev/null || return 1 +} + +# record_tool_call -- emit the audit-trail line for this event. +# +# A record that cannot be built is never guessed at -- an unreadable event is not evidence of +# what ran -- but neither is it passed over in silence. Silence here is ambiguous in the one +# direction that matters: a reader of a trail with no lines in it cannot tell "this session +# ran no tools" from "the recorder was broken or bypassed", and the second reads as the first, +# which is worse than no trail at all because it manufactures confidence. So a failure to +# record is itself recorded, at WARNING, naming the gap. The reason is resolved only on the +# failure path, so the common case pays nothing for it, and `jq` is singled out because its +# absence degrades every hook in the session (handback and sweeps included), not just this +# line -- that is a host-level fault an operator must see, not a parse hiccup. +record_tool_call() { + local hook_event_json="$1" formatted_record="" failure_reason="" + local -a record_parts=() + if formatted_record="$(format_tool_call_record "${hook_event_json}")" \ + && [[ -n "${formatted_record}" ]]; then + # Element 0 is the human-readable MESSAGE; the rest are FIELD=value pairs for the + # journal's structured fields, which the shared logger validates and reduces. + # printf, not a here-string: a here-string appends a newline, which would ride along on + # the final field's value. + mapfile -t -d "${RECORD_FIELD_SEPARATOR}" record_parts \ + < <(printf '%s' "${formatted_record}") + ai_tools_log_structured info "${record_parts[0]}" "${record_parts[@]:1}" + return 0 + fi + failure_reason="the event JSON could not be parsed" + command -v jq >/dev/null 2>&1 \ + || failure_reason="jq is not installed, so every hook in this session is degraded" + ai_tools_log_warn "tool call NOT recorded (${failure_reason}) -- this is a gap in the trail" +} + +# hand_back_written_path -- restore operator ownership of the file this +# Write/Edit produced, and of any parent directory the write itself created. +# +# The handback call is made only for a path the agent itself wrote -- one currently owned by +# @SANDBOX_USER@ -- which is exactly the set ai-tools-chown will act on (its own owner guard) +# and the same signal the parent-dir walk uses, so an already-handed-back file +# (operator-owned, or a quarantined secret) makes no socket call. The root-owned validator +# does the real work: it checks the allowlist (as root, which can read it), chowns + strips +# world bits, and for secret-named files revokes ai-tools access and prints a NOTICE. That +# stderr is deliberately NOT redirected to /dev/null, so Claude Code surfaces the NOTICE in +# the session. +hand_back_written_path() { + local hook_event_json="$1" written_file_path="" current_owner_name="" parent_directory="" + + written_file_path="$(jq -r '.tool_input.file_path // empty' \ + <<< "${hook_event_json}" 2>/dev/null)" || return 0 + [[ -n "${written_file_path}" ]] || return 0 + + current_owner_name="$(stat -c '%U' "${written_file_path}" 2>/dev/null || true)" + if [[ "${current_owner_name}" == "@SANDBOX_USER@" ]]; then + ai_tools_log_debug "PostToolUse handing back ${written_file_path} (owner ${current_owner_name})" + "${HANDBACK_CLIENT}" CHOWN "${written_file_path}" || true + fi + + # Normalize any directories the write just created. Claude Code's Write tool makes + # missing parent dirs owned by ai-tools at the agent's umask -- often world-traversable + # and never handed back. Walk upward from the file's directory and hand back each + # ai-tools-owned dir, stopping at the first dir the agent does NOT own: that is the + # pre-existing user tree (the project root and above, which is -owned), so the walk + # never leaves the project. The common case -- writing into an existing dir -- breaks on + # the first iteration with no socket call. ai-tools-chown re-validates each path against + # the allowlist as root. + parent_directory="$(dirname -- "${written_file_path}")" + while [[ "${parent_directory}" != "/" && "${parent_directory}" != "." ]]; do + [[ "$(stat -c '%U' "${parent_directory}" 2>/dev/null || true)" == "@SANDBOX_USER@" ]] || break + "${HANDBACK_CLIENT}" CHOWN "${parent_directory}" || true + parent_directory="$(dirname -- "${parent_directory}")" + done + return 0 +} + +main() { + local hook_invocation_mode="${1-}" hook_event_json="" + + # An empty stdin is not a tool call the harness made: it means this hook ran outside the + # session that feeds it (a hand invocation, a misconfigured declaration). Say so rather + # than exiting mute, for the same reason record_tool_call reports its gaps -- but at the + # lower level, since nothing was lost from the trail here; there was nothing to record. + hook_event_json="$(cat)" || return 0 + if [[ -z "${hook_event_json}" ]]; then + ai_tools_log_info "PostToolUse invoked with no event on stdin -- nothing to record or hand back" + return 0 + fi + + record_tool_call "${hook_event_json}" + + # The Bash form records and stops: a Bash-created file carries no file_path, so there is + # nothing for the handback to act on (the Stop sweep catches those at turn end). + if [[ "${hook_invocation_mode}" == "record" ]]; then + return 0 + fi + + hand_back_written_path "${hook_event_json}" +} -# Normalize any directories the write just created. Claude Code's Write tool -# makes missing parent dirs owned by ai-tools at the agent's umask -- often -# world-traversable and never handed back. Walk upward from the file's directory -# and hand back each ai-tools-owned dir, stopping at the first dir the agent does -# NOT own: that is the pre-existing user tree (the project root and above, which -# is -owned), so the walk never leaves the project. The common case -- writing -# into an existing dir -- breaks on the first iteration with no socket call. -# ai-tools-chown re-validates each path against the allowlist as root. -dir="$(dirname -- "${file}")" -while [[ "${dir}" != "/" && "${dir}" != "." ]]; do - [[ "$(stat -c '%U' "${dir}" 2>/dev/null || true)" == "@SANDBOX_USER@" ]] || break - /usr/local/bin/ai-tools-handback-client CHOWN "${dir}" || true - dir="$(dirname -- "${dir}")" -done +main "$@" diff --git a/src/opt/ai-tools/agents/claude-code/settings.json b/src/opt/ai-tools/agents/claude-code/settings.json index d1c0ef88..29ac28f8 100644 --- a/src/opt/ai-tools/agents/claude-code/settings.json +++ b/src/opt/ai-tools/agents/claude-code/settings.json @@ -116,6 +116,10 @@ { "type": "command", "command": "/opt/ai-tools/.claude/filter-hook.sh post-tool-use" + }, + { + "type": "command", + "command": "/opt/ai-tools/.claude/post-tool-hook.sh record" } ] } diff --git a/src/opt/ai-tools/bin/ai-tools-run.sh b/src/opt/ai-tools/bin/ai-tools-run.sh old mode 100644 new mode 100755 index cb15ca2a..cada2dc1 --- a/src/opt/ai-tools/bin/ai-tools-run.sh +++ b/src/opt/ai-tools/bin/ai-tools-run.sh @@ -13,6 +13,12 @@ # working directory) carried through sudo's env_keep. Both are re-validated here, so neither # side is a single point of trust. # +# What is CHECKED is what is EXEC'd. AI_TOOLS_AGENT_EXEC names the versioned launcher symlink; this +# shim resolves it once, contains the target to the same semver version directory, and uses that +# single path for the SELinux label preflight, the entrypoint pin, and the unit's ExecStart -- then +# re-resolves it immediately before the launch. What that window is, and why it is a DAC-only +# concern, are in launch.rule.md. +# # It names no agent. Which executables may launch, what environment each session gets, and # whether the session's ownership handback needs driving from here come from the root-owned # provider manifests under /usr/local/lib/ai-tools/agents.d and the session-env fragments under @@ -146,6 +152,47 @@ agent_display_name="$(ai_tools_agent_manifest_field "${agent_name}" display_name # session-end sweep below (see the sweep section). agent_handback="$(ai_tools_agent_manifest_field "${agent_name}" handback || true)" +# ── Entrypoint resolution: verify and exec the same inode ──────────────────────────────────── +# The path validated above is the versioned launcher SYMLINK; the file execve actually transitions +# on is what it resolves to. Resolve it ONCE here and use that single path for both the SELinux +# label preflight and the unit's ExecStart, so the file this shim checks is the file the manager +# runs -- rather than checking one path and handing systemd another to re-resolve at exec time. +# +# Containment: the resolved path must stay inside the SAME semver version directory the launcher +# was accepted at -- a property string-matching cannot carry across a symlink, so a link repointed +# at another version's tree, or out of the toolchain, is refused rather than exec'd. +# +# Frozen at the validated version: node_version is re-assigned to "n/a" further down when it fails +# the banner's display pattern, and the pre-launch re-check must resolve against the SAME root the +# first resolution used, not a display value. +readonly entrypoint_version_root="${AI_TOOLS_NVM_DIR}/versions/node/${node_version}/" + +# resolve_entrypoint : print the launcher's resolved, contained, executable target; non-zero when +# it does not resolve or leaves that root. Called twice -- once here, once immediately before the +# launch -- so the check and the re-check cannot drift. +resolve_entrypoint() { + local resolved + resolved="$(realpath -e "${agent_executable_path}" 2>/dev/null)" || return 1 + [[ "${resolved}" == "${entrypoint_version_root}"* && "${resolved}" != *"/../"* ]] || return 1 + [[ -f "${resolved}" && -x "${resolved}" ]] || return 1 + printf '%s' "${resolved}" +} + +# entrypoint_identity : print a change-detecting identity for the file -- device, inode, +# size, and ctime at nanosecond precision. Each of the three ways a same-uid process can swap an +# entrypoint moves it: a symlink repoint and a rename-over both land a different inode, and an +# in-place write bumps ctime (which no unprivileged caller can roll back -- utimes(2) sets atime +# and mtime, never ctime). Prints nothing when the path cannot be stat'd, which compares unequal. +entrypoint_identity() { + stat -c '%d:%i:%s:%z' -- "$1" 2>/dev/null || true +} + +session_exec_path="$(resolve_entrypoint)" \ + || refuse "the launcher does not resolve to an executable inside ${entrypoint_version_root}" \ + "resolved from: ${agent_executable_path}" \ + 'reprovision the toolchain: sudo ai-tools-bootstrap' +session_exec_identity="$(entrypoint_identity "${session_exec_path}")" + # ── Session working directory ──────────────────────────────────────────────────────────────── # A transient unit does not inherit the caller's cwd, so the wrapper's validated project # directory is passed through and re-validated here before it becomes --working-directory. @@ -175,9 +222,9 @@ export XDG_RUNTIME_DIR="/run/user/${UID}" # ai_tools_confinement_verdict; this block owns only the probing and the reporting. if command -v getenforce >/dev/null 2>&1; then selinux_mode="$(getenforce 2>/dev/null || echo unknown)" - # realpath resolves the launcher symlink chain to the real transition entrypoint. It - # succeeds here because this runs as @SANDBOX_USER@, which owns the 700 package directory. - entrypoint_path="$(realpath -e "${agent_executable_path}" 2>/dev/null || printf '%s' "${agent_executable_path}")" + # The already-resolved and contained entrypoint -- the same inode this shim hands systemd as + # ExecStart, so the label checked here is the label the transitioning execve reads. + entrypoint_path="${session_exec_path}" expected_label="" actual_label="" manager_domain="" module_present=no if command -v matchpathcon >/dev/null 2>&1; then expected_label="$(matchpathcon -n "${entrypoint_path}" 2>/dev/null | awk -F: '{print $3}' || true)" @@ -405,7 +452,7 @@ ai_tools_version="@AI_TOOLS_VERSION@"; [[ "${ai_tools_version}" == @*@ ]] && ai_ [[ "${node_version}" =~ ${VERSION_PATTERN} ]] || node_version="n/a" agent_version="n/a" -package_directory="$(realpath -e "${agent_executable_path}" 2>/dev/null || true)" +package_directory="${session_exec_path}" for _ in 1 2 3; do package_directory="${package_directory%/*}" [[ -n "${package_directory}" && -f "${package_directory}/package.json" ]] && break @@ -455,9 +502,69 @@ if ai_tools_agent_sweeps_at_exit "${agent_handback}"; then trap 'sweep_project_ownership || true' EXIT fi -# ExecStart is the entrypoint directly, so the manager's execve performs the domain transition -# with no intermediary. Run rather than exec: --pty implies --wait and returns the payload's -# status, which a fast failure below turns into an actionable breadcrumb. +# ── Last-moment entrypoint re-validation ───────────────────────────────────────────────────── +# Everything between resolving the entrypoint and starting the unit -- the label probe, the version +# reads, the session-env fragments, the banner -- is time in which a concurrent process running as +# this same account could swap the file out from under the check. Re-resolve and re-stat here, at +# the last instruction before the launch, so the window such a process would have to win is the +# systemd-run round trip rather than the whole preflight. +# +# This NARROWS the race; it does not close it. Only an exec root the agent cannot write removes it, +# which is exactly what the SELinux types give: on an enforcing host with the module loaded the nvm +# tree is read-only to ai_tools_t and there is no move to make, so this check is for the DAC-only +# deployment, where it is the only observer of a swap. Both the path and the identity are compared: +# a repoint changes the path, a rename-over keeps it and changes the inode, an in-place write keeps +# both and changes ctime. +# The pin is checked in the same breath, this being the one place where hashing the file and +# starting it are adjacent. A MISMATCH means the binary changed after root verified it, and refuses; +# an UNPINNED entrypoint launches unless the operator required otherwise. Why those two outcomes +# differ, and what each costs, are in updater.rule.md. +entrypoint_pin_verdict=unchecked +# Guarded, not bare: the pin is a check the launch tightens with, and a missing library is a broken +# install rather than agent action -- it degrades to "unchecked", which the require switch below +# turns into a refusal on a host that declared verification mandatory. +# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/entrypoint-verify.lib.sh +if source "${AI_TOOLS_LIB_DIR}/entrypoint-verify.lib.sh" 2>/dev/null \ + && declare -F ai_tools_entrypoint_check >/dev/null 2>&1; then + entrypoint_pin_verdict="$(ai_tools_entrypoint_check "${agent_name}" "${session_exec_path}")" || true +fi +# Through the library's own accessor, so this launch and the updater's activation gate cannot +# disagree about how strict the host is. +require_entrypoint_verify=no +declare -F ai_tools_entrypoint_verify_required >/dev/null 2>&1 \ + && ai_tools_entrypoint_verify_required && require_entrypoint_verify=yes +audit info "entrypoint: agent=${agent_name} pin=${entrypoint_pin_verdict} require=${require_entrypoint_verify}" + +case "${entrypoint_pin_verdict}" in + mismatch) + audit warning "REFUSED: entrypoint does not match its pin (${session_exec_path})" + refuse 'the agent entrypoint does not match the checksum its vendor signed for the installed version -- refusing to start the session' \ + "entrypoint: ${session_exec_path}" \ + 'The binary changed after it was verified. Treat this toolchain as tampered and reprovision it:' \ + ' sudo ai-tools-bootstrap' ;; + ok) ;; + *) if [[ "${require_entrypoint_verify}" == yes ]]; then + audit warning "REFUSED: entrypoint unverified (${entrypoint_pin_verdict}) and AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set" + refuse 'refusing to launch -- AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set in operator.conf, but this entrypoint carries no verified checksum.' \ + 'Pin it (this fetches the vendor'"'"'s signed release manifest, so the host must be online):' \ + ' ai-tools --relabel' + fi ;; +esac + +if [[ "$(resolve_entrypoint || true)" != "${session_exec_path}" \ + || "$(entrypoint_identity "${session_exec_path}")" != "${session_exec_identity}" ]]; then + audit warning "REFUSED: entrypoint changed between preflight and launch (${session_exec_path})" + refuse 'the agent entrypoint changed while this launch was being prepared -- refusing to start the session' \ + "entrypoint: ${session_exec_path}" \ + 'A toolchain update running at the same moment explains this: rerun the launch.' \ + 'If it repeats with no update running, treat the toolchain as untrusted:' \ + 'reprovision it: sudo ai-tools-bootstrap' +fi + +# ExecStart is the RESOLVED entrypoint, not the launcher symlink: the manager's execve performs the +# domain transition on the same inode this shim verified, with no link left for it to re-resolve. +# Run rather than exec: --pty implies --wait and returns the payload's status, which a fast failure +# below turns into an actionable breadcrumb. session_start_seconds=${SECONDS} session_exit_status=0 systemd-run --user --pty --quiet \ @@ -468,7 +575,7 @@ systemd-run --user --pty --quiet \ --property=RestrictNamespaces=yes \ --property=NoNewPrivileges=yes \ --property=UMask=0007 \ - -- "${agent_executable_path}" "$@" || session_exit_status=$? + -- "${session_exec_path}" "$@" || session_exit_status=$? if (( session_exit_status != 0 && SECONDS - session_start_seconds < 5 )); then audit warning "session unit ${session_unit_name} exited with status ${session_exit_status} at startup" diff --git a/src/opt/ai-tools/bin/nvm-update.sh b/src/opt/ai-tools/bin/nvm-update.sh index 3e2c13fc..26d9210a 100755 --- a/src/opt/ai-tools/bin/nvm-update.sh +++ b/src/opt/ai-tools/bin/nvm-update.sh @@ -153,6 +153,88 @@ verify_toolchain_signatures() { esac } +# Entrypoint verifier (entrypoint-verify.lib.sh). Best-effort source, same posture as the npm +# verifier above: a missing lib is a broken install, and degrades to "unable to verify". +readonly ENTRYPOINT_VERIFY_LIB="/usr/local/lib/ai-tools/entrypoint-verify.lib.sh" +# shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/entrypoint-verify.lib.sh +if ! source "${ENTRYPOINT_VERIFY_LIB}" 2>/dev/null \ + || ! declare -F ai_tools_entrypoint_release_verify >/dev/null 2>&1; then + warn "entrypoint verifier unavailable (${ENTRYPOINT_VERIFY_LIB}) -- skipping the release-checksum check" + ai_tools_entrypoint_release_verify() { return 2; } +fi + +# entrypoint_blocked[] : set where a declared release manifest exists but the freshly +# installed entrypoint did not verify against it AND the operator required verification. The +# repoint loop skips those launchers. Populated by verify_agent_entrypoints. +declare -A entrypoint_blocked=() +entrypoint_unverified=0 + +# verify_agent_entrypoints : check every enabled agent's just-installed entrypoint +# against the checksum its vendor signed for the version its package declares. A MISMATCH dies here, +# before the prune and the repoint, exactly as the npm gate does. Anything else is not fatal; see +# the branch comments below. +verify_agent_entrypoints() { + local target="$1" agent launcher entrypoint version rc + declare -F ai_tools_enabled_agents >/dev/null 2>&1 || return 0 + while IFS=$'\t' read -r agent _ launcher; do + [[ -n "${agent}" && -n "${launcher}" ]] || continue + local url_template + url_template="$(ai_tools_agent_manifest_field "${agent}" release_manifest_url || true)" + [[ -n "${url_template}" ]] || continue # declares no provenance: nothing to check + # Resolved, never executed: the versioned launcher is a symlink into the package, and what + # must be hashed is the file it points at -- the same inode the launch shim verifies. + entrypoint="$(realpath -e "${HOME}/.nvm/versions/node/${target}/bin/${launcher}" 2>/dev/null || true)" + if [[ -z "${entrypoint}" ]]; then + log "${agent}: ${launcher} not installed in ${target} -- nothing to verify" + continue + fi + version="$(agent_package_version "${entrypoint}")" + if [[ -z "${version}" ]]; then + warn "${agent}: could not read the installed version beside ${entrypoint} -- not verified" + ai_tools_entrypoint_verify_required 2>/dev/null \ + && { entrypoint_blocked["${launcher}"]=1; entrypoint_unverified=$(( entrypoint_unverified + 1 )); } + continue + fi + rc=0 + ai_tools_entrypoint_release_verify "${entrypoint}" "${version}" "${url_template}" \ + "$(ai_tools_agent_manifest_field "${agent}" release_key || true)" \ + "$(ai_tools_agent_manifest_field "${agent}" release_fingerprint || true)" \ + >/dev/null || rc=$? + case "${rc}" in + 0) log "${agent}: entrypoint matches the checksum signed for release ${version}" ;; + 1) die "${agent}: the installed entrypoint does NOT match the checksum its vendor signed for release ${version} -- refusing to activate it; the previous version stays in use" ;; + *) # Unverifiable does not block by default -- an air-gap decision (updater.rule.md): + # a host with an internal npm mirror and no vendor route keeps updating, unpinned. + # Where the operator required verification, activating such a release would instead + # refuse every launch, so the repoint is held back and the verified version stays. + if ai_tools_entrypoint_verify_required 2>/dev/null; then + warn "${agent}: could not verify release ${version} against its signed manifest -- not activating it (AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY is set); the previously verified version stays in use" + entrypoint_blocked["${launcher}"]=1; entrypoint_unverified=$(( entrypoint_unverified + 1 )) + else + warn "${agent}: could not verify release ${version} against its signed manifest -- activating it anyway; its entrypoint will be unpinned until the vendor's manifest is reachable" + fi ;; + esac + done < <(ai_tools_enabled_agents 2>/dev/null) + return 0 +} + +# agent_package_version : print the MAJOR.MINOR.PATCH the package beside the entrypoint +# declares, walking up to the nearest package.json. Bounded read; anything not semver yields +# nothing, so a crafted value cannot become part of a URL. +agent_package_version() { + local dir="${1%/*}" declared + for _ in 1 2 3; do + if [[ -f "${dir}/package.json" ]]; then + declared="$(head -c 65536 -- "${dir}/package.json" 2>/dev/null \ + | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + [[ "${declared}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && { printf '%s' "${declared}"; return 0; } + fi + dir="${dir%/*}" + [[ -n "${dir}" ]] || break + done + return 0 +} + # version_in_use: succeed if a live process is executing from this version's tree. # A session is pinned to the Node version it launched with (ai-tools-run sets PATH to # that version's bin and DISABLE_AUTOUPDATER=1), so pruning a version out from under a @@ -337,6 +419,12 @@ main() { # stays active. Runs against the just-installed global tree as the sandbox account. verify_toolchain_signatures + # Second gate, same position and the same fail-closed shape, asking the other question: the + # signature above says the package was DELIVERED untampered, this says the binary inside it is + # the one the vendor published. It runs as the sandbox account, so it is a fail-fast economy + # measure rather than the boundary -- the root-side pin is (updater.rule.md). + verify_agent_entrypoints "${target_version}" + prune_versions "${node_alias}" # Refresh the stable launcher symlink each wrapper resolves with one readlink hop -- one per @@ -354,6 +442,12 @@ main() { log "${launcher} not installed in ${target_version} -- skipping its stable-symlink repoint" continue fi + # Not activated: leaving the stable symlink where it is keeps the previously verified and + # pinned version in use, so the next launch works and this run changed nothing for it. + if [[ -n "${entrypoint_blocked[${launcher}]:-}" ]]; then + warn "not repointing ${AI_TOOLS_BIN}/${launcher}: its new entrypoint is unverified (see above); the previously verified version stays active" + continue + fi # Repoint (and, via the ai-tools-relabel.path watcher the touched bin directory drives, # relabel) is best-effort, NOT fatal: this warns rather than dying. A manual/out-of-band # run (this script's documented use) executes outside a session, where the handback @@ -366,6 +460,13 @@ main() { done log "Done. Active: $(nvm version "${node_alias}")" + + # Reported LAST, after every agent that did verify has been repointed, so one unverifiable + # agent never strands another. TRANSIENT rather than failed: nothing is broken and nothing + # changed for that agent. It goes through the stamp because that is the only channel an + # operator session can read at all -- see the stamp's own section in this file's header. + (( entrypoint_unverified == 0 )) \ + || skip unverified "${entrypoint_unverified} agent entrypoint(s) could not be verified against their vendor's signed release manifest -- not activated; the previously verified version stays in use" } main "$@" diff --git a/src/usr/local/bin/ai-tools.sh b/src/usr/local/bin/ai-tools.sh index 2a8ee8ea..4796eeb0 100755 --- a/src/usr/local/bin/ai-tools.sh +++ b/src/usr/local/bin/ai-tools.sh @@ -8,12 +8,20 @@ # secret lockdown -- through the sudo root helpers (no NOPASSWD: the operator is prompted for a # password; the sandbox account holds no grant). # -# Two preflight gates run before dispatch: require_bootstrap (provisioned install) and, for the +# Three preflight gates run before dispatch: require_bootstrap (provisioned install); for the # operator-acting commands (--project-*/--sandbox-*/--lockdown/--reclaim/--relabel), # require_operator -- the invoking user must be in OPERATORS in operator.conf, since the root -# helpers resolve the caller's identity from that list. --help/--version/--list/--providers stay +# helpers resolve the caller's identity from that list; and require_for_target, which validates a +# --for run and re-points the registry at its target. --help/--version/--list/--providers stay # open to any user. # +# --for performs a command ON BEHALF OF another enrolled operator: the allowlist entry +# lands in THEIR registry, so ai-tools-setfacl grants user:, the handback restores to them, +# and their agent's launch gate covers the path. It exists for a service account that runs an +# agent but holds no password to authenticate a claim of its own. The target's registry is +# unreadable to the invoker (0600 in a 0700 directory), so a --for run reads a root-side snapshot +# of it and routes its writes through ai-tools-allowlist. +# # Commands (each confirms before applying and reports the result): # --project-claim [path] claim a project in place -- grant the agent access (idempotent; # default: cwd); -y/--yes pre-answers its proceed prompt (delegated) @@ -33,7 +41,8 @@ # --reclaim [--full] [path] take back ownership of agent-written files -- the project stays # claimed and the agent keeps access; the on-demand ownership # handback, e.g. before an ACL-unaware backup (sudo; default: cwd) -# --relabel relabel the enabled agents' entrypoints after a Node upgrade (sudo) +# --relabel reconcile the enabled agents' entrypoints -- verify each against its +# vendor's signed release checksum and pin it, then relabel (sudo) # --providers report the installed agents/integrations, which are enabled, # and why (read-only; resolved through providers.lib.sh) # --status report ai-tools service health (read-only; services.lib.sh) @@ -110,6 +119,19 @@ readonly SAFEDIR_BIN="/usr/local/libexec/ai-tools/ai-tools-safedir" # under a project back to the operator via ai-tools-chown (the per-path trust boundary), needed for # the .git tree the per-session sweeps skip; useful before an ACL-unaware backup. readonly RECLAIM_BIN="/usr/local/libexec/ai-tools/ai-tools-reclaim" +# Root-only cross-operator allowlist helper, same sudo (no NOPASSWD) model. Reads and edits ANOTHER +# enrolled operator's allowed-projects for a --for run; root is needed for the READ too, since an +# allowlist is 0600 inside a 0700 .config/ai-tools. Only a --for run reaches it -- without the flag +# the CLI writes the invoker's own registry directly, as before. +readonly ALLOWLIST_BIN="/usr/local/libexec/ai-tools/ai-tools-allowlist" + +# Reader for the refusal/rejection trails (--audit). Root-only, since the trail it reads is +# 700 root:root; no NOPASSWD rule, so sudo prompts like the other per-project helpers. +readonly AUDIT_BIN="/usr/local/libexec/ai-tools/ai-tools-audit" +# Session-stop helper (--stop). Root-only, since a session is a transient unit in the sandbox +# account's own `systemd --user` manager, which no operator can reach; no NOPASSWD rule, so sudo +# prompts like the other root helpers. What it accepts, and why so little: cmd_stop. +readonly STOP_BIN="/usr/local/libexec/ai-tools/ai-tools-stop" # Sentinel in a guard CLAUDE.md (see drop_lockdown_guard) so the lockdown step can # recognise and remove its own placeholder once secrets are secured. readonly GUARD_MARKER="ai-tools-lockdown-guard" @@ -119,11 +141,6 @@ readonly GUARD_MARKER="ai-tools-lockdown-guard" # write the registries with the wrong owner) and never as the sandbox account # (the agent must not manage its own allowlist). ME="$(id -un)" -# The invoking operator's own primary group, for the one message that must name it: the lockdown -# preamble, which states the owner a locked secret ends up with (:). Not a decision -# input anywhere -- what a walk treats as "the operator's group" is resolved per path from the -# path's owner, never from who happens to be running the CLI. -MY_GROUP="$(id -gn)" [[ "${ME}" == "root" ]] \ && { echo "ai-tools: do not run as root -- run as the projects user, without sudo" >&2 echo " (the CLI invokes sudo itself for the steps that need it)" >&2; exit 1; } @@ -133,10 +150,63 @@ MY_GROUP="$(id -gn)" HOME_DIR="$(getent passwd "${ME}" | cut -d: -f6)" [[ -d "${HOME_DIR}" ]] || { echo "ai-tools: cannot resolve home for ${ME}" >&2; exit 1; } readonly ME HOME_DIR -# One resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that -# sets AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see -# the GITCONFIG note above for why the override grants the CLI's operator caller nothing new. -readonly ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}" + +# ── --for : act on another enrolled operator's project registry ──────── +# A service account that runs an agent has no password, so it cannot authenticate the claim's own +# root helpers -- and a project claimed by a human lands in the HUMAN's registry, which is not the +# one that account's launch gate reads. --for closes both: a human operator performs the claim ON +# BEHALF OF the target, whose allowlist then covers the path, so ai-tools-setfacl grants +# user:, the handback restores to , and that account's own launch finds the project +# already claimed and never reaches a password prompt. +# +# The flag is separated from the command's own arguments HERE, before the registry path below is +# resolved and before dispatch, so every command reads one already-decided owner instead of each +# parsing the flag itself. Validation (is the target enrolled, does this verb accept --for) needs +# conf.lib.sh and runs at the dispatch gate. +FOR_OPERATOR="" +_forless_args=() +while (( $# )); do + case "$1" in + --for) [[ -n "${2:-}" && "${2:-}" != -* ]] \ + || { echo "ai-tools: --for needs an operator name" >&2; exit 1; } + FOR_OPERATOR="$2"; shift 2 ;; + --for=*) FOR_OPERATOR="${1#--for=}" + [[ -n "${FOR_OPERATOR}" ]] \ + || { echo "ai-tools: --for needs an operator name" >&2; exit 1; } + shift ;; + *) _forless_args+=("$1"); shift ;; + esac +done +set -- "${_forless_args[@]}" +unset _forless_args + +# The operator this run acts FOR: the --for target, or the invoker. Every message that names the +# owner a file ends up with, and every scan that matches on that owner, reads these rather than ME +# -- on a --for run the tree belongs to the target, so naming the invoker would misreport who ends +# up holding the files. What a root helper's walk treats as "the operator" is still resolved per +# path from the path's own allowlist coverage, never from either of these. +OWNER_USER="${FOR_OPERATOR:-${ME}}" +# Without --for the owner is the invoker, whose group always resolves. With --for the group is +# resolved by require_for_target only AFTER the target is confirmed enrolled: a name that is +# neither an operator nor a user on this host has to be refused with the actionable "not a +# configured ai-tools operator -- enrol it with ..." message, not with a getent failure that names +# the wrong problem. +OWNER_GROUP="" +if [[ -z "${FOR_OPERATOR}" ]]; then + OWNER_GROUP="$(id -gn "${OWNER_USER}" 2>/dev/null)" \ + || { echo "ai-tools: cannot resolve the primary group of ${OWNER_USER}" >&2; exit 1; } +fi +readonly FOR_OPERATOR OWNER_USER + +# The registry this run reads and writes. Without --for it is the invoker's own file, read and +# written directly. With --for, require_for_target re-points it at a root-side SNAPSHOT of the +# target's file: an allowlist is 0600 inside a 0700 .config/ai-tools, so one operator cannot read +# another's at all, and every decision made from it (is the path listed, which '!' exclusions +# apply, what --list reports) would otherwise read an unreadable file as an empty one. One +# resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that sets +# AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see the +# GITCONFIG note above for why the override grants the CLI's operator caller nothing new. +ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}" # ── Output / prompt helpers ────────────────────────────────────────────────────── if [[ -t 1 ]]; then @@ -306,6 +376,18 @@ require_sandbox_clone() { reg_allow() { local dir="$1" + # A --for run edits a registry in a home this operator cannot even read, so the write goes + # through the root helper (which re-reads the real file and applies its own idempotency), and + # the snapshot is refreshed so the rest of this run sees the entry it just added. + if [[ -n "${FOR_OPERATOR}" ]]; then + if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --add "${dir}" >/dev/null; then + snapshot_allowlist + say " allowed-projects: added for ${FOR_OPERATOR}" + else + die "could not add ${dir} to ${FOR_OPERATOR}'s allowed-projects" + fi + return 0 + fi [[ -f "${ALLOWLIST}" ]] || die "allowlist not found at ${ALLOWLIST} -- run install first" # Match through the shared grammar, not a raw line: a hand-added entry with a comment or # quotes is already listed, and appending would duplicate it (conf.lib.sh). @@ -326,6 +408,18 @@ allow_escape() { printf '%s' "$1" | sed 's/[]\.*^$|[]/\\&/g'; } unreg_allow() { local dir="$1" + # A --for run de-lists through the root helper, which applies the same raw-line matcher below + # to the real file; the snapshot is refreshed so a later read in this run agrees with it. + if [[ -n "${FOR_OPERATOR}" ]]; then + if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --remove "${dir}" >/dev/null; then + snapshot_allowlist + say " allowed-projects: removed for ${FOR_OPERATOR}" + else + warn "could not remove ${dir} from ${FOR_OPERATOR}'s allowed-projects -- run:" + say " ${C_BOLD}sudo ${ALLOWLIST_BIN} --operator ${FOR_OPERATOR} --remove ${dir}${C_RST}" + fi + return 0 + fi [[ -f "${ALLOWLIST}" ]] || return 0 # Delete the RAW line(s) whose grammar entry matches ${dir}, not a line rebuilt from ${dir}: # a hand-added entry may carry a comment or quotes (conf.lib.sh), and anchoring on ${dir} @@ -486,7 +580,7 @@ acl_drift_scan() { [[ "${excl}" == "${dir}"/* ]] && skip+=( -o -path "${excl}" -prune ) done < <(grep '^!' "${ALLOWLIST}" 2>/dev/null || true) find "${dir}" -xdev \( "${skip[@]}" \) -o \ - \( -user "${ME}" -o -user "${SANDBOX_USER}" \) \ + \( -user "${OWNER_USER}" -o -user "${SANDBOX_USER}" \) \ ! -group "${SANDBOX_GROUP}" -perm /077 -print 2>/dev/null } @@ -568,14 +662,19 @@ agent_can_traverse() { return 1 } -# grantable_ancestor -- 0 if reg_reach may grant traverse on : the operator OWNS it and -# it is not a protected system directory (the safe-paths backstop). Fail-closed when the predicate -# is unavailable, so a broken install never widens a directory it cannot vet. +# grantable_ancestor -- 0 if reg_reach may grant traverse on : the project's OWNER owns +# it and it is not a protected system directory (the safe-paths backstop). Fail-closed when the +# predicate is unavailable, so a broken install never widens a directory it cannot vet. +# +# On a --for run the owner is the target, whose directories the invoker may not be able to setfacl; +# the grant is still offered, because the alternative -- declining a reachable path outright -- +# would report a working project as unreachable. An unprivileged setfacl that is refused falls to +# reg_reach's per-path warning, which prints the exact command to run as the owner or as root. grantable_ancestor() { local p="$1" declare -F ai_tools_protected_path_match >/dev/null 2>&1 || return 1 if ai_tools_protected_path_match "${p}" >/dev/null 2>&1; then return 1; fi - [[ "$(stat -c '%U' "${p}" 2>/dev/null || true)" == "${ME}" ]] + [[ "$(stat -c '%U' "${p}" 2>/dev/null || true)" == "${OWNER_USER}" ]] } # reach_scan -- detect the traverse gap between the sandbox account and : @@ -618,7 +717,7 @@ reg_reach() { elif ai_tools_protected_path_match "${REACH_BLOCKED}" >/dev/null 2>&1; then why="a protected system directory" else - why="owned by $(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || echo '?'), not by ${ME}" + why="owned by $(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || echo '?'), not by ${OWNER_USER}" fi headline_warn "WARNING: project unreachable for the sandbox account" \ "the sandbox account cannot traverse ${REACH_BLOCKED} (${why}), so it cannot reach ${dir}; an isolated clone under the sandbox area is the way in:" @@ -1367,7 +1466,7 @@ resolve_handback_group() { # Default YES: the natural completion of an unclaim. Still confirmed, because it rewrites # ownership and permissions across the tree. if confirm "Hand the files back to a group and remove the agent's write access?" y; then - hb_user="$(ask " Hand the files to which user's group?" "${ME}")" + hb_user="$(ask " Hand the files to which user's group?" "${OWNER_USER}")" if ! HANDBACK_GROUP="$(id -gn "${hb_user}" 2>/dev/null)"; then warn "no such user '${hb_user}' -- skipping the filesystem hand-back" HANDBACK_GROUP=""; HANDBACK_HINT=1 @@ -1952,7 +2051,7 @@ cmd_lockdown() { # If it is genuinely missing, sudo reports it and run_lockdown returns non-zero. section "Lock down project secrets" say " ${d}" - say " ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${ME}:${MY_GROUP}${C_RST}" + say " ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${OWNER_USER}:${OWNER_GROUP}${C_RST}" if run_lockdown "${d}" "${passthru[@]}"; then ${dry} || clear_lockdown_guard "${d}" ok "lockdown done: ${d}" @@ -1963,7 +2062,7 @@ cmd_lockdown() { } # cmd_reclaim [--full] [path] -- hand agent-written files under the project (default: cwd) back to -# ${ME}:${SANDBOX_GROUP} via ai-tools-reclaim (sudo). Reclaims the .git tree the per-session sweeps +# ${OWNER_USER}:${SANDBOX_GROUP} via ai-tools-reclaim (sudo). Reclaims the .git tree the per-session sweeps # skip; run it before an ACL-unaware backup so ownership (not the per-project ACL) carries the # operator's access into the copy. --full also reclaims the heavy trees the default run skips # (node_modules, .venv, ...). @@ -1984,7 +2083,7 @@ cmd_reclaim() { " list your registered projects with: ai-tools --list" section "Reclaim agent-written files" say " ${d}${C_DIM}$(${full} && printf ' (--full: incl. node_modules, .venv, ...)')${C_RST}" - say " ${C_DIM}-> ${ME}:${SANDBOX_GROUP} (secret-named files stay ${ME}:${ME} 600)${C_RST}" + say " ${C_DIM}-> ${OWNER_USER}:${SANDBOX_GROUP} (secret-named files stay ${OWNER_USER}:${OWNER_GROUP} 600)${C_RST}" # The helper reports the outcome itself -- the pre-scan count, the one whole-set # confirm, then "handed back N" / "nothing to reclaim" / "declined" -- so no blanket # success line here: the CLI states only what actually happened. @@ -1992,32 +2091,103 @@ cmd_reclaim() { ai_tools_log_info "reclaim run for ${d}$(${full} && printf ' (full)')" } -# cmd_relabel -- restore the ai_tools_exec_t SELinux label on each enabled agent's entrypoint -# after a Node auto-upgrade, via the root helper (sudo, no password: the dedicated rule). An -# nvm-update installs a fresh agent binary that npm leaves mislabelled (bin_t), so the domain -# transition stops firing and ai-tools-run refuses to launch (fail-closed) until the label is -# restored. Takes no path -- the helper resolves the entrypoints from the agent manifests. +# cmd_relabel -- reconcile each enabled agent's entrypoint after a toolchain change, via the root +# helper (sudo, no password: the dedicated fixed-path rule). Two steps, in this order: +# 1. VERIFY the entrypoint against the checksum its vendor signed and record it in that agent's +# pin, which ai-tools-run compares the binary against at launch. Needs the host online, and +# fails soft when it cannot reach the vendor; a MISMATCH fails the command. +# 2. RELABEL it to ai_tools_exec_t. An nvm-update installs a fresh agent binary that npm leaves +# mislabelled (bin_t), so the domain transition stops firing and ai-tools-run refuses to +# launch (fail-closed) until the label is restored. +# Takes no path -- the helper resolves the entrypoints from the agent manifests. # -# Design note: if post-upgrade maintenance ever grows beyond this one step, fold the steps -# under a `--postupgrade` umbrella verb that runs them in sequence; while relabel is the -# only step, the explicit `--relabel` is clearer in the UX, so there is no umbrella yet. +# The verb keeps the name it had when relabelling was its only step; why the two are one command +# is in .claude/rules/cli.rule.md. cmd_relabel() { [[ "$#" -eq 0 ]] || die "--relabel takes no arguments" - section "Relabel the agent entrypoints (after a Node upgrade)" - say " A Node auto-upgrade installs new agent binaries that must be relabelled so" - say " the sandbox can confine the session; until then the agent refuses to launch." + section "Reconcile the agent entrypoints (after a toolchain change)" + say " Verifies each agent binary against the checksum its vendor signed, then relabels" + say " it so the sandbox can confine the session; until then the agent refuses to launch." command -v sudo >/dev/null 2>&1 \ - || die "sudo not found -- cannot relabel; run as root: ${RELABEL_ENTRYPOINT_BIN}" + || die "sudo not found -- cannot reconcile; run as root: ${RELABEL_ENTRYPOINT_BIN}" # Reaches the helper through the dedicated fixed-path NOPASSWD rule (the same one the # nvm-update timer uses), so this runs as root without a password prompt. if sudo "${RELABEL_ENTRYPOINT_BIN}"; then - ok "entrypoints relabelled -- exit any running session and relaunch" - ai_tools_log_info "relabelled the agent entrypoints (post-upgrade)" + ok "entrypoints reconciled -- exit any running session and relaunch" + ai_tools_log_info "reconciled the agent entrypoints (verify + relabel)" else - die "relabel failed -- see the message above" + die "reconcile failed -- see the message above" fi } +# cmd_audit -- report what has refused, been rejected, been stranded or been flagged since a +# given time. A thin pass-through to the root helper, which does the reading and the rendering: +# the trail is 700 root:root, so there is nothing this unprivileged CLI could usefully do with +# it first. The helper's EXIT STATUS is propagated deliberately -- non-zero means findings -- +# so `ai-tools --audit` is usable from cron or a login banner without parsing its output, the +# same contract --status already offers. +cmd_audit() { + command -v sudo >/dev/null 2>&1 \ + || die "sudo not found -- cannot read the root-only trail; run as root: ${AUDIT_BIN}" + sudo "${AUDIT_BIN}" "$@" +} + +# cmd_stop -- terminate every running agent session, through ai-tools-stop. Thin by design, and the +# thinness is the whole contract: the command takes no target and no authorization input, so there +# is nothing on this side to decide. What a stop reaches follows from membership of the sandbox +# account's cgroup slice, which only the root helper can read, and every remaining decision is a +# security decision that must not be made twice in two places. Option grammar is all that lives +# here. Why the command is shaped this way: docs/session-stop.md. +# +# The helper's EXIT STATUS propagates unchanged, so a caller reads one set of codes whichever side +# refused. They are listed in ai-tools(1) and are not restated here, so the two cannot drift. +# +# die_stop_usage -- refuse a --stop command line in the HELPER's exit-code space (2 = usage), not +# the CLI's own (die exits 1). Because cmd_stop propagates the helper's status, 2 is what a caller +# reading --stop's exit code is told a usage error is -- in ai-tools(1) and docs/session-stop.md +# alike -- and WHICH SIDE refused is an implementation detail of the ordering below, not something +# the caller asked about. Exiting 1 here would report the same mistake as one code from the CLI and +# another from a direct root call, and 1 already means "a process survived SIGKILL". +die_stop_usage() { ai_tools_log_error "$*"; ai_tools_msg_error "ai-tools: $*"; exit 2; } + +cmd_stop() { + local argument; local -a passthru=() + for argument in "$@"; do + case "${argument}" in + # --all is accepted and inert; ai-tools(1) says why it exists at all. + --all|-n|--dry-run|-y|--yes|--force) passthru+=("${argument}") ;; + -*) die_stop_usage "unknown --stop option: ${argument}" \ + "allowed: --all, --dry-run/-n, --yes/-y, --force" ;; + # A PATH IS REFUSED HERE, NOT PASSED ON. The helper refuses it too -- that is the last + # line, for a direct root call -- but the refusal has to happen on this side as well, + # BEFORE the sudo below: a command that is going to be refused must not first prompt + # for a password (the ordering rule --for follows). Why refusing beats ignoring is in + # the helper's refuse_positional_argument. + # + # THIS TEXT IS A DELIBERATE TWIN of that function's, and the duplication is unavoidable: + # the two run in different processes and the helper is 750 root:root, so neither can + # source the other, while an operator meets whichever side refused. The two must say the + # same thing and offer the same four commands -- change one, change both. + # + # The commands are printed PLAIN, ahead of die(): die() joins its arguments and wraps + # them through the error emitter, which would break a command across lines + # (messaging.rule.md). + *) printf '\n' >&2 + printf ' %s\n' \ + "Terminate every session: ai-tools --stop" \ + "See what is running first: ai-tools --stop --dry-run" \ + "End one session cleanly: /exit inside it, which runs its session-end handback" \ + "Terminate one by hand: sudo systemctl --user -M ${SANDBOX_USER}@.host stop " >&2 + printf '\n' >&2 + die_stop_usage "--stop takes no path: ${argument}. It TERMINATES every agent session on this host -- killing the process tree, so no session-end handback runs -- and has no per-project form, because a session is attributed to a project by the sandbox account's own user manager -- the account being stopped -- so that attribution is reported, never trusted to decide what a stop reaches." ;; + esac + done + command -v sudo >/dev/null 2>&1 \ + || die "sudo not found -- a session runs in the sandbox account's cgroups, which only root can signal" \ + "run as root: ${STOP_BIN}" + sudo "${STOP_BIN}" "${passthru[@]}" +} + # cmd_providers -- report the installed providers of both kinds and, for each, whether a # session gets it and why. Read-only: it resolves through providers.lib.sh, the same resolver # ai-tools-run and the toolchain layer use, so what it reports is what a session gets rather than @@ -2194,7 +2364,7 @@ list_maintenance_note() { say " ai-tools --project-unclaim release a project (revoke agent access)" say " ai-tools --reclaim [--full] take back ownership; project stays claimed" say " ai-tools --lockdown lock down secret-named files" - say " ai-tools --relabel relabel the agent entrypoints after a Node upgrade" + say " ai-tools --relabel re-verify and relabel the agent entrypoints" } # status_fmt_age -- render an age the way an operator reads it ("3 days ago"), not as a @@ -2240,6 +2410,78 @@ status_sandbox_unit_commands() { # healthy, its consequence and the exact commands that inspect and fix it. # Reuses services.lib.sh -- the SAME registry the launch-time warning reads -- so the status view and # the launch warning never disagree. Informational (no operator gate), like --list/--providers. +# status_entrypoint_pins -- report, per enabled agent, whether its entrypoint carries a verified +# checksum. This is the ONE piece of the verification an operator can observe: the entrypoint itself +# lives in the 0750 toolchain they cannot read (which is why its LABEL stays unreportable here), but +# the pin is a root-owned record placed where they can. Without this line the only signals are a +# warning in a journal the operator cannot reach and, eventually, a refused launch. +# +# The pin is written in the same KEY=value stamp grammar as the updater's last-run record, so it is +# read through the SAME accessors -- charset-clamped fields and one age implementation - rather than +# a second reader that could drift. Its path comes from entrypoint-verify.lib.sh, never hardcoded. +# +# Returns non-zero only when an unpinned entrypoint is actually actionable, which is exactly when +# the operator has required verification: everywhere else unpinned is a legitimate state (an +# air-gapped host, a release the vendor published no manifest for) and must not make a healthy host +# alarm, the same rule the unqueryable units follow. A pin this account cannot read is reported as +# unknown and is never a fault -- --status stays open to a non-operator, who cannot traverse the +# state directory at all. +status_entrypoint_pins() { + local providers_lib=/usr/local/lib/ai-tools/providers.lib.sh + local verify_lib=/usr/local/lib/ai-tools/entrypoint-verify.lib.sh + # shellcheck source=SCRIPTDIR/../lib/ai-tools/providers.lib.sh + source "${providers_lib}" 2>/dev/null || true + # shellcheck source=SCRIPTDIR/../lib/ai-tools/entrypoint-verify.lib.sh + source "${verify_lib}" 2>/dev/null || true + declare -F ai_tools_enabled_agents >/dev/null 2>&1 || return 0 + declare -F ai_tools_entrypoint_pin_path >/dev/null 2>&1 || return 0 + declare -F ai_tools_service_stamp_field >/dev/null 2>&1 || return 0 + + local strict=no + declare -F ai_tools_entrypoint_verify_required >/dev/null 2>&1 \ + && ai_tools_entrypoint_verify_required && strict=yes + + local agent pin version verified age seen=0 unpinned=0 + while IFS=$'\t' read -r agent _ _; do + [[ -n "${agent}" ]] || continue + # An agent whose package declares no release manifest has nothing to verify against, so it + # is left out entirely rather than reported as perpetually unpinned. + [[ -n "$(ai_tools_agent_manifest_field "${agent}" release_manifest_url 2>/dev/null || true)" ]] || continue + (( seen++ == 0 )) && section "Entrypoint verification" + pin="$(ai_tools_entrypoint_pin_path "${agent}" 2>/dev/null || true)" + version="$(ai_tools_service_stamp_field "${pin}" VERSION)" + if [[ -n "${version}" ]]; then + verified="$(ai_tools_service_stamp_age "${pin}" VERIFIED)" + age="$(status_fmt_age "${verified}")" + printf ' %-28s %sVERIFIED%s %s(%s%s)%s\n' "${agent}" "${C_GRN}" "${C_RST}" \ + "${C_DIM}" "${version}" "${age:+, ${age}}" "${C_RST}" + elif [[ -e "${pin}" && ! -r "${pin}" ]]; then + # Not a fault: --status stays open to a non-operator, who cannot traverse the state + # directory at all. It says only that this vantage cannot tell. + printf ' %-28s %s? (pin not readable from this account)%s\n' "${agent}" "${C_DIM}" "${C_RST}" + elif [[ -e "${pin}" ]]; then + # Readable but carrying no VERSION the clamped reader will accept. Distinct from both + # states above and from a missing pin, because the remedy is to rewrite it -- and it is + # never read as verified, since the version check above is what gates that line. + printf ' %-28s %sunverified%s %s(pin present but unreadable -- rewrite it: ai-tools --relabel)%s\n' \ + "${agent}" "${C_DIM}" "${C_RST}" "${C_DIM}" "${C_RST}" + else + unpinned=$(( unpinned + 1 )) + if [[ "${strict}" == yes ]]; then + printf ' %-28s %sUNVERIFIED%s\n' "${agent}" "${C_YEL}" "${C_RST}" + say " this host requires verification, so its sessions will not launch" + say " ${C_BOLD}ai-tools --relabel${C_RST} ${C_DIM}(needs network -- it fetches the vendor's signed manifest)${C_RST}" + else + printf ' %-28s %sunverified%s %s(no pin -- launches are not blocked)%s\n' \ + "${agent}" "${C_DIM}" "${C_RST}" "${C_DIM}" "${C_RST}" + fi + fi + done < <(ai_tools_enabled_agents 2>/dev/null) + + [[ "${strict}" == yes && "${unpinned}" -gt 0 ]] && return 1 + return 0 +} + cmd_status() { local problems=0 @@ -2345,6 +2587,8 @@ cmd_status() { fi done < <(ai_tools_service_records) + status_entrypoint_pins || problems=$(( problems + 1 )) + # Pointers, not duplication: name the sibling read-only reports (which own their own detail) and # where the full command list lives, so --status is a hub without re-implementing --providers or # --help. @@ -2367,7 +2611,13 @@ cmd_status() { # and verbs -- no recovery machinery of its own. cmd_list() { [[ -f "${ALLOWLIST}" ]] || { say "no allowlist at ${ALLOWLIST}"; return 0; } - section "Registered projects" + # Name the operator on a --for run: the entries below are that account's launch gate, not the + # invoker's, and an unlabelled listing of someone else's projects reads as your own. + if [[ -n "${FOR_OPERATOR}" ]]; then + section "Registered projects for ${FOR_OPERATOR}" + else + section "Registered projects" + fi local raw entry excl kind safe sd shown=0 local -a cleanup=() @@ -2501,8 +2751,10 @@ ai-tools -- manage Claude Code sandbox projects (run as the projects user) ai-tools --sandbox-remove [path] remove a sandbox clone and unregister it ai-tools --lockdown [path] [-n|-y] lock down secret files (sudo; default: cwd) ai-tools --reclaim [--full] [path] take back ownership of agent files; project stays claimed (sudo; default: cwd) - ai-tools --relabel relabel the agent entrypoints after a Node upgrade (sudo) + ai-tools --stop terminate every agent session and all it spawned (sudo) + ai-tools --relabel re-verify and relabel the agent entrypoints (sudo) ai-tools --providers list installed agents/integrations and which are enabled + ai-tools --audit [--since ] report what refused, was rejected or stranded (sudo; default: 7 days) ai-tools --status report service health (handback socket, relabel watcher, updater) ai-tools --list list registered projects ai-tools --version @@ -2521,6 +2773,26 @@ ai-tools -- manage Claude Code sandbox projects (run as the projects user) directory name; default: repo basename), -y/--yes (skip the create confirm) --lockdown options: -n/--dry-run (preview only), -y/--yes (skip confirmation) --reclaim options: --full (also reclaim node_modules, .venv, ... not just the work tree + .git) + --stop options: -n/--dry-run (list what would be terminated, change nothing), -y/--yes + (skip the confirmation, which DEFAULTS TO YES here: an unattended stop + that declines is a stop that failed), --force (kill immediately, no + grace period -- the current turn's unsaved work is lost), --all + (accepted and inert; every run already terminates every session). + It takes NO path: there is no per-project form, because a session is + attributed to a project by the account being stopped. To finish one + session cleanly use /exit inside it, which runs its session-end + handback. Exits 1 if anything survived, 2 on a path, 4 declined. + --audit options: --since (anything date(1) parses: '2 days ago', '2026-08-01'; + default: 7 days ago). Exits non-zero when anything is reported, so it + is usable from cron or a login banner without parsing its output. + + --for act on another enrolled operator's projects instead of your own: the + entry lands in THEIR allowed-projects, so the tree is granted to them and + their agent launches there. For a service account that runs an agent but + has no password to authenticate a claim of its own. Accepted on + --project-claim/-create, --project-unclaim/-remove, --lockdown, + --reclaim and --list; not with --project-unclaim --force. + Enrol the target first: sudo ai-tools-admin operator add Sandbox workflow: /var/opt/ai-tools/README.md EOF @@ -2566,6 +2838,83 @@ require_operator() { " sudo ai-tools-admin operator add ${ME}" } +# snapshot_allowlist -- point ALLOWLIST at a private copy of the --for target's registry, read +# through the root helper. The copy is read-only input for THIS run: every mutation goes back +# through the helper, which re-reads the real file, so a stale snapshot can never be what a write +# is based on -- and reg_allow/unreg_allow refresh it after theirs. mktemp creates it 0600, and the +# EXIT trap removes it, so another operator's project list does not outlive the command. +ALLOWLIST_SNAPSHOT="" +snapshot_allowlist() { + if [[ -z "${ALLOWLIST_SNAPSHOT}" ]]; then + ALLOWLIST_SNAPSHOT="$(mktemp)" || die "cannot create a temporary file for the allowlist snapshot" + trap 'rm -f -- "${ALLOWLIST_SNAPSHOT}"' EXIT + fi + # shellcheck disable=SC2024 # the redirect is meant to be the CALLER's: root reads the + # 0600 allowlist, this shell writes the snapshot it owns. `sudo tee` would create the temp + # file as root and leave the CLI unable to read back what it just asked for. + sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --print > "${ALLOWLIST_SNAPSHOT}" \ + || die "could not read ${FOR_OPERATOR}'s allowed-projects" + ALLOWLIST="${ALLOWLIST_SNAPSHOT}" +} + +# require_for_target [verb-args...] -- validate a --for run, resolve the target's group, and +# re-point ALLOWLIST at the target's registry. A no-op without the flag, so nothing below changes +# for an ordinary run. +# +# EVERY refusal here precedes snapshot_allowlist, which is the run's first sudo: a command that is +# going to be refused must not first prompt the operator for a password. That ordering is why the +# --force incompatibility is checked HERE, on the verb's own arguments, rather than where --force is +# parsed in cmd_project_unclaim -- that runs after this gate, so the prompt would come first. +# +# --for is accepted only on the verbs whose whole effect is decided by WHICH operator's allowlist +# covers the path: the registry pair, the two per-project root helpers that gate on allowlist +# coverage, and the listing. Elsewhere it is REFUSED rather than ignored -- a --sandbox-create +# --for that silently cloned as the invoker would leave the tree owned by the wrong operator with +# nothing to show the flag was disregarded. +# +# The target must be ENROLLED in OPERATORS: ai-tools-setfacl and the handback helpers resolve a +# path's owner over that list, so an entry written for an unenrolled name would create a launch +# gate no ownership machinery can act on. Enrollment is checked before the group lookup, so an +# unknown name is refused with the enrolment command rather than a getent failure. +require_for_target() { + local verb="${1:-}"; shift || true + [[ -n "${FOR_OPERATOR}" ]] || return 0 + case "${verb}" in + --project-claim|--project-create|--project-unclaim|--project-remove|\ + --lockdown|--reclaim|--list) ;; + *) die "--for is not accepted on ${verb}" \ + "it applies to: --project-claim, --project-create, --project-unclaim," \ + " --project-remove, --lockdown, --reclaim, --list" ;; + esac + # --force reaches a tree NO allowlist names, so ai-tools-unclaim cannot resolve its owner from + # an entry and binds the walk to the INVOKING uid instead -- the guard that stops one operator + # rewriting another's files. Honouring --for there would have the CLI name one operator while + # the helper acted as another. + local a + for a in "$@"; do + [[ "${a}" == "--force" ]] || continue + die "--for cannot be combined with --force" \ + "an unlisted tree has no allowlist entry naming its owner, so the unclaim is bound to" \ + " you as the invoking operator; run it as ${FOR_OPERATOR}, or unclaim the registered" \ + " project without --force" + done + [[ "${FOR_OPERATOR}" != "${SANDBOX_USER}" ]] \ + || die "the sandbox account is not an operator and must not own projects" + [[ "${FOR_OPERATOR}" != "root" ]] || die "root is not an operator" + local conf="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}" + local -a ops=(); local op found=false + if ai_tools_conf_list ops "${conf}" OPERATORS 2>/dev/null; then + for op in "${ops[@]}"; do + [[ "${op}" == "${FOR_OPERATOR}" ]] && { found=true; break; } + done + fi + ${found} || die "${FOR_OPERATOR} is not a configured ai-tools operator -- enrol it first with:" \ + " sudo ai-tools-admin operator add ${FOR_OPERATOR}" + OWNER_GROUP="$(id -gn "${FOR_OPERATOR}" 2>/dev/null)" \ + || die "cannot resolve the primary group of ${FOR_OPERATOR}" + snapshot_allowlist +} + # Gate the operator-acting commands up front; the informational ones (--help/--version/--list/ # --providers) stay open so an unenrolled user can still read usage and inspect the host. case "${1:-}" in @@ -2574,6 +2923,11 @@ case "${1:-}" in --lockdown|--reclaim|--relabel) require_operator ;; esac +# Validate a --for run and re-point the registry at the target, after require_operator: acting for +# another operator is an operator action, so the invoker must be enrolled before the target is even +# looked up. +require_for_target "$@" + # ── Dispatch ───────────────────────────────────────────────────────────────────── case "${1:-}" in --project-claim) shift; cmd_project_claim "$@" ;; @@ -2587,6 +2941,8 @@ case "${1:-}" in --reclaim) shift; cmd_reclaim "$@" ;; --relabel) shift; cmd_relabel "$@" ;; --providers) shift; cmd_providers "$@" ;; + --audit) shift; cmd_audit "$@" ;; + --stop) shift; cmd_stop "$@" ;; --status) cmd_status ;; --list) cmd_list ;; --version|-V) printf 'ai-tools %s\n' "${AI_TOOLS_VERSION}" ;; diff --git a/src/usr/local/lib/ai-tools/agents.d/claude-code.conf b/src/usr/local/lib/ai-tools/agents.d/claude-code.conf index 467c7588..40a7a1f8 100644 --- a/src/usr/local/lib/ai-tools/agents.d/claude-code.conf +++ b/src/usr/local/lib/ai-tools/agents.d/claude-code.conf @@ -39,3 +39,13 @@ entrypoint_fcontext=/opt/ai-tools/\.nvm/versions/node/[^/]+/lib/node_modules/@an # Provisioned by default when operator.conf names no explicit AI_TOOLS_AGENTS set: Claude Code # is the baseline agent this project ships, and it widens no host surface beyond the sandbox. default_enable=yes +# Release provenance: where Anthropic publishes the per-release SHA-256 of every platform binary, +# the key that signs it, and that key's fingerprint. entrypoint-verify.lib.sh reads these to prove +# the installed entrypoint is the binary the vendor published; the three fields, and why the key is +# shipped here rather than fetched, are in providers.rule.md. +# The {version} slot takes the installed package version. +release_manifest_url=https://downloads.claude.ai/claude-code-releases/{version}/manifest.json +# Published at https://downloads.claude.ai/keys/claude-code.asc. +release_key=/usr/local/lib/ai-tools/keys/claude-code.asc +# A list: a key rotation declares both fingerprints for the overlap. +release_fingerprint=31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE diff --git a/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh b/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh new file mode 100644 index 00000000..9a2ade23 --- /dev/null +++ b/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# /usr/local/lib/ai-tools/entrypoint-verify.lib.sh +# Verify that an agent's entrypoint is the binary its vendor published, and carry that verdict to +# the launch in a record the sandbox account cannot write. +# +# It names no agent: the release manifest, the signing key, and its fingerprint are optional fields +# on the agent's own manifest (providers.rule.md). Why the check exists, which caller runs which +# half, what each outcome means, and where the pin lives are in updater.rule.md; this header covers +# only what a reader of this file needs. +# +# Two entry points, split by principal -- which is what keeps the network off the launch path: +# ai_tools_entrypoint_release_verify ROOT. Fetch the vendor's signed release manifest, verify it +# against the pinned key, and compare the entrypoint's hash to the checksum it publishes. +# ai_tools_entrypoint_check SANDBOX. Hash the entrypoint, compare to the pin. No +# network, no key, no JSON -- it runs on every launch. +# Between them, ai_tools_entrypoint_pin_write records a verified checksum (root only). +# +# Status contract, shared with npm-verify.lib.sh so the two gates in nvm-update read alike: +# 0 verified (or matches its pin) +# 1 MISMATCH -- the caller MUST fail closed +# 2 unable to verify -- NOT a tamper signal; the caller warns, or refuses only where the +# operator required verification (ai_tools_entrypoint_verify_required) +# The pure decisions take no I/O and are unit-tested over their truth tables +# (tests/unit/entrypoint-verify.sh). + +# Include guard: an if-statement, not `[[ ]] && return`, which returns 1 for an unset guard and +# trips the sourcing shell's set -e. +if [[ -n "${_AI_TOOLS_ENTRYPOINT_VERIFY_LIB_LOADED:-}" ]]; then + return 0 +fi +_AI_TOOLS_ENTRYPOINT_VERIFY_LIB_LOADED=1 + +# The shared KEY=value grammar, for the strictness switch and the fingerprint list. Best-effort, +# NOT required: the launch-side check (the hot path) needs neither, and every consumer that does +# has already loaded conf.lib.sh through providers.lib.sh -- so a failure here degrades the two +# functions that use it in their permissive/refusing directions rather than defining nothing. Both +# guard on `declare -F` before calling into it. +# shellcheck source=SCRIPTDIR/conf.lib.sh +source "${BASH_SOURCE[0]%/*}/conf.lib.sh" 2>/dev/null || true + +# Deployed paths, overridable as root-only test hooks (the same posture as providers.lib.sh's +# manifest directories: every consumer runs under sudo, which scrubs the environment, and no +# sudoers rule keeps these names). +: "${AI_TOOLS_ENTRYPOINT_PIN_DIR:=/var/opt/ai-tools/state/entrypoint-pin.d}" + +# _ai_tools_ev_warn : report to stderr and, when log.lib.sh is loaded by the caller, +# to journald. Never alters a verdict. +_ai_tools_ev_warn() { + printf 'entrypoint-verify: %s\n' "$*" >&2 + declare -F ai_tools_log_warn >/dev/null 2>&1 && ai_tools_log_warn "entrypoint-verify: $*" + return 0 +} + +# ── Pure decisions (no I/O, no privilege, no network) ──────────────────────────────────────── + +# ai_tools_entrypoint_platform_key [libc] : print the key a vendor release manifest +# lists this host's binary under, or nothing for an architecture with no mapping. is +# uname -m; is `musl` or empty. Pure, so the mapping is unit-tested without needing the +# architectures it maps. +ai_tools_entrypoint_platform_key() { + local machine="${1:-}" libc="${2:-}" arch="" suffix="" + case "${machine}" in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) return 1 ;; + esac + [[ "${libc}" == musl ]] && suffix="-musl" + printf 'linux-%s%s' "${arch}" "${suffix}" +} + +# ai_tools_release_url_valid : succeed when may be fetched as a release manifest. +# HTTPS only, and a character set that cannot carry a shell metacharacter, whitespace, or a +# traversal into a URL that reaches curl. Allowlist, not blocklist. +ai_tools_release_url_valid() { + # Held in a variable: a bracket expression carrying `&` and braces cannot be written inline in + # `[[ =~ ]]` -- bash parses those as operators before the regex is ever assembled. `-` closes + # the set, the POSIX way to include it literally. + local allowed='^[A-Za-z0-9:/._~%?=&{}-]+$' + local url="${1:-}" + [[ "${url}" == https://* ]] || return 1 + [[ "${url}" != *..* ]] || return 1 + [[ "${url}" =~ ${allowed} ]] +} + +# ai_tools_release_manifest_url