diff --git a/.claude/rules/claude-settings.rule.md b/.claude/rules/claude-settings.rule.md index 1f061469..88c43032 100644 --- a/.claude/rules/claude-settings.rule.md +++ b/.claude/rules/claude-settings.rule.md @@ -330,7 +330,7 @@ No rpm directive resolves the split on its own, because rpm has no vocabulary fo subtree of a file: plain `%config` would install the shipped file and move the host's aside to `.rpmsave`, reverting the permission rules the file was kept for, while `%config(noreplace)` alone leaves a newly shipped hook declared nowhere. The merge therefore runs on request — -**`sudo ai-tools-admin postupgrade`**, through the same `conf.lib.sh` entry point — and the agent +**`sudo ai-tools-admin system post-upgrade`**, through the same `conf.lib.sh` entry point — and the agent package's `%post` prints that pointer whenever a `.rpmnew` is present. No scriptlet edits a config file. diff --git a/.claude/rules/cli-grammar.rule.md b/.claude/rules/cli-grammar.rule.md index 55de29e3..5a9a37ae 100644 --- a/.claude/rules/cli-grammar.rule.md +++ b/.claude/rules/cli-grammar.rule.md @@ -4,12 +4,13 @@ paths: - "src/usr/local/libexec/ai-tools/ai-tools-admin.sh" - "src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh" - "src/usr/local/share/man/man1/ai-tools.1" + - "src/usr/local/share/man/man8/ai-tools-admin.8" --- # Command grammar The shape every command in this project takes: `ai-tools`, `ai-tools-admin`, `ai-tools-dotnet`, -and `ai-tools(1)`. What each command *does* is in the rule for its component — +`ai-tools(1)` and `ai-tools-admin(8)`. What each command *does* is in the rule for its component — [cli](cli.rule.md) for the project lifecycle, [confinement](confinement.rule.md) for the SELinux group verbs, [dotnet](dotnet.rule.md) for the .NET integration. This rule covers only how a command is spelled and how it projects onto an HTTP surface. @@ -100,7 +101,10 @@ carries no `/admin` prefix (below). There are two typed commands: | Binary | Caller | Holds | |---|---|---| | `ai-tools` | the invoking operator, unprivileged; refuses the sandbox account | project lifecycle and the reports | -| `ai-tools-admin` | root, enforced by `EUID -eq 0` | host administration | +| `ai-tools-admin` | root, enforced by `EUID -eq 0` before any command dispatches | host administration | + +`--help` and `--version` are answered ahead of that root check, since they read no host state and +change nothing, so the first thing an operator meets is the command surface rather than a refusal. A command requiring root belongs on `ai-tools-admin` rather than in a binary of its own, so an administrator learns one name and one grammar. Each additional top-level name costs a @@ -232,15 +236,17 @@ on the HTTP side. Neither needs a path segment. ## Where the surface stands -Four commands carry a `%{_sbindir}` symlink so `sudo ` resolves through `secure_path`: -`ai-tools`, `ai-tools-admin`, `ai-tools-bootstrap` and `ai-tools-dotnet`. Three of the four are -root-only, so the last two fold into `ai-tools-admin` as verbs: +`ai-tools-admin` conforms: `operators [list|add|remove]`, `selinux groups [list|enable|disable]`, +`system post-upgrade`, `--help`/`-h`, `--version`. `ai-tools-admin(8)` documents that surface and +`tests/unit/man.sh` holds the page, the helper's `usage()` and its dispatch arms in agreement, so a +command renamed in one of the three fails the suite rather than going stale in the others. + +Two commands still diverge. Four names carry a `%{_sbindir}` symlink so `sudo ` resolves +through `secure_path` — `ai-tools`, `ai-tools-admin`, `ai-tools-bootstrap` and `ai-tools-dotnet` — +and three of the four are root-only, so the last two fold into `ai-tools-admin` as verbs: | Its spelling | Under this grammar | |---|---| -| `ai-tools-admin operator add\|remove\|list` | `operators add\|remove\|list` | -| `ai-tools-admin selinux list-groups\|enable-group\|disable-group` | `selinux groups list\|enable\|disable` | -| `ai-tools-admin postupgrade` | `system post-upgrade` | | `sudo ai-tools-bootstrap` | `system bootstrap` | | `sudo ai-tools-dotnet setup` | `dotnet bootstrap` | | `sudo ai-tools-dotnet install-tools ` | `dotnet tools install ` | @@ -253,7 +259,7 @@ symlinks with the move, in both `install.sh` and the RPM. `ai-tools-admin` ships the toolchain it installs exists. `system bootstrap` belongs to base and `dotnet bootstrap` to the integration package that owns the domain. -`ai-tools-admin` dispatches a fixed `case` over its own subcommands, so the discovery seam that +`ai-tools-admin` dispatches a fixed `case` over its own commands, so the discovery seam that lets a provider package contribute `dotnet` is the work that carries that domain in, and the `--scope full` opt-in on `system bootstrap` depends on it: base can only run each enabled integration's `bootstrap` once there is a seam to find one through. diff --git a/.claude/rules/cli.rule.md b/.claude/rules/cli.rule.md index dabe36ad..5343c071 100644 --- a/.claude/rules/cli.rule.md +++ b/.claude/rules/cli.rule.md @@ -93,7 +93,7 @@ caller's identity from that list (`operator.lib.sh`, inside the root helpers); w unenrolled user proceeds through the registry writes and confirm prompts only to be refused by the first helper that resolves owner (`ai-tools-lockdown`: "not in allowed projects for current operator"), after partial state was written and rolled back. The gate replaces that with one -up-front message pointing at `sudo ai-tools-admin operator add `. `operator.conf` is `644`, +up-front message pointing at `sudo ai-tools-admin operators add `. `operator.conf` is `644`, so the unprivileged CLI reads `OPERATORS` directly, and enrollment there takes effect on the next command — no re-login, unlike the `ai-ops` group the admin verb also grants (which the launch wrapper needs and which does require a fresh login). The **informational** commands @@ -305,11 +305,11 @@ file sink being the authoritative one. whole section is **omitted** when that list is not readable unprivileged (common — the policy store is root-only on many hosts): every line it prints needs the module list, so a section that could only say "cannot read" is not shown at all (inspect groups with - `sudo ai-tools-admin selinux list-groups`). When the `dotnet` integration is enabled under **Enforcing** it + `sudo ai-tools-admin selinux groups`). When the `dotnet` integration is enabled under **Enforcing** it warns of the two disjoint policy groups a full .NET workflow wants but that are not loaded: `tmpmap` (restore/build mmap of `/tmp`, `EACCES` without it) and `apphost` (executable/host projects — `dotnet run`, ASP.NET Core, `xunit.v3` — whose memfd exec is denied without it), each - with its own enable command: `ai-tools-admin selinux enable-group tmpmap` for the stable one, the + with its own enable command: `ai-tools-admin selinux groups enable 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 diff --git a/.claude/rules/confinement.rule.md b/.claude/rules/confinement.rule.md index 70d2d00d..34c5d02a 100644 --- a/.claude/rules/confinement.rule.md +++ b/.claude/rules/confinement.rule.md @@ -208,12 +208,12 @@ and stability from one place — `selinux-groups.lib.sh`, so they cannot disagre - **Stable** groups (a single, tested rule, e.g. `tmpmap`) ship **prebuilt** (`ai_tools_.pp`) alongside the core in `/usr/share/selinux/packages/ai-tools/`, and - `sudo ai-tools-admin selinux enable-group ` `semodule`-loads the prebuilt `.pp` on an - installed host, needing no source tree or `selinux-policy-devel`. `list-groups`/`disable-group` - round it out (`disable-group` works for any loaded group). The spelling these subcommands take - is set by [cli-grammar](cli-grammar.rule.md). + `sudo ai-tools-admin selinux groups enable ` `semodule`-loads the prebuilt `.pp` on an + installed host, needing no source tree or `selinux-policy-devel`. A bare `selinux groups` lists + them and `selinux groups disable ` rounds it out, working for any loaded group. The + spelling these commands take is set by [cli-grammar](cli-grammar.rule.md). - **Experimental** groups are unaudited drafts and are **not shipped prebuilt**; - `ai-tools-admin enable-group` refuses one and points at the source workflow rather than + `ai-tools-admin selinux groups enable` refuses one and points at the source workflow rather than loading an unaudited module. They are compiled and verified from a source checkout — `sudo selinux/install-selinux.sh enable-group ` (which compiles from `.te`/`.fc`, then loads) plus the `avc/` bring-up loop. Promoting one to stable means marking it `stable` in the diff --git a/.claude/rules/launch.rule.md b/.claude/rules/launch.rule.md index f0f843bc..0f1afc18 100644 --- a/.claude/rules/launch.rule.md +++ b/.claude/rules/launch.rule.md @@ -20,14 +20,14 @@ agent-specific inputs live in that agent's own rule — 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 +dotfiles by `ai-tools-admin operators 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-tools-admin operators 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 @@ -293,7 +293,7 @@ Every agent wrapper lives in `/usr/local/bin`, which `path-dedup.sh` 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 +sourced per-account: `ai-tools-admin operators 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 non-login interactive shells read `~/.bashrc` only). Those two files govern **bash**, so an diff --git a/.claude/rules/providers.rule.md b/.claude/rules/providers.rule.md index d25419e7..0c2cdc4e 100644 --- a/.claude/rules/providers.rule.md +++ b/.claude/rules/providers.rule.md @@ -220,8 +220,8 @@ the directive for the same reason, which is why a newly shipped hook is installe uninvoked until its declaration is merged ([claude-settings](claude-settings.rule.md)). The cost is that reconciling the `.rpmnew` is manual, so it is signposted rather than automated: -each package's `%post` prints the pointer whenever one is present, and `sudo ai-tools-admin -postupgrade` names the options the new version documents that the file does not mention, shows the +each package's `%post` prints the pointer whenever one is present, and `sudo ai-tools-admin system +post-upgrade` names the options the new version documents that the file does not mention, shows the difference, and offers to clear the copy. Against this file it writes nothing. An additive merge could append an option block the file lacks, but it could never correct the prose of one already there, so `operator.conf(5)` is the single current statement of what an option means and the file diff --git a/.claude/rules/tests.rule.md b/.claude/rules/tests.rule.md index 48add4ef..20f6cfe4 100644 --- a/.claude/rules/tests.rule.md +++ b/.claude/rules/tests.rule.md @@ -96,7 +96,7 @@ override — the same limitation as `AI_TOOLS_ALLOWLIST`. The journald sink is u every line is still queryable by its per-component tag. `AI_TOOLS_POSTUPGRADE_ROOT` is the fourth hook of that family and the widest in reach: -`ai-tools-admin postupgrade` reconciles a fixed registry of absolute control-plane paths, and +`ai-tools-admin system post-upgrade` reconciles a fixed registry of absolute control-plane paths, and this prefixes every one of them, so `unit/postupgrade.sh` drives the real command against a fixture tree in its testdir. It carries the same standing as the three above — the helper is reachable only as root, `sudo` strips the name, and a caller who could set it may already edit @@ -206,14 +206,26 @@ check asserts required **content** rather than consistency: `--help` and `--vers leaves the gate's refusal as the only route to the provisioning command — a regression visible only on the host nobody develops against. -`man.sh` is a pure text-sync check between the CLI's `usage()` heredoc and the `ai-tools(1)` -man page, validated from the repo sources (or the installed pair outside a checkout) without -executing the CLI. The two are not copies — the help is orientation, the page is the reference -(see [cli](cli.rule.md)) — so it asserts three relations rather than set equality: the **verb** -sets match in both directions, every option the help names is documented, and every option the -page documents is one a CLI **parser** accepts. The last is the direction with teeth: what goes -stale is an option outliving its parser, whereas requiring the help to name every documented -option is what previously made slimming the help impossible. +`man.sh` is a pure text-sync check over both of this project's man pages and the `usage()` +heredoc of the command each documents — `ai-tools(1)` against the CLI, `ai-tools-admin(8)` +against the admin helper — validated from the repo sources (or the installed pair outside a +checkout) and executing neither command, since the CLI's bootstrap gate fail-closes on an +unprovisioned host and the helper refuses a non-root caller. In each pair the help is +orientation and the page is the reference (see [cli](cli.rule.md)), so it asserts relations +rather than set equality. For `ai-tools(1)`: the **verb** sets match in both directions, every +option the help names is documented, and every option the page documents is one a CLI **parser** +accepts. The last is the direction with teeth: what goes stale is an option outliving its parser, +whereas requiring the help to name every documented option is what previously made slimming the +help impossible. + +`ai-tools-admin(8)` gets the same relations over a surface spelled in bare words rather than long +options ([cli-grammar](cli-grammar.rule.md)), so what is compared is the whole **command path** — +`selinux groups enable`, three tokens. The parser direction becomes a dispatch check: the helper +splits its dispatch across nested `case` statements, one per domain and collection, so a whole +path never appears in a single arm and each token of a documented path must be an arm somewhere in +the helper. That is what catches a page still naming a command after the dispatch renamed it, +without pinning where in the nesting the arm sits. Both pages are then checked for a non-empty +`.TH` version field. `sandbox.sh` closes with `tree_is_pristine`, which is not a sandbox helper but belongs to the same class: a pure decision with a security consequence. `--project-create` skips the secret scan, the @@ -281,7 +293,7 @@ flag does **not** decide: a `SUDO_USER=root` invocation naming a usable operator while the same invocation naming nobody is refused, so the flag chooses who is enrolled and never how the script was invoked. -`postupgrade.sh` is that same reconciliation seen from the RPM side: `ai-tools-admin postupgrade` +`postupgrade.sh` is that same reconciliation seen from the RPM side: `ai-tools-admin system post-upgrade` end to end, from dispatch through the registry to each treatment (see [providers](providers.rule.md) and [claude-settings](claude-settings.rule.md)). It asserts which treatment each file got — the settings JSON merged with its permission rules intact and a dated @@ -295,7 +307,7 @@ directory, `boundary/providers.sh` and `boundary/filters.sh` cover `operator.con `boundary/sudo.sh` covers the grant, so no input this command reads is agent-writable. `admin-operator-add.sh` pins the other reported decision that command makes: the line -`operator add` closes with, naming which of the two operator shapes the enrolment produced. The +`operators add` closes with, naming which of the two operator shapes the enrolment produced. The verdict is read out of `sudo -l -U`, so what the file drives is the direction that misleads — a sudo which fails for its own reasons must read as *undetermined* rather than as a verdict about the account, since an administrator acts on that line at the moment of the decision and a false @@ -393,8 +405,8 @@ 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 `stability` field, guarding the regression where a fourth pipe field bleeds into the reason), the -validity predicate the `enable-group` gate depends on (an unknown name is rejected), and the -`is_experimental` predicate agreeing with the field (it decides whether `enable-group` loads a +validity predicate the `selinux groups enable` gate depends on (an unknown name is rejected), and the +`is_experimental` predicate agreeing with the field (it decides whether `selinux groups enable` loads a shipped module or refuses and points to the source workflow). And — because only **stable** groups ship prebuilt — registry↔filesystem lockstep: every registered group has a `.te` source; a **stable** group additionally has a **committed** `.pp` while an **experimental** group must have diff --git a/.gitignore b/.gitignore index 9d5bfa09..9b5d9676 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ .idea/ .env/ -# Scratch space for work-in-progress notes, issue drafts, and design docs not yet promoted -# into the tree (docs/, .claude/rules/) or a tracker. +# Work-in-progress notes, issue drafts and design docs live in the sibling repository +# `tools-agent-tools-restricted-wip`. This directory holds only an untracked pointer to it +# (wip/CLAUDE.md); nothing in this tree references anything under it. wip/ # Compiled policy packages are build artifacts. Only the STABLE ones ship prebuilt so a -# normal install and `ai-tools-admin selinux enable-group` need no selinux-policy-devel +# normal install and `ai-tools-admin selinux groups enable` need no selinux-policy-devel # toolchain -- the core (ai_tools.pp) and each stable optional group (currently tmpmap). # EXPERIMENTAL groups are deliberately NOT committed: they are compiled and verified from # source on demand (selinux/install-selinux.sh enable-group + the avc bring-up loop), so a diff --git a/CLAUDE.md b/CLAUDE.md index 366ea0ef..c1702282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ the management CLI (`ai-tools`), and root-helper binary names (`ai-tools-chown`, | 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, 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) | -| How every command is spelled: bare-word commands, plural collections, verb after noun, and the REST projection each maps onto | `bin/ai-tools.sh`, `ai-tools-admin.sh`, `ai-tools-dotnet.sh`, `ai-tools.1` | [cli-grammar](.claude/rules/cli-grammar.rule.md) | +| How every command is spelled: bare-word commands, plural collections, verb after noun, and the REST projection each maps onto | `bin/ai-tools.sh`, `ai-tools-admin.sh`, `ai-tools-dotnet.sh`, `ai-tools.1`, `ai-tools-admin.8` | [cli-grammar](.claude/rules/cli-grammar.rule.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) | @@ -123,7 +123,7 @@ sandbox account can never hold the operator grant. ### An operator is two facts; provisioning needs a third this project does not grant -`ai-tools-admin operator add` writes both facts that make an operator: membership of `ai-ops` +`ai-tools-admin operators add` writes both facts that make an operator: membership of `ai-ops` (the rules above, and the launch wrapper's own gate) and a name in `OPERATORS` (`/etc/ai-tools/operator.conf`, from which `operator.lib.sh` resolves each path's owner). An account holding only those two runs agent sessions on the projects claimed for it, and is a diff --git a/README.md b/README.md index 6b97cf85..929261ee 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ sudo ai-tools-bootstrap # 2. Enrol yourself as an operator: records you in /etc/ai-tools/operator.conf and grants # ai-ops membership (the sudo rules and ownership hand-back). -sudo ai-tools-admin operator add "$(id -un)" +sudo ai-tools-admin operators add "$(id -un)" # every host command: man ai-tools-admin # 3. Make a project and launch in it. --project-create makes the directory, initializes a # git repository, and claims it -- one command, no prompts, nothing pre-existing to @@ -297,7 +297,7 @@ The privilege model and every guard above are specified in cd tools-agent-tools-restricted # steps 1-3: PATH fragment, the ai-tools account, nvm + Node + claude sudo ./install.sh install # step 4: helpers, units, sudoers, CLI - sudo ai-tools-admin operator add # enrol yourself as an operator + sudo ai-tools-admin operators add # enrol yourself as an operator `install.sh` stops unless the sandbox account and `/opt/ai-tools/bin` already exist — steps 1–3 create them (once the package is deployed, `sudo ai-tools-bootstrap` does both diff --git a/docs/install-from-source.md b/docs/install-from-source.md index 7b7d4984..63af3c46 100644 --- a/docs/install-from-source.md +++ b/docs/install-from-source.md @@ -32,7 +32,7 @@ claude restricted — always resolves ahead of the nvm-managed `claude`. It is sourced per-account: only the operator shells wired for it get the ordering, and every other account on the host keeps its stock PATH. -`sudo ai-tools-admin operator add ` offers to wire the source line into your +`sudo ai-tools-admin operators add ` offers to wire the source line into your `~/.bashrc` and `~/.bash_profile`. To wire it by hand, add it to **both** files (non-login interactive shells read only `~/.bashrc`, login shells `~/.bash_profile`), after your nvm init: @@ -171,7 +171,7 @@ install directory is never auto-registered as a project. Enrol each further login user as an operator (ai-ops membership, allowlist seed): - sudo ai-tools-admin operator add # defaults to $SUDO_USER + sudo ai-tools-admin operators add # defaults to $SUDO_USER It reports which shape the enrolment produced — whether the account can claim projects, or only launch sessions and have them claimed for it — by asking sudo about that account. diff --git a/docs/multi-operator.md b/docs/multi-operator.md index df2bcd44..004f74bf 100644 --- a/docs/multi-operator.md +++ b/docs/multi-operator.md @@ -31,7 +31,7 @@ operators list). the launcher. Everything deeper stays `o=0`. - The launch wrapper ships system-wide as **`/usr/local/bin/claude`** (`root:root 0755`), rpm-owned. `path-dedup.sh`, wired into each operator's dotfiles by - `ai-tools-admin operator add`, ranks `/usr/local/bin` (Tier 1) above the nvm shim, + `ai-tools-admin operators add`, ranks `/usr/local/bin` (Tier 1) above the nvm shim, so it shadows any nvm-managed `claude`. - The wrapper checks `ai-ops` membership first and frames a `msg.lib` refusal for a non-operator, instead of leaking a raw `sudo` denial. @@ -71,7 +71,7 @@ operators list). per-operator owner). - Sudoers grants are **group** rules: `%ai-ops ALL=(ai-tools:ai-tools) NOPASSWD: /opt/ai-tools/bin/ai-tools-run`. The per-operator-line form is gone. -- **Operator management is a symmetric root helper, `ai-tools-admin operator +- **Operator management is a symmetric root helper, `ai-tools-admin operators add|remove|list`** (run via `sudo`), replacing the one-shot `ai-tools-enroll`. It is a root helper, not an `ai-tools` CLI verb, because the CLI is unprivileged and refuses root while this edits host config (sudoers group, `ai-ops`, `OPERATORS`); the @@ -150,7 +150,7 @@ boundary-mode constants the installer/spec assert. verb leaves stale entries on unclaim and re-introduces agent-triggered control-plane writes.) - **(B) operator.conf format** → `OPERATORS="alice bob svc-ci"`, one list for human and service accounts alike (they share `ai-tools`); home/group derived via `getent`. -- **(C) operator lifecycle** → `ai-tools-admin operator add|remove|list`; `add` with no +- **(C) operator lifecycle** → `ai-tools-admin operators add|remove|list`; `add` with no arg offers `$SUDO_USER`, with an arg enrols that user or service account. - **(D) per-operator isolation** → private `state//` for agent state, private `/tmp` per session via `PrivateTmp`. diff --git a/docs/naming-conventions.md b/docs/naming-conventions.md index 722e63bf..2aedb5df 100644 --- a/docs/naming-conventions.md +++ b/docs/naming-conventions.md @@ -40,7 +40,7 @@ operators, and prose distinguishes them by naming the grant rather than by inven account that runs an agent is this shape, and "service account" describes its intent — the host records nothing that distinguishes it from any other grant-less operator. -`operator.conf` is managed in place at runtime by `ai-tools-admin operator add|remove`. Its +`operator.conf` is managed in place at runtime by `ai-tools-admin operators add|remove`. Its source template carries one substitution token, `OPERATORS="@PROJECTS_USER@"`, and the two install paths treat it differently: the RPM rewrites the line to `OPERATORS=""` at build (`packaging/ai-tools.spec`), so a packaged host ships with nobody enrolled, while `install.sh` diff --git a/docs/rpm-packaging.md b/docs/rpm-packaging.md index 10b80324..b10b3a64 100644 --- a/docs/rpm-packaging.md +++ b/docs/rpm-packaging.md @@ -96,7 +96,7 @@ performs the rename inside the same transaction and nothing has to be removed by Removing the packages moves an edited `/etc/ai-tools/operator.conf` to `operator.conf.rpmsave` and a fresh install writes an empty one, dropping the operator list (re-add with -`ai-tools-admin operator add`); an in-place upgrade keeps it via `%config(noreplace)`. What else +`ai-tools-admin operators add`); an in-place upgrade keeps it via `%config(noreplace)`. What else survives an erase is in [Preservation on erase](#preservation-on-erase). `dnf reinstall` requires the *same* version already installed and is not the way to move between versions. @@ -153,7 +153,7 @@ modified after an operator is added. ## Operator administration -`ai-tools-admin operator add|remove|list` (`/usr/local/libexec/ai-tools/ai-tools-admin`, +`ai-tools-admin operators add|remove|list` (`/usr/local/libexec/ai-tools/ai-tools-admin`, root, run via `sudo`) manages the operators -- the login users (a human or a rootless service account) that drive the sandbox through the shared `ai-tools` account. It is a root helper rather than an `ai-tools` CLI verb, because it edits host config (the @@ -182,7 +182,7 @@ ship with the package, so adding an operator is a membership change, not a sudoe The `%post` of `ai-tools-base` does **not** bind an operator: it is per-operator, which a non-interactive scriptlet cannot do. `%post` installs cleanly and unenrolled and prints the -ordered `sudo ai-tools-bootstrap` then `sudo ai-tools-admin operator add ` directives. +ordered `sudo ai-tools-bootstrap` then `sudo ai-tools-admin operators add ` directives. ## Bootstrap @@ -228,10 +228,10 @@ nvm-update timer maintains the tree from then on. file owned by them is unpacked. `Requires(pre): shadow-utils`. The `ai-ops` group ships empty; operators are added to it per host. - `%post` runs `%systemd_post ai-tools-handback.socket`, applies the shared-area ACLs, and - prints the ordered `ai-tools-bootstrap` then `ai-tools-admin operator add` + prints the ordered `ai-tools-bootstrap` then `ai-tools-admin operators add` directives. Loading the SELinux policy is NOT base's job — that scriptlet lives with the payload in `ai-tools-selinux`. It does not bind an operator or provision the toolchain and its update - timer — those belong to `ai-tools-admin operator add` and `ai-tools-bootstrap`. + timer — those belong to `ai-tools-admin operators add` and `ai-tools-bootstrap`. - `%preun` runs `%systemd_preun ai-tools-handback.socket`. - `%postun` runs `%systemd_postun_with_restart ai-tools-handback.socket`. @@ -264,8 +264,8 @@ stable groups are shipped but stay **off**, toggled per host by an operator who boundary: ```bash -sudo ai-tools-admin selinux list-groups -sudo ai-tools-admin selinux enable-group tmpmap +sudo ai-tools-admin selinux groups +sudo ai-tools-admin selinux groups enable tmpmap ``` That helper `semodule`-loads the prebuilt `.pp` from the package directory. The @@ -290,17 +290,17 @@ rather than packaged files: - `/opt/ai-tools/.nvm` (nvm and Node) and `/var/opt/ai-tools` (sandbox clones), which are unpackaged runtime data; - each operator's `~/.config/ai-tools/{allowed-projects,secret-patterns}`, which - the package never owns — `ai-tools-admin operator add` seeds the allowlist and they + the package never owns — `ai-tools-admin operators add` seeds the allowlist and they survive erase untouched. `operator.conf` ships as `%config(noreplace)` with an empty `OPERATORS=""` baseline and is -edited in place by `ai-tools-admin operator add|remove`. `%config(noreplace)` keeps the +edited in place by `ai-tools-admin operators add|remove`. `%config(noreplace)` keeps the edited copy across an **upgrade** (`dnf upgrade`, or `dnf install ./*.rpm` of a higher version), so the host's operators persist. An **erase** is different: rpm saves the modified config as `operator.conf.rpmsave` and removes the tracked file, so a remove-then-install cycle drops the operator list — the fresh install lays down the empty baseline. Upgrade in place rather than `dnf remove` + install; if a `.rpmsave` was left behind, re-add operators -with `ai-tools-admin operator add` (see the README "Upgrading" note). +with `ai-tools-admin operators add` (see the README "Upgrading" note). ## Tests diff --git a/install.sh b/install.sh index dfc28a68..0587c215 100755 --- a/install.sh +++ b/install.sh @@ -75,7 +75,7 @@ readonly SANDBOX_GROUP="ai-tools" # routes -- the invoking SUDO_USER, --operator, and the prompt -- which must refuse alike or the # route decides the outcome. # -# Root is the one that matters: `ai-tools-admin operator add` refuses it outright and this script +# Root is the one that matters: `ai-tools-admin operators add` refuses it outright and this script # reaches the same end state by a different route (the @PROJECTS_USER@ substitution plus # `usermod -aG ai-ops`), producing a host nobody can provision -- the CLI refuses root every # mutating verb, --for refuses root as a target, and operator.lib.sh resolves path owners from @@ -592,7 +592,7 @@ offer_selinux() { # ai-tools-admin is the shipped entry point and is deployed by now, so name it rather # than the checkout path an installed host may not keep. if command -v ai-tools-admin >/dev/null 2>&1; then - say " ${C_DIM}manage them with: sudo ai-tools-admin selinux list-groups${C_RST}" + say " ${C_DIM}manage them with: sudo ai-tools-admin selinux groups${C_RST}" else say " ${C_DIM}manage them with: sudo ${selinux_script} {install|remove|list-groups}${C_RST}" fi @@ -700,6 +700,7 @@ do_summary() { _chk /usr/local/bin/ai-tools _chk /usr/local/share/man/man1/ai-tools.1 _chk /usr/local/share/man/man5/operator.conf.5 + _chk /usr/local/share/man/man8/ai-tools-admin.8 _chk /var/opt/ai-tools _chk /var/opt/ai-tools/sandbox-projects _chk /var/opt/ai-tools/README.md @@ -1080,7 +1081,7 @@ do_install() { /usr/local/lib/ai-tools/filters.d/dotnet.rules # SELinux policy packages (prebuilt): stage the core plus each STABLE optional group under the - # canonical package dir, so the installed ai-tools-admin can `selinux enable-group` a prebuilt + # canonical package dir, so the installed ai-tools-admin can `selinux groups enable` a prebuilt # module without a source checkout (parity with the RPM). Only stable groups ship prebuilt; # experimental groups are compiled and verified from source on demand, so they are not staged. # Keep this list in step with the stable set in selinux-groups.lib.sh. install-selinux.sh @@ -1195,7 +1196,7 @@ do_install() { "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh" \ /usr/local/libexec/ai-tools/ai-tools-bootstrap - # Host administration: ai-tools-admin operator add|remove|list manages the OPERATORS list and + # Host administration: ai-tools-admin operators add|remove|list manages the OPERATORS list and # ai-ops membership. This dev install binds the invoking user as the sole operator inline below. log "/usr/local/libexec/ai-tools/ai-tools-admin" install_subst 750 root root \ @@ -1311,6 +1312,14 @@ do_install() { "${SCRIPT_DIR}/src/usr/local/share/man/man1/ai-tools.1" \ /usr/local/share/man/man1/ai-tools.1 + # ai-tools-admin(8). Section 8 because every command it documents refuses a non-root + # caller. Version-substituted and command-synced with the helper's usage() the same way. + log "/usr/local/share/man/man8/ai-tools-admin.8" + install -d -o root -g root -m 755 /usr/local/share/man/man8 + install_subst 644 root root \ + "${SCRIPT_DIR}/src/usr/local/share/man/man8/ai-tools-admin.8" \ + /usr/local/share/man/man8/ai-tools-admin.8 + # operator.conf(5). Documents the shared KEY=value grammar and every host option, so an # operator reading the config has a manual rather than only its inline comments. log "/usr/local/share/man/man5/operator.conf.5" @@ -1842,6 +1851,7 @@ do_install() { say " configure and read up:" say " ${C_BOLD}/etc/ai-tools/operator.conf${C_RST} ${C_DIM}# host options, each documented inline${C_RST}" say " ${C_BOLD}man ai-tools${C_RST} ${C_DIM}# the CLI${C_RST}" + say " ${C_BOLD}man ai-tools-admin${C_RST} ${C_DIM}# the root-only host commands${C_RST}" say " ${C_BOLD}man 5 operator.conf${C_RST} ${C_DIM}# every host option${C_RST}" say "" suggest_lint_tools @@ -1919,6 +1929,7 @@ do_uninstall() { rm -f /usr/local/bin/ai-tools rm -f /usr/local/share/man/man1/ai-tools.1 rm -f /usr/local/share/man/man5/operator.conf.5 + rm -f /usr/local/share/man/man8/ai-tools-admin.8 rm -f /usr/local/bin/claude # Units, after the stop/disable above. Globs cover the handback socket+service and # the relabel path+service in one sweep, plus the updater service+timer. diff --git a/packaging/README.md b/packaging/README.md index d94e10a6..5d268e8f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -8,7 +8,7 @@ Build the ai-tools RPMs on Rocky Linux and run the whole admin → operator → make -C packaging rpmtest-rocky9 ``` -That one target builds every RPM the spec defines from the working tree, installs the `ai-tools` metapackage from a local repo (so `dnf` pulls `ai-tools-base` through its `Requires` and the `ai-tools-agents` / `ai-tools-integration` umbrellas and their members through weak `Recommends`, with `install_weak_deps` forced on), boots `systemd` as PID 1, and runs `container-selftest.sh`. The build and the selftest both assert that every package the build produced resolved from the metapackage alone, derived from the built set rather than a list kept by hand, so a new subpackage is covered without editing either. The selftest walks the documented Quick-start end to end — `ai-tools-bootstrap`, `ai-tools-admin operator add`, `ai-tools --project-claim`, `tests/run.sh all`, and an auth-free confined `claude --version` session — then calls `systemctl exit` with the aggregate status, so the command exits non-zero if any phase fails. Use `rpmtest-rocky10` for Rocky 10. +That one target builds every RPM the spec defines from the working tree, installs the `ai-tools` metapackage from a local repo (so `dnf` pulls `ai-tools-base` through its `Requires` and the `ai-tools-agents` / `ai-tools-integration` umbrellas and their members through weak `Recommends`, with `install_weak_deps` forced on), boots `systemd` as PID 1, and runs `container-selftest.sh`. The build and the selftest both assert that every package the build produced resolved from the metapackage alone, derived from the built set rather than a list kept by hand, so a new subpackage is covered without editing either. The selftest walks the documented Quick-start end to end — `ai-tools-bootstrap`, `ai-tools-admin operators add`, `ai-tools --project-claim`, `tests/run.sh all`, and an auth-free confined `claude --version` session — then calls `systemctl exit` with the aggregate status, so the command exits non-zero if any phase fails. Use `rpmtest-rocky10` for Rocky 10. ## Reading the result @@ -18,7 +18,7 @@ That one target builds every RPM the spec defines from the working tree, install ══════════════════════════════════════════════════════════════ PASS Metapackage pulled every built subpackage PASS ai-tools-bootstrap (nvm + Node + claude; linger + timer) -PASS ai-tools-admin operator add tester +PASS ai-tools-admin operators add tester PASS operator claims the project (allowlist + ACL + safedir + label) PASS tests/run.sh all PASS confined session launches (claude --version through the wrapper) diff --git a/packaging/VERSION b/packaging/VERSION index a803cc22..a5510516 100644 --- a/packaging/VERSION +++ b/packaging/VERSION @@ -1 +1 @@ -0.14.0 +0.15.0 diff --git a/packaging/ai-tools.spec b/packaging/ai-tools.spec index 69113a43..f409bfdf 100644 --- a/packaging/ai-tools.spec +++ b/packaging/ai-tools.spec @@ -259,6 +259,9 @@ install -m 0750 src%{ai_bindir}/ai-tools-handback-client.py %{buildroot}%{ai_bin # ships. brp-compress may gzip it (hence the %%files glob). install -d -m 0755 %{buildroot}%{ai_mandir}/man1 install -m 0644 src%{ai_mandir}/man1/ai-tools.1 %{buildroot}%{ai_mandir}/man1/ai-tools.1 +# ai-tools-admin(8): section 8 because every command it documents refuses a non-root caller. +install -d -m 0755 %{buildroot}%{ai_mandir}/man8 +install -m 0644 src%{ai_mandir}/man8/ai-tools-admin.8 %{buildroot}%{ai_mandir}/man8/ai-tools-admin.8 # operator.conf(5): the host options and the shared KEY=value grammar they are written in. install -d -m 0755 %{buildroot}%{ai_mandir}/man5 install -m 0644 src%{ai_mandir}/man5/operator.conf.5 %{buildroot}%{ai_mandir}/man5/operator.conf.5 @@ -327,7 +330,7 @@ install -m 0440 src%{_sysconfdir}/sudoers.d/ai-tools %{buildroot}%{_sysconfdir}/ # ── base: host-config template. The @PROJECTS_USER@ token stays literal at build (the # operator is a runtime identity), so stage the template with OPERATORS emptied; -# `ai-tools-admin operator add` fills it in place. %config(noreplace) keeps the +# `ai-tools-admin operators add` fills it in place. %config(noreplace) keeps the # operator's OPERATORS/SKIP_* edits across upgrades. ── install -d -m 0755 %{buildroot}%{_sysconfdir}/ai-tools sed 's/^OPERATORS=.*/OPERATORS=""/' src%{_sysconfdir}/ai-tools/operator.conf \ @@ -338,7 +341,7 @@ chmod 0644 %{buildroot}%{_sysconfdir}/ai-tools/operator.conf # Staged here, shipped in the ai-tools-selinux subpackage (which also carries the load/unload # scriptlets and the GPL licence text -- see its %%package block). # The core (loaded on install) plus each STABLE optional group. Only stable groups ship -# prebuilt: they are toggled per host with `ai-tools-admin selinux enable-group `, +# prebuilt: they are toggled per host with `ai-tools-admin selinux groups enable `, # which semodule-loads the prebuilt .pp from this directory (no source tree or # selinux-policy-devel needed). EXPERIMENTAL groups are NOT shipped -- they are compiled and # verified from a source checkout on demand (install-selinux.sh enable-group + the avc loop); @@ -532,7 +535,7 @@ chmod 2770 /var/opt/ai-tools/sandbox-projects 2>/dev/null || : # keep_existing semantics), so a fresh install or upgrade self-heals a missing guard while an # existing -- possibly operator-customised -- file is never clobbered. This runs on every # transition, not fresh-install only, so a file lost to an earlier package's config handling is -# restored. No operator is bound yet at %post time (that is `ai-tools-admin operator add`, run +# restored. No operator is bound yet at %post time (that is `ai-tools-admin operators add`, run # after this), so the .gitconfig email uses the hostname -f fallback. if [ ! -f /opt/ai-tools/.gitignore ]; then install -m 0640 -o root -g ai-tools \ @@ -574,7 +577,7 @@ done # Each is gated on the state it would create rather than on install-vs-upgrade, so an upgrade # names only what this host still owes, a step undone since an earlier run included. An operator # is two facts -- ai-ops membership and a name in OPERATORS (cli.rule.md) -- so either one -# missing asks for `operator add`, which writes both. OPERATORS ships holding the literal +# missing asks for `operators add`, which writes both. OPERATORS ships holding the literal # @PROJECTS_USER@ token, which the name-character class excludes. A gate that cannot read its # input prints its hint. _at_toolchain=1 @@ -596,13 +599,13 @@ fi if [ "${_at_toolchain}${_at_operator}${_at_merge}" != "000" ]; then echo "ai-tools-base: steps this host still needs:" if [ "${_at_toolchain}" = 1 ]; then - echo " sudo ai-tools-bootstrap # install nvm + Node + Claude Code (network)" + echo " sudo ai-tools-bootstrap # install nvm + Node + Claude Code (network)" fi if [ "${_at_operator}" = 1 ]; then - echo " sudo ai-tools-admin operator add # bind an operator (ai-ops, OPERATORS, linger)" + echo " sudo ai-tools-admin operators add # bind an operator (ai-ops, OPERATORS, linger)" fi if [ "${_at_merge}" = 1 ]; then - echo " sudo ai-tools-admin postupgrade # operator.conf.rpmnew is waiting" + echo " sudo ai-tools-admin system post-upgrade # operator.conf.rpmnew is waiting" fi fi @@ -668,13 +671,13 @@ fi %post -n ai-tools-selinux # Load the core module into the RUNNING policy and apply contexts. Core only -- the stable # optional groups ship prebuilt alongside it but stay OFF, toggled per host with -# `ai-tools-admin selinux enable-group ` (experimental groups are not shipped). +# `ai-tools-admin selinux groups enable ` (experimental groups are not shipped). # # `semodule -i` loads into the RUNNING policy, not just the module store: the entrypoint is # labelled by the restorecon below only once the module's types exist in the kernel, and # ai-tools-run's preflight refuses to launch (`mislabel`) while it is unlabelled. The default # module priority puts this in the same slot selinux/install-selinux.sh and `ai-tools-admin -# selinux enable-group` address, so one host holds one copy of each module and a package upgrade +# selinux groups enable` address, so one host holds one copy of each module and a package upgrade # always supersedes what it replaces. # # After relabelling the daemon binary, refresh an already-active handback socket (an upgrade): the @@ -706,7 +709,7 @@ fi %postun -n ai-tools-selinux # On final erase only, unload every loaded ai_tools module -- the core, any stable optional group -# enabled with `ai-tools-admin selinux enable-group`, and any EXPERIMENTAL group compiled from a +# enabled with `ai-tools-admin selinux groups enable`, and any EXPERIMENTAL group compiled from a # source checkout. Enumerated rather than named: a .pp is erased with the package, but the # compiled module persists in the policy store until removed, and a module built from source was # never in the rpm database at all. Leaving one loaded would keep a domain alive for files the @@ -806,13 +809,13 @@ for kind in skills:skills subagents:agents; do done # settings.json is %config(noreplace), so a host that tuned its permission rules keeps them and rpm # parks this version's copy as .rpmnew. Choosing between the two is the operator's call, made -# through `ai-tools-admin postupgrade` -- a scriptlet does not edit a config file. Say so here, +# through `ai-tools-admin system post-upgrade` -- a scriptlet does not edit a config file. Say so here, # because leaving it costs silently: a hook this version ships installs its body and its data, and # nothing invokes it until its DECLARATION reaches settings.json. if [ -f /opt/ai-tools/.claude/settings.json.rpmnew ]; then echo "ai-tools: settings.json.rpmnew is waiting -- this version's hook declarations are not in" echo " your settings.json yet, so the hooks they declare never run. Merge them with:" - echo " sudo ai-tools-admin postupgrade" + echo " sudo ai-tools-admin system post-upgrade" fi %preun -n ai-tools-agents-claude-code-restricted @@ -859,6 +862,7 @@ fi %{_sbindir}/ai-tools %attr(0644, root, root) %{ai_mandir}/man1/ai-tools.1* %attr(0644, root, root) %{ai_mandir}/man5/operator.conf.5* +%attr(0644, root, root) %{ai_mandir}/man8/ai-tools-admin.8* %attr(0750, root, ai-tools) %{ai_bindir}/ai-tools-handback-client %dir %attr(0751, root, ai-tools) %{ai_libdir} %attr(0644, root, root) %{ai_libdir}/log.lib.sh @@ -1005,6 +1009,24 @@ fi %config(noreplace) %attr(0640, root, ai-tools) /opt/ai-tools/.claude/settings.json %changelog +* Tue Sep 01 2026 dagnode - 0.15.0-1 +- CHANGED: The ai-tools-admin commands are spelled as a resource grammar, so the names an + administrator types are 'operators add ', 'operators remove ', 'operators' + (which lists them), 'selinux groups', 'selinux groups enable ', 'selinux groups + disable ', and 'system post-upgrade'. The old spellings -- operator add, selinux + list-groups, selinux enable-group, selinux disable-group, postupgrade -- are gone and are + not aliased. Update any script, cron job or runbook that calls them. What each command does + is unchanged. +- NEW: 'ai-tools-admin --help' and '-h' print the command summary, and '--version' prints the + installed version. The tool previously answered a wrong command with a one-line error and had + no way to show its surface at all. Both answer any caller rather than only root, so reading + what the tool does needs no sudo. +- NEW: ai-tools-admin(8) documents every command, its arguments, the exit codes and the files + each one touches, with worked examples. 'man ai-tools-admin'. +- CHANGED: A rejected command line exits 2 rather than 1, so an unattended caller can tell a + command nobody can type correctly from an operation that ran and failed. Exit 0 and exit 1 + keep their meanings. + * Mon Aug 31 2026 dagnode - 0.14.0-1 - NEW: 'ai-tools --project-create ' creates a project: one directory, an empty git repository, a README.md naming it, then the ordinary claim on the result. It was an alias for diff --git a/packaging/ai-tools.sysusers b/packaging/ai-tools.sysusers index 87d7212e..fc44b46e 100644 --- a/packaging/ai-tools.sysusers +++ b/packaging/ai-tools.sysusers @@ -7,5 +7,5 @@ u ai-tools - "AI tools sandbox user" /opt/ai-tools /sbin/nologin # Operators group. Names the login users (a human plus rootless service accounts) that drive # the sandbox through the shared ai-tools account; the sudoers grant and the launch wrapper gate # on membership. The agent account ai-tools is NOT a member. Created here but left empty: members -# are added per host by `ai-tools-admin operator add`, never by the package. +# are added per host by `ai-tools-admin operators add`, never by the package. g ai-ops - diff --git a/packaging/container-selftest.sh b/packaging/container-selftest.sh index ea52bad4..ba061812 100755 --- a/packaging/container-selftest.sh +++ b/packaging/container-selftest.sh @@ -52,7 +52,7 @@ phase() { } # as_operator : run a command in a fresh login shell of the operator, so it picks -# up the ai-ops group membership `operator add` just granted (a stale shell would not). +# up the ai-ops group membership `operators add` just granted (a stale shell would not). as_operator() { runuser -l "${OPERATOR}" -c "$*"; } # ── environment dump ───────────────────────────────────────────────────────── @@ -120,8 +120,8 @@ phase "nvm-update.timer enabled in the ai-tools --user instance" \ test -L /opt/ai-tools/.config/systemd/user/timers.target.wants/nvm-update.timer # ── operator enrolment ─────────────────────────────────────────────────────── -phase "ai-tools-admin operator add ${OPERATOR}" \ - ai-tools-admin operator add "${OPERATOR}" +phase "ai-tools-admin operators add ${OPERATOR}" \ + ai-tools-admin operators add "${OPERATOR}" phase "${OPERATOR} is in ai-ops + listed in operator.conf" \ bash -c "id -nG '${OPERATOR}' | tr ' ' '\n' | grep -qx ai-ops && grep -q '${OPERATOR}' /etc/ai-tools/operator.conf" diff --git a/selinux/README.md b/selinux/README.md index 0c98d2a1..d12a2ea0 100644 --- a/selinux/README.md +++ b/selinux/README.md @@ -74,9 +74,9 @@ surface for tasks that reach into system context, all **disabled by default**: core and load on any installed host with no toolchain: ```bash -sudo ai-tools-admin selinux list-groups -sudo ai-tools-admin selinux enable-group tmpmap -sudo ai-tools-admin selinux disable-group tmpmap +sudo ai-tools-admin selinux groups +sudo ai-tools-admin selinux groups enable tmpmap +sudo ai-tools-admin selinux groups disable tmpmap ``` **Experimental** groups are unaudited drafts: their rule set has not been verified under diff --git a/selinux/install-selinux.sh b/selinux/install-selinux.sh index 45057325..6db6828f 100755 --- a/selinux/install-selinux.sh +++ b/selinux/install-selinux.sh @@ -126,14 +126,18 @@ _list() { printf '[%s]' "${joined}" } -# _group_cmd : the command an operator on THIS host should run to manage a policy group. -# ai-tools-admin is the shipped entry point and is on PATH once the package is installed, so -# prefer it; a source checkout with nothing installed yet falls back to this script's own path. +# _group_cmd [name]: the command an operator on THIS host should run to manage a +# policy group. ai-tools-admin is the shipped entry point and is on PATH once the package is +# installed, so prefer it; a source checkout with nothing installed yet falls back to this +# script's own path. The two front doors spell the action differently -- the shipped one follows +# the command grammar (.claude/rules/cli-grammar.rule.md) while this developer-only script keeps +# its hyphenated verb -- so each branch renders its own spelling from the same action. _group_cmd() { + local verb="$1" name="${2:-}" if command -v ai-tools-admin >/dev/null 2>&1; then - printf 'sudo ai-tools-admin selinux %s' "$1" + printf 'sudo ai-tools-admin selinux groups %s %s' "${verb}" "${name}" else - printf 'sudo %s %s' "$0" "$1" + printf 'sudo %s %s-group %s' "$0" "${verb}" "${name}" fi } sayx() { printf '%s\n' "$*" >&2; } @@ -304,7 +308,7 @@ prompt_groups() { # under this script's IFS=$'\n\t' glues them with a NEWLINE -- so the command would land # unindented on its own line. Keep the note and its (indented) command as separate lines. sayx " ${C_DIM}this step only adds or rebuilds modules; remove one with:${C_RST}" - sayx " ${C_DIM}$(_group_cmd 'disable-group ')${C_RST}" + sayx " ${C_DIM}$(_group_cmd disable)${C_RST}" fi sayx "" @@ -325,7 +329,7 @@ prompt_groups() { # (stability) tag matches list-groups so the stable/experimental split is visible per row. if ai_tools_selinux_group_loaded "${name}"; then printf ' %s[LOADED]%s %s %s(%s)%s -- %s\n' "${C_GRN}" "${C_RST}" "${name}" "${C_DIM}" "${stability}" "${C_RST}" "${desc}" >&2 - sayx " already enabled; to remove it: $(_group_cmd "disable-group ${name}")" + sayx " already enabled; to remove it: $(_group_cmd disable "${name}")" # A loaded group is still offered, because from a source checkout the operator may be # iterating on its .te/.fc and want to rebuild + reload it in place. A yes recompiles # FROM SOURCE (build_pp below), not a prebuilt reuse -- that is the point of offering diff --git a/src/etc/ai-tools/operator.conf b/src/etc/ai-tools/operator.conf index e30cf058..da71e5cc 100644 --- a/src/etc/ai-tools/operator.conf +++ b/src/etc/ai-tools/operator.conf @@ -14,7 +14,7 @@ # A repeated key takes its last assignment. # The login users whose projects the sandbox works on. Managed in place by -# `ai-tools-admin operator add|remove`; read at runtime by the root helpers and the +# `ai-tools-admin operators add|remove`; read at runtime by the root helpers and the # agent hooks (resolver: /usr/local/lib/ai-tools/operator.lib.sh). OPERATORS="@PROJECTS_USER@" diff --git a/src/usr/local/bin/ai-tools.sh b/src/usr/local/bin/ai-tools.sh index 7a542b48..bb47f67b 100755 --- a/src/usr/local/bin/ai-tools.sh +++ b/src/usr/local/bin/ai-tools.sh @@ -3561,7 +3561,7 @@ cmd_providers() { # Read the loaded module list FIRST. If it is not readable unprivileged (common: the policy # store is root-only on many hosts), omit the whole section rather than print a section that # only says "cannot read" -- the group/dependency reporting below all needs this list, so - # without it there is nothing accurate to show. `sudo ai-tools-admin selinux list-groups` is + # without it there is nothing accurate to show. `sudo ai-tools-admin selinux groups` is # where an operator inspects policy groups. local modules { modules="$(semodule -l 2>/dev/null)" && [[ -n "${modules}" ]]; } || return 0 @@ -3584,7 +3584,7 @@ cmd_providers() { fi done (( loaded_any )) || say " ${C_DIM}(no optional groups loaded)${C_RST}" - say " ${C_DIM}toggle with: sudo ai-tools-admin selinux enable-group ${C_RST}" + say " ${C_DIM}toggle with: sudo ai-tools-admin selinux groups enable ${C_RST}" # dotnet <-> tmpmap: dotnet restore/build mmaps a shared-memory file under /tmp, which # needs the 'tmpmap' group. Under enforcing, if dotnet is enabled but tmpmap is not loaded @@ -3595,7 +3595,7 @@ cmd_providers() { say "" say " ${C_YEL}dotnet is enabled but the 'tmpmap' SELinux group is not loaded:${C_RST}" say " ${C_YEL}dotnet restore/build will fail under enforcing (EACCES on mmap of /tmp).${C_RST}" - say " fix: sudo ai-tools-admin selinux enable-group tmpmap" + say " fix: sudo ai-tools-admin selinux groups enable tmpmap" fi # dotnet <-> apphost: executable/host projects run their apphost/JIT code from an # anonymous memfd file, which needs the 'apphost' group -- disjoint from tmpmap (that @@ -4182,7 +4182,7 @@ require_operator() { for op in "${ops[@]}"; do [[ "${op}" == "${INVOKING_USER}" ]] && return 0; done fi die "you (${INVOKING_USER}) are not a configured ai-tools operator -- add your name to OPERATORS in ${conf} with:" \ - " sudo ai-tools-admin operator add ${INVOKING_USER}" + " sudo ai-tools-admin operators add ${INVOKING_USER}" } # handover_target [args...] -- the project path to name in a handed-over command. Naming it @@ -4438,7 +4438,7 @@ require_for_target() { 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}" + " sudo ai-tools-admin operators add ${FOR_OPERATOR}" OWNER_GROUP="$(id -gn "${FOR_OPERATOR}" 2>/dev/null)" \ || die "cannot resolve the primary group of ${FOR_OPERATOR}" snapshot_allowlist diff --git a/src/usr/local/bin/claude.sh b/src/usr/local/bin/claude.sh index 079c4528..5a6c08a2 100755 --- a/src/usr/local/bin/claude.sh +++ b/src/usr/local/bin/claude.sh @@ -168,7 +168,7 @@ if [[ " $(id -nG 2>/dev/null) " != *" ${OPERATORS_GROUP} "* ]]; then else die "claude: ${_user} is not an ai-tools operator -- not a member of the ${OPERATORS_GROUP} group" \ " an administrator can grant access with:" \ - " sudo ai-tools-admin operator add ${_user}" + " sudo ai-tools-admin operators add ${_user}" fi fi diff --git a/src/usr/local/lib/ai-tools/conf.lib.sh b/src/usr/local/lib/ai-tools/conf.lib.sh index 6287f52a..b260d9a6 100644 --- a/src/usr/local/lib/ai-tools/conf.lib.sh +++ b/src/usr/local/lib/ai-tools/conf.lib.sh @@ -389,7 +389,7 @@ ai_tools_conf_keys() { # ...but only when written hard against the `#` or one space in. A comment indented # further is illustrative prose: operator.conf's header documents the grammar with # lines like `# KEY=value`, so counting those would make the minimally seeded file - # `ai-tools-admin operator add` writes report every documented key as new. + # `ai-tools-admin operators add` writes report every documented key as new. [[ "${line}" != " "* ]] || continue line="${line#"${line%%[![:space:]]*}"}" fi diff --git a/src/usr/local/lib/ai-tools/path-dedup.sh b/src/usr/local/lib/ai-tools/path-dedup.sh index b6f510c0..7c4facb5 100644 --- a/src/usr/local/lib/ai-tools/path-dedup.sh +++ b/src/usr/local/lib/ai-tools/path-dedup.sh @@ -4,13 +4,15 @@ # entries of an operator shell and orders them so the root-owned system tiers # win first-match. That places /usr/local/bin/claude — the wrapper that # launches claude restricted — ahead of any nvm-managed claude, so typing -# `claude` in a shell that has sourced this enters the sandbox. Sourced -# per-account: `ai-tools-admin operator add` wires it into the operator's -# ~/.bashrc and ~/.bash_profile after their nvm init (it must follow anything -# that prepends to PATH), which scopes the reorder to the operators who need -# it — root and unrelated accounts keep their stock PATH. Those two are bash's -# init files; `operator add` names a login shell that reads its own instead, -# and the operator ranks the tiers there. The sandbox session needs no sourcing: +# `claude` runs the wrapper wherever this fragment is sourced. +# `ai-tools-admin operators add` appends that source line to the operator's +# ~/.bashrc and ~/.bash_profile, below their nvm init, since it must follow +# anything that prepends to PATH. The fragment lives in the ai-tools lib dir +# rather than /etc/profile.d, so root and unrelated accounts keep their stock +# PATH. Both files belong to bash: `operators add` reads the account's login +# shell and, for any other, reports which init file the operator adds the +# ordering to. ai-tools-run pins the session PATH as a unit property, so a +# sandbox session takes its order from the unit. # ai-tools-run pins the session PATH as a unit property. # # PATH is first-match-wins: an early directory shadows every later one. The diff --git a/src/usr/local/lib/ai-tools/selinux-groups.lib.sh b/src/usr/local/lib/ai-tools/selinux-groups.lib.sh index f8166a22..82da1abf 100644 --- a/src/usr/local/lib/ai-tools/selinux-groups.lib.sh +++ b/src/usr/local/lib/ai-tools/selinux-groups.lib.sh @@ -31,7 +31,7 @@ readonly AI_TOOLS_SELINUX_PACKAGE_DIR="/usr/share/selinux/packages/ai-tools" # The stability field is 'experimental' or 'stable': 'experimental' groups are # unaudited drafts whose rule set has not been verified under permissive against a # real workload, so a consumer that enables one warns and confirms first (see -# ai-tools-admin selinux enable-group); a 'stable' group is a single, reasoned rule +# ai-tools-admin selinux groups enable); a 'stable' group is a single, reasoned rule # that has been tested. Add a group as 'experimental' until it earns 'stable'. # shellcheck disable=SC2034 # iterated by consumers via the accessors below readonly AI_TOOLS_SELINUX_GROUPS=( diff --git a/src/usr/local/libexec/ai-tools/ai-tools-admin.sh b/src/usr/local/libexec/ai-tools/ai-tools-admin.sh index 1b48861f..2d707b66 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-admin.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-admin.sh @@ -6,13 +6,18 @@ # linger; the loaded optional SELinux policy groups) while the ai-tools CLI is unprivileged and # refuses to run as root. # -# sudo ai-tools-admin operator add [user] # default: $SUDO_USER -# sudo ai-tools-admin operator remove -# sudo ai-tools-admin operator list -# sudo ai-tools-admin selinux list-groups # show core + optional group state -# sudo ai-tools-admin selinux enable-group # load a prebuilt (stable) group -# sudo ai-tools-admin selinux disable-group # unload one -# sudo ai-tools-admin postupgrade # reconcile the .rpmnew files upgrades leave +# sudo ai-tools-admin operators # list (the zero-argument default) +# sudo ai-tools-admin operators add [user] # default: $SUDO_USER +# sudo ai-tools-admin operators remove +# sudo ai-tools-admin selinux groups # show core + optional group state +# sudo ai-tools-admin selinux groups enable # load a prebuilt (stable) group +# sudo ai-tools-admin selinux groups disable # unload one +# sudo ai-tools-admin system post-upgrade # reconcile the .rpmnew files upgrades leave +# +# The spelling is the project's command grammar (.claude/rules/cli-grammar.rule.md): a bare-word +# command, a plural collection, the verb after the noun, `list` as the zero-argument default, and +# a singular domain (`selinux`, `system`) where one is needed. `--` introduces an option and +# never a command, which here is `--help`/`-h` and `--version`. # # An operator is a login user (a human or a rootless service account) that drives the sandbox # through the shared ai-tools account. `add` is accumulating and idempotent: it appends the @@ -22,17 +27,17 @@ # membership (drops the name from OPERATORS and ai-ops), leaving the user's own allowlist and config. # `list` prints the current operators. # -# `selinux` toggles the optional policy groups (systemd/pkgmgmt/netadmin/podman/tmpmap/apphost/netcore), all off +# `selinux groups` toggles the optional policy groups (systemd/pkgmgmt/netadmin/podman/tmpmap/apphost/netcore), all off # by default. It loads the PREBUILT ai_tools_.pp shipped in the base package via semodule -- # no source tree or selinux-policy-devel needed on the host. The group set, descriptions, and # per-group stability are single-sourced from selinux-groups.lib.sh, shared with # selinux/install-selinux.sh (the source-tree authoring tool that instead COMPILES a group; this # operator helper only loads a shipped one). Only STABLE groups ship prebuilt (currently tmpmap); -# enable-group of an EXPERIMENTAL (unaudited) group is refused with a pointer to the source +# `groups enable` of an EXPERIMENTAL (unaudited) group is refused with a pointer to the source # compile-and-verify workflow (install-selinux.sh + the avc bring-up loop), since this tool will -# not load an unaudited module. disable-group works for any loaded group, stable or not. +# not load an unaudited module. `groups disable` works for any loaded group, stable or not. # -# `postupgrade` reconciles the `.rpmnew` copies an upgrade leaves beside the +# `system post-upgrade` reconciles the `.rpmnew` copies an upgrade leaves beside the # %config(noreplace) files this stack owns. rpm keeps what the host edited and parks the new # version alongside it; choosing between the two is a judgement about the operator's own # configuration, so it happens here, when the operator asks, and never in a scriptlet. Each file @@ -54,14 +59,67 @@ readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh" readonly SELINUX_GROUPS_LIB="/usr/local/lib/ai-tools/selinux-groups.lib.sh" readonly CONF_LIB="/usr/local/lib/ai-tools/conf.lib.sh" +# Substituted at deploy time (install.sh install_subst from packaging/VERSION; the RPM from +# %{version}), and left as the literal token in the checkout -- which `--version` reports as +# `dev`, the same value and the same fallback the CLI uses. +AI_TOOLS_VERSION="@AI_TOOLS_VERSION@" +[[ "${AI_TOOLS_VERSION}" == @*@ ]] && AI_TOOLS_VERSION="dev" +readonly AI_TOOLS_VERSION + die() { printf 'ai-tools-admin: error: %s\n' "$*" >&2; exit 1; } log() { printf 'ai-tools-admin: %s\n' "$*"; } +# reject : the command line was rejected. Exit 2 separates a command nobody can type +# correctly from an operation that ran and failed (`die`, exit 1), which is the split +# ai-tools-admin(8) documents and the one ai-tools(1) already uses. +reject() { + printf 'ai-tools-admin: %s\n' "$*" >&2 + printf "try 'ai-tools-admin --help'\n" >&2 + exit 2 +} + +# usage: the command surface, grouped by domain. Orientation rather than reference -- every +# option, exit code and example is in ai-tools-admin(8), and tests/unit/man.sh holds the two in +# agreement on the command set. +usage() { + cat < withdraw an operator's enrolment + SELinux + selinux groups the core module and the optional groups + selinux groups enable load a prebuilt optional group + selinux groups disable unload a loaded group + System + system post-upgrade reconcile the .rpmnew files an upgrade leaves + + --version the installed version + --help this summary + + Run every command through sudo: each one administers the host and refuses a + non-root caller. The project lifecycle is the unprivileged ai-tools CLI, which + you run as yourself. + + Every command, exit code and example: man ai-tools-admin +EOF +} + # Executed, this administers a host and needs root. Sourced -- by tests/unit/admin-operator-add.sh, # which drives one function with sudo stubbed -- it asserts nothing about the host and only # defines, stopping at the matching guard above the dispatch. Everything between the two is # definitions, so the executed path still refuses a non-root caller before any action. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # --help and --version read no host state and change nothing, so they answer any caller and + # are handled here, ahead of the root check: an operator meeting the tool gets the command + # surface rather than a refusal naming sudo without saying what to run under it. Both ignore + # any further argument. + case "${1:-}" in + --help|-h) usage; exit 0 ;; + --version) printf 'ai-tools-admin %s\n' "${AI_TOOLS_VERSION}"; exit 0 ;; + esac [[ "${EUID}" -eq 0 ]] || die "run as root (sudo)" fi @@ -72,7 +130,7 @@ fi # shellcheck source=SCRIPTDIR/../../lib/ai-tools/selinux-groups.lib.sh . "${SELINUX_GROUPS_LIB}" || die "cannot source ${SELINUX_GROUPS_LIB}" -# The shared config grammar, sidecar handling, and hook-declaration merge that `postupgrade` +# The shared config grammar, sidecar handling, and hook-declaration merge that `system post-upgrade` # drives. Required, not optional: a reconcile that silently skipped its merge would leave a # shipped hook uninvoked while reporting success. # shellcheck source=SCRIPTDIR/../../lib/ai-tools/conf.lib.sh @@ -257,7 +315,7 @@ report_operator_role() { op_add() { local user="${1:-${SUDO_USER:-}}" - [[ -n "${user}" ]] || die "usage: ai-tools-admin operator add (or run via sudo so SUDO_USER is set)" + [[ -n "${user}" ]] || reject "operators add: name a user, or run it through sudo so SUDO_USER is set" [[ "${user}" != "${SANDBOX_USER}" ]] || die "an operator must not be the sandbox account ${SANDBOX_USER}" [[ "${user}" != "root" ]] || die "an operator must be a normal login user, not root" id "${user}" &>/dev/null || die "no such user: ${user}" @@ -304,7 +362,7 @@ op_add() { op_remove() { local user="${1:-}" - [[ -n "${user}" ]] || die "usage: ai-tools-admin operator remove " + [[ -n "${user}" ]] || reject "operators remove: name the user to withdraw" ai_tools_load_operators || true if ! in_list "${user}"; then log "${user} is not an operator; nothing to remove" @@ -328,12 +386,12 @@ op_list() { fi } -# ── selinux: optional policy-group management ──────────────────────────────────────── +# ── selinux groups: optional policy-group management ───────────────────────────────── # These load/unload the PREBUILT ai_tools_.pp shipped in the base package; the group # set and text come from selinux-groups.lib.sh. Distinct from selinux/install-selinux.sh, # which compiles a group from source in a repo checkout -- this runs on any installed host. -# require_selinux: guard shared by every selinux subcommand. Returns 1 (caller exits 0 -- +# require_selinux: guard shared by every selinux command. Returns 1 (caller exits 0 -- # nothing to manage) when SELinux is disabled; dies when semodule is absent (a real gap). require_selinux() { if [[ "$(getenforce 2>/dev/null)" == "Disabled" ]]; then @@ -356,8 +414,8 @@ _selinux_usage_groups() { sel_enable() { local name="${1:-}" - [[ $# -le 1 ]] || die "one group name at a time (usage: ai-tools-admin selinux enable-group )" - [[ -n "${name}" && "${name}" != -* ]] || die "usage: ai-tools-admin selinux enable-group " + [[ $# -le 1 ]] || reject "selinux groups enable: one group name at a time" + [[ -n "${name}" && "${name}" != -* ]] || reject "selinux groups enable: name the group to load" require_selinux || return 0 if ! ai_tools_selinux_group_valid "${name}"; then log "unknown group '${name}'. Available groups:"; _selinux_usage_groups @@ -392,8 +450,8 @@ sel_enable() { sel_disable() { local name="${1:-}" - [[ $# -le 1 ]] || die "one group name at a time (usage: ai-tools-admin selinux disable-group )" - [[ -n "${name}" && "${name}" != -* ]] || die "usage: ai-tools-admin selinux disable-group " + [[ $# -le 1 ]] || reject "selinux groups disable: one group name at a time" + [[ -n "${name}" && "${name}" != -* ]] || reject "selinux groups disable: name the group to unload" require_selinux || return 0 if ! ai_tools_selinux_group_valid "${name}"; then log "unknown group '${name}'. Available groups:"; _selinux_usage_groups @@ -428,12 +486,12 @@ sel_list() { if ai_tools_selinux_group_loaded "${name}"; then state='[LOADED] '; else state='[disabled]'; fi printf ' %s %-9s %-15s %s\n' "${state}" "${name}" "(${stability})" "${desc}" done - printf '\n toggle : sudo ai-tools-admin selinux enable-group | disable-group \n' + printf '\n toggle : sudo ai-tools-admin selinux groups enable | disable \n' printf ' experimental : not shipped prebuilt -- enable from a source checkout with\n' printf ' sudo selinux/install-selinux.sh enable-group \n' } -# ── postupgrade: reconcile the .rpmnew files an upgrade leaves ─────────────────────────────── +# ── system post-upgrade: reconcile the .rpmnew files an upgrade leaves ─────────────────────── # rpm keeps an operator-modified %config(noreplace) file and parks the package's copy beside it as # .rpmnew. Choosing between the two is a judgement call about the operator's own # configuration, so no scriptlet makes it: this is the explicit, interactive command that does, and @@ -563,7 +621,7 @@ _pu_review() { } postupgrade() { - [[ $# -eq 0 ]] || die "usage: ai-tools-admin postupgrade (takes no arguments)" + [[ $# -eq 0 ]] || reject "system post-upgrade: takes no arguments" local entry file kind label found=0 local root="${AI_TOOLS_POSTUPGRADE_ROOT:-}" @@ -587,42 +645,59 @@ postupgrade() { log "done. this command is idempotent -- re-run it at any time." } +# ── dispatch ───────────────────────────────────────────────────────────────────────────────── +# One arm per name in the grammar's two shapes: ` [verb]`, where the absent verb is +# `list`, and ` `. A bare collection lists; a bare domain prints its own +# commands, since a domain has no reading that a default could safely take and every verb under +# one of these mutates the host. + +operators_dispatch() { + local verb="${1:-list}"; [[ $# -eq 0 ]] || shift + case "${verb}" in + list) op_list "$@" ;; + add) op_add "$@" ;; + remove) op_remove "$@" ;; + *) reject "unknown command 'operators ${verb}' (list|add|remove)" ;; + esac +} + +selinux_groups_dispatch() { + local verb="${1:-list}"; [[ $# -eq 0 ]] || shift + case "${verb}" in + list) sel_list ;; + enable) sel_enable "$@" ;; + disable) sel_disable "$@" ;; + *) reject "unknown command 'selinux groups ${verb}' (list|enable|disable)" ;; + esac +} + selinux_dispatch() { - [[ $# -ge 1 ]] || die "usage: ai-tools-admin selinux [name]" - local sub="$1"; shift - case "${sub}" in - list-groups) sel_list ;; - enable-group) sel_enable "$@" ;; - disable-group) sel_disable "$@" ;; - *) die "unknown selinux subcommand '${sub}' (list-groups|enable-group|disable-group)" ;; + [[ $# -ge 1 ]] || reject "selinux owns one collection: 'selinux groups [list|enable|disable]'" + local resource="$1"; shift + case "${resource}" in + groups) selinux_groups_dispatch "$@" ;; + *) reject "unknown command 'selinux ${resource}' (groups)" ;; + esac +} + +system_dispatch() { + [[ $# -ge 1 ]] || reject "system takes a verb: 'system post-upgrade'" + local verb="$1"; shift + case "${verb}" in + post-upgrade) postupgrade "$@" ;; + *) reject "unknown command 'system ${verb}' (post-upgrade)" ;; esac } # Sourced rather than executed (see the note at the root check): stop here with every function -# defined and nothing dispatched, so the caller's arguments are not read as a subcommand. +# defined and nothing dispatched, so the caller's arguments are not read as a command. [[ "${BASH_SOURCE[0]}" == "${0}" ]] || return 0 -# Dispatch: `operator ` | `selinux `. -[[ $# -ge 1 ]] || die "usage: ai-tools-admin ..." +# --help/-h and --version are answered above, before the root check. +[[ $# -ge 1 ]] || { usage >&2; exit 2; } case "$1" in - postupgrade) - shift - postupgrade "$@" - ;; - operator) - shift - [[ $# -ge 1 ]] || die "usage: ai-tools-admin operator [user]" - sub="$1"; shift - case "${sub}" in - add) op_add "$@" ;; - remove) op_remove "$@" ;; - list) op_list "$@" ;; - *) die "unknown operator subcommand '${sub}' (add|remove|list)" ;; - esac - ;; - selinux) - shift - selinux_dispatch "$@" - ;; - *) die "unknown subcommand '$1' (operator|selinux|postupgrade)" ;; + operators) shift; operators_dispatch "$@" ;; + selinux) shift; selinux_dispatch "$@" ;; + system) shift; system_dispatch "$@" ;; + *) printf 'ai-tools-admin: unknown command: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; esac diff --git a/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh b/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh index c26bcd1f..9936d8a6 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh @@ -133,7 +133,7 @@ caller="$(id -un "${caller_uid}" 2>/dev/null)" \ || die "the sandbox account may not manage an allowlist -- nothing changed" ai_tools_load_operators 2>/dev/null \ - || die "no operators configured -- run: sudo ai-tools-admin operator add " + || die "no operators configured -- run: sudo ai-tools-admin operators add " _is_operator() { local want="$1" op @@ -157,7 +157,7 @@ _is_operator "${caller}" \ || die "root is not an operator -- nothing changed" _is_operator "${OPERATOR}" \ || die "${OPERATOR} is not a configured ai-tools operator -- enrol it first with: - sudo ai-tools-admin operator add ${OPERATOR}" + sudo ai-tools-admin operators add ${OPERATOR}" target_home="$(getent passwd "${OPERATOR}" 2>/dev/null | cut -d: -f6)" \ || die "cannot resolve ${OPERATOR} -- nothing changed" diff --git a/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh b/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh index ef4441ae..a80dae84 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh @@ -431,7 +431,7 @@ configure_git_identity # deploy (the common flow -- the wrapper is already present), or before it on a from-source # host. Name the step that is actually still outstanding rather than assuming one order. if [[ -x /usr/local/bin/claude ]]; then - log "next: enrol an operator -- sudo ai-tools-admin operator add " + log "next: enrol an operator -- sudo ai-tools-admin operators add " else log "next: deploy the control plane -- sudo ./install.sh install (or install the RPM)" fi diff --git a/src/usr/local/share/man/man1/ai-tools.1 b/src/usr/local/share/man/man1/ai-tools.1 index 3e64dda9..f79ec7bf 100644 --- a/src/usr/local/share/man/man1/ai-tools.1 +++ b/src/usr/local/share/man/man1/ai-tools.1 @@ -57,7 +57,7 @@ plain Enter \(em and any run without a terminal \(em takes the default, which always falls on the safe side of the question. .SS Operators and the sudo grant Being an operator is two facts, both written by -.BR "ai\-tools\-admin operator add" : +.BR "ai\-tools\-admin operators add" : membership of the .B ai\-ops group, and a name in @@ -464,7 +464,7 @@ or each provider's own when that file names none. Read-only, and it reports any input the resolver refused as untrusted. On an SELinux host it also reports the loaded optional policy groups, and warns with the -.B ai-tools-admin selinux enable-group tmpmap +.B ai-tools-admin selinux groups enable tmpmap fix when the .B dotnet integration is enabled under Enforcing but the @@ -611,7 +611,7 @@ human operator performs the claim on its behalf, once, and the account never meets a password prompt. .I operator must already be enrolled -.RB ( "sudo ai\-tools\-admin operator add " \fIoperator\fR ), +.RB ( "sudo ai\-tools\-admin operators add " \fIoperator\fR ), since the ownership helpers resolve a project's owner from the .B OPERATORS list. @@ -763,6 +763,7 @@ ai\-tools \-\-project\-unclaim ~/src/myproject .EE .RE .SH SEE ALSO +.BR ai\-tools\-admin (8), .BR operator.conf (5), .BR setfacl (1), .BR sudo (8). diff --git a/src/usr/local/share/man/man5/operator.conf.5 b/src/usr/local/share/man/man5/operator.conf.5 index 04208352..5d02ca81 100644 --- a/src/usr/local/share/man/man5/operator.conf.5 +++ b/src/usr/local/share/man/man5/operator.conf.5 @@ -68,7 +68,7 @@ is ignored. .BI OPERATORS= "user\fR[,\fP user\fR...]\fP" The login accounts that may launch a session and that own the project trees. Maintained with -.BR "ai-tools-admin operator add" , +.BR "ai-tools-admin operators add" , which also adds the account to the .B ai-ops group; edit by hand only to review or remove. @@ -266,7 +266,7 @@ see the full difference, and clear the copy when you are done: .PP .RS 4 .EX -sudo ai\-tools\-admin postupgrade +sudo ai\-tools\-admin system post\-upgrade .EE .RE .PP diff --git a/src/usr/local/share/man/man8/ai-tools-admin.8 b/src/usr/local/share/man/man8/ai-tools-admin.8 new file mode 100644 index 00000000..e94d1702 --- /dev/null +++ b/src/usr/local/share/man/man8/ai-tools-admin.8 @@ -0,0 +1,269 @@ +.\" ai-tools-admin(8) -- host administration for Agent Tools Restricted. +.\" Kept in sync with the helper's own usage() text by tests/unit/man.sh, which +.\" compares the command sets of both; @AI_TOOLS_VERSION@ is substituted at +.\" deploy time (install.sh install_subst / the RPM %prep), like the helper. +.TH AI-TOOLS-ADMIN 8 "" "ai-tools @AI_TOOLS_VERSION@" "Agent Tools Restricted" +.SH NAME +ai-tools-admin \- administer the host a sandboxed coding agent runs on +.SH SYNOPSIS +.B ai-tools-admin +.I command +.RI [ argument ] +.br +.B ai-tools-admin +.BR \-\-help | \-\-version +.SH DESCRIPTION +.B ai-tools-admin +administers the host state the sandbox rests on: who is enrolled as an +operator, which optional SELinux policy groups are loaded, and the +configuration files an upgrade leaves to reconcile. +Every command runs as root and refuses any other caller, which is what +separates it from +.BR ai-tools (1) +\(em the project-lifecycle CLI, run as your own login user, which refuses root +for every command that writes an operator's registries. +.PP +A command is a bare word and +.B \-\- +introduces an option, so a collection takes its verb after the noun +.RB ( "operators add" , +.BR "selinux groups enable" ). +A collection named on its own lists what it holds. +A domain named on its own +.RB ( selinux , +.BR system ) +prints its commands, since every verb it owns changes the host. +.PP +Commands that write a file back it up first, name each path they touched, and +state each yes/no question's default in the prompt +.RB ( "[Y/n] (default: Yes)" ); +plain Enter \(em and any run without a terminal \(em takes that default. +.SS Operators +An operator is a login user, a person or a rootless service account, that +drives the sandbox through the shared +.B ai-tools +account. +Enrolment writes two facts: +a name in +.B OPERATORS +in +.BR operator.conf (5), +from which the root helpers resolve who owns a path, and membership of the +.B ai-ops +group, which the +.BR sudoers (5) +grant and the launch wrapper both gate on. +.PP +Claiming a project needs a third fact this command cannot write: a general +.BR sudo (8) +grant, which the host's own sudoers decides. +.B "operators add" +reports which of the two shapes the enrolment produced \(em an account that +can claim projects as well as launch sessions, or one that launches sessions +on projects another operator claimed for it with +.BR "ai\-tools \-\-project\-claim \-\-for" . +.PP +Group membership applies to new login sessions, so an operator enrolled from +an already-open shell starts a new one before launching an agent. +.SS SELinux groups +The core policy module confines every session to the +.B ai_tools_t +domain and covers a repository workload: files, git, HTTPS. +The optional groups widen that domain for one workload each, are disabled by +default, and are loaded per host. +.B "selinux groups enable" +loads the module the base package ships prebuilt, needing no source tree and +no +.BR selinux\-policy\-devel . +.PP +Only groups marked +.I stable +ship prebuilt. +A group marked +.I experimental +is an unaudited draft and is refused here, with a pointer to the source +checkout that compiles and verifies it. +.SH COMMANDS +.TP +.B operators +List the enrolled operators, one per line. +This is what a bare +.B operators +runs, and +.B "operators list" +is the same command spelled in full. +.TP +.B operators add \fR[\fIuser\fR] +Enrol +.IR user , +defaulting to +.BR $SUDO_USER . +Appends the name to +.BR OPERATORS , +adds the account to +.BR ai\-ops , +seeds an empty allowlist at +.IR ~/.config/ai\-tools/allowed\-projects , +enables +.BR loginctl (1) +linger for the sandbox account, and offers to source the PATH ordering +fragment from the account's +.I ~/.bashrc +and +.IR ~/.bash_profile . +Idempotent: a re-run reconciles each of those and reports what was already in +place. +Refuses root and the sandbox account. +.TP +.B operators remove \fIuser\fR +Withdraw +.IR user 's +enrolment: drops the name from +.B OPERATORS +and the account from +.BR ai\-ops . +Leaves the account's own allowlist and configuration on disk, so re-enrolling +restores the projects it had. +.TP +.B selinux groups +Report the core module and every optional group with its state +.RB ( [LOADED] / [disabled] ), +its stability, and what it covers. +Reports that there is nothing to manage when SELinux is disabled. +.TP +.B selinux groups enable \fIname\fR +Load the prebuilt module for +.IR name . +Prints the group list when +.I name +is not a registered group, and refuses an +.I experimental +group with the source-checkout command that compiles it. +Loading a group widens what SELinux permits and does not lift the session's +namespace filter. +.TP +.B selinux groups disable \fIname\fR +Unload the module for +.IR name . +Works for any loaded group, stable or experimental. +.TP +.B system post-upgrade +Reconcile the +.I .rpmnew +copies an upgrade leaves beside the configuration files this stack owns. +Each file gets the treatment its content calls for: the agent's +.I settings.json +has the hook declarations it lacks merged in, with its permission rules left +as written; +.BR operator.conf (5) +is reported and never rewritten; +.I /etc/sudoers.d/ai\-tools +is shown only. +Every treatment shows the difference first, confirms, and writes a dated +backup before changing anything. +Idempotent, and a no-op when no +.I .rpmnew +file is waiting. +.SH OPTIONS +Both options read no host state and change nothing, so both answer any caller +rather than only root, and each ignores any further argument. +.TP +.BR \-\-help ", " \-h +Print the command summary and exit. +.TP +.B \-\-version +Print the installed version and exit. +.SH EXIT STATUS +.TP +.B 0 +The command completed. +An idempotent command that found nothing left to do lands here, as does a +command that had nothing to manage because SELinux is disabled. +.TP +.B 1 +The operation failed. +.TP +.B 2 +The command line was rejected \(em an unknown command, a missing argument, or +a domain named with no command after it. +Nothing was changed. +.SH ENVIRONMENT +.TP +.B AI_TOOLS_ASSUME_YES +Set to +.B 1 +to answer every question whose default is already +.I yes +without prompting, for unattended runs. +A question defaulting to +.I no +still prompts, so this never turns a refusal into consent. +.PP +Host settings are not environment variables: they live in +.BR operator.conf (5), +which is root-owned so that a session cannot widen what it is given. +.SH FILES +.TP +.I /etc/ai-tools/operator.conf +Operator registry and host options. +.B operators +edits the +.B OPERATORS +line in place and leaves every other setting as written. +.TP +.I ~/.config/ai-tools/allowed-projects +The enrolled account's own allowlist of project paths, seeded empty by +.BR "operators add" . +.TP +.I /usr/share/selinux/packages/ai-tools +The prebuilt policy modules +.B "selinux groups enable" +loads. +.TP +.I /etc/sudoers.d/ai-tools +The grant that lets an operator launch a sandboxed session. +.B "system post-upgrade" +shows changes to it and never writes it. +.SH EXAMPLES +Enrol yourself on a freshly installed host: +.PP +.RS 4 +.EX +sudo ai\-tools\-admin operators add "$(id \-un)" +.EE +.RE +.PP +Claim a project for a passwordless service account you enrolled earlier: +.PP +.RS 4 +.EX +sudo ai\-tools\-admin operators add svc\-agent +ai\-tools \-\-project\-claim \-\-for svc\-agent ~/src/myproject +.EE +.RE +.PP +See which policy groups are loaded, then widen the domain for a .NET build: +.PP +.RS 4 +.EX +sudo ai\-tools\-admin selinux groups +sudo ai\-tools\-admin selinux groups enable tmpmap +.EE +.RE +.PP +Reconcile the configuration files an upgrade left behind: +.PP +.RS 4 +.EX +sudo ai\-tools\-admin system post\-upgrade +.EE +.RE +.SH SEE ALSO +.BR ai\-tools (1), +.BR operator.conf (5), +.BR semodule (8), +.BR sudoers (5). +.PP +Running one sandbox for several operators \(em what each account gets, and +what stays private to it: +.I /usr/share/doc/ai\-tools/multi\-operator.md diff --git a/tests/integration/cli.sh b/tests/integration/cli.sh index e726eae1..5c43aba7 100644 --- a/tests/integration/cli.sh +++ b/tests/integration/cli.sh @@ -800,7 +800,7 @@ fi # ── --project-disable / --project-enable: parking a project in place ───────────────────────── # The pair edits ONE line of the operator's own allowlist and reaches no root helper, so the whole # lifecycle is drivable here as the projects user over the fixture registry. What is asserted is -# what the flat-file model rests on (see the state note in wip/issues): the line is edited IN +# what the flat-file model rests on (the three entry states, in cli.rule.md): the line is edited IN # PLACE, a parked project is not an unlisted one, and neither verb ever invents or lifts a line it # cannot attribute -- since lifting the wrong '!' hands the agent a subtree its operator withheld. section "ai-tools --project-disable / --project-enable" diff --git a/tests/integration/hooks.sh b/tests/integration/hooks.sh index 39b05de5..5fede8a1 100644 --- a/tests/integration/hooks.sh +++ b/tests/integration/hooks.sh @@ -66,7 +66,7 @@ else if grep -qxF "${hook} record" <<<"${got}"; then pass "settings.json declares the Bash tool-call record (${hook} record)" else - fail "settings.json does not declare '${hook} record' -- the agent's Bash calls are unrecorded (merge the shipped hook declarations into the kept settings.json: sudo ai-tools-admin postupgrade)" + fail "settings.json does not declare '${hook} record' -- the agent's Bash calls are unrecorded (merge the shipped hook declarations into the kept settings.json: sudo ai-tools-admin system post-upgrade)" fi # (0a-ii) The token-saving filter hook is declared on both Bash events. Losing it costs @@ -82,7 +82,7 @@ else for ev in PreToolUse PostToolUse; do got="$(jq -r --arg e "${ev}" '[.hooks[$e][]?.hooks[]?.command] | join("\n")' "${settings}" 2>/dev/null)" if ! grep -qxF "${want_filter[$ev]}" <<<"${got}"; then - fail "settings.json ${ev} does not declare '${want_filter[$ev]}' -- Bash output is unfiltered (merge the shipped hook declarations into the kept settings.json: sudo ai-tools-admin postupgrade)" + fail "settings.json ${ev} does not declare '${want_filter[$ev]}' -- Bash output is unfiltered (merge the shipped hook declarations into the kept settings.json: sudo ai-tools-admin system post-upgrade)" filter_ok=false fi done diff --git a/tests/integration/perms.sh b/tests/integration/perms.sh index 2cc2217e..6bab9edd 100644 --- a/tests/integration/perms.sh +++ b/tests/integration/perms.sh @@ -252,6 +252,11 @@ if [[ -e /usr/local/share/man/man5/operator.conf.5.gz ]]; then else check_file /usr/local/share/man/man5/operator.conf.5 root root 644 fi +if [[ -e /usr/local/share/man/man8/ai-tools-admin.8.gz ]]; then + check_file /usr/local/share/man/man8/ai-tools-admin.8.gz root root 644 +else + check_file /usr/local/share/man/man8/ai-tools-admin.8 root root 644 +fi # Launch wrapper: 755 root:root -- system-wide on every operator's PATH (path-dedup.sh ranks # /usr/local/bin above the nvm shims, so it shadows nvm's claude). Runs as the invoking # operator, gates on ai-ops membership, then drops to the sandbox account via sudo; root-owned diff --git a/tests/integration/stop.sh b/tests/integration/stop.sh index ed15da37..d9efa7c1 100644 --- a/tests/integration/stop.sh +++ b/tests/integration/stop.sh @@ -33,7 +33,7 @@ section "session stop: the kill path against real processes (integration)" readonly STOP_HELPER="/usr/local/libexec/ai-tools/ai-tools-stop" if [[ ! -r "${STOP_HELPER}" ]]; then - skip "session stop" "ai-tools-stop is not installed -- run: sudo ai-tools-admin postupgrade" + skip "session stop" "ai-tools-stop is not installed -- run: sudo ai-tools-admin system post-upgrade" finish; exit fi diff --git a/tests/manual/verify-live-flows.sh b/tests/manual/verify-live-flows.sh index 3ca03462..aea304bf 100755 --- a/tests/manual/verify-live-flows.sh +++ b/tests/manual/verify-live-flows.sh @@ -448,7 +448,7 @@ if [[ -r /etc/ai-tools/operator.conf ]]; then fi if [[ -z "${OTHER_OP}" ]]; then skip "--for create/remove (no second enrolled operator on this host)" - note "enrol one with: sudo ai-tools-admin operator add -- then re-run with --for-drill" + note "enrol one with: sudo ai-tools-admin operators add -- then re-run with --for-drill" elif ! ${FOR_DRILL}; then skip "--for create/remove (would act for ${OTHER_OP}; re-run with --for-drill)" note "the run would create a tree owned by ${OTHER_OP} and delete it again" diff --git a/tests/unit/admin-operator-add.sh b/tests/unit/admin-operator-add.sh index 1b36dcd3..b8565bd3 100644 --- a/tests/unit/admin-operator-add.sh +++ b/tests/unit/admin-operator-add.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-only # tests/unit/admin-operator-add.sh -# Unit test for report_operator_role -- the lines `ai-tools-admin operator add` prints to say which +# Unit test for report_operator_role -- the lines `ai-tools-admin operators add` prints to say which # of the two operator shapes the enrolment just produced: one that can claim projects, or one whose # projects another operator claims for it. # @@ -27,10 +27,10 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" HELPER="/usr/local/libexec/ai-tools/ai-tools-admin" [[ -r "${HELPER}" ]] || HELPER="${ROOT}/src/usr/local/libexec/ai-tools/ai-tools-admin.sh" -section "ai-tools-admin operator add: the sudo-grant report (unit)" +section "ai-tools-admin operators add: the sudo-grant report (unit)" if [[ ! -r "${HELPER}" ]]; then - skip "operator add report" "helper not readable (neither installed nor in a checkout)" + skip "operators add report" "helper not readable (neither installed nor in a checkout)" finish; exit fi diff --git a/tests/unit/conf.sh b/tests/unit/conf.sh index 79ad060f..63ce0d87 100644 --- a/tests/unit/conf.sh +++ b/tests/unit/conf.sh @@ -304,7 +304,7 @@ fi # A commented-out DEFAULT and an indented EXAMPLE look alike to a naive scan, and the difference # decides what an upgrade reports. operator.conf documents its own grammar with lines like # `# KEY=value`, so counting those as mentions makes the minimally seeded file -# `ai-tools-admin operator add` writes look like it already knows every option there is. +# `ai-tools-admin operators add` writes look like it already knows every option there is. example_conf="${TESTDIR}/example.conf" cat > "${example_conf}" <<'CONF' # Grammar, by example: diff --git a/tests/unit/install-guards.sh b/tests/unit/install-guards.sh index 749209da..418dbe8a 100644 --- a/tests/unit/install-guards.sh +++ b/tests/unit/install-guards.sh @@ -4,7 +4,7 @@ # Hermetic check of install.sh's entry guards -- the ones that decide WHICH account the install # enrols as the operator, before it writes anything. # -# The one that matters is root. `ai-tools-admin operator add` refuses it outright, and install.sh +# The one that matters is root. `ai-tools-admin operators add` refuses it outright, and install.sh # reaches the same end state by a different route (the @PROJECTS_USER@ substitution plus # `usermod -aG ai-ops`), so the two have to refuse alike or the dev path produces a host nobody # can provision: the CLI refuses root every mutating verb, --for refuses root as a target, and the diff --git a/tests/unit/man.sh b/tests/unit/man.sh index 3b633acb..705a9992 100644 --- a/tests/unit/man.sh +++ b/tests/unit/man.sh @@ -1,25 +1,82 @@ #!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-only # tests/unit/man.sh -# Hermetic sync test between ai-tools(1) and the CLI's own help. The two surfaces are no -# longer copies of each other -- usage() is orientation (verbs and the cross-verb flags) -# while the page is the reference (every per-verb option) -- so equality of their whole -# option sets is the wrong contract and is what used to make slimming the help impossible. -# Four checks replace it: +# Hermetic sync test between this project's two man pages and the help text of the command each +# documents: ai-tools(1) against the CLI's usage(), and ai-tools-admin(8) against the admin +# helper's. In both pairs the page and the help are no longer copies of each other -- usage() is +# orientation while the page is the reference -- so equality of their whole option sets is the +# wrong contract and is what used to make slimming the help impossible. +# +# ai-tools(1), four checks: # (1) the VERB sets match in both directions; # (2) every long option usage() names anywhere is documented in the page; # (3) every long option the page's OPTIONS section documents is one a CLI parser # accepts -- the direction that catches an option outliving its parser; # (4) the .TH version field is present -- @AI_TOOLS_VERSION@ in the repo source, a version # number on an RPM install, `dev` on a source install of an unstamped tree. -# Pure text comparison of the two source files -- no root, no install dependency, no CLI -# execution (the CLI's bootstrap gate fail-closes on an unprovisioned host, so it cannot be -# run for its help output here). Validates the repo sources directly, falling back to the -# installed CLI + man page outside a checkout (the man page may be gzipped there). +# +# ai-tools-admin(8), the same three relations over a surface spelled in bare words rather than +# long options (.claude/rules/cli-grammar.rule.md), so what is compared is the COMMAND PATH -- +# `selinux groups enable`, three tokens -- rather than a single flag: +# (1) the command sets match in both directions; +# (2) every token of every documented command is one a dispatch `case` arm accepts, which is +# what catches a page still naming a command after the dispatch renamed it. The admin +# helper dispatches through nested `case` statements rather than one flat parser, so the +# arms are collected across all of them and matched per token; +# (3) the same .TH version field. +# +# Pure text comparison of the source files -- no root, no install dependency, and neither command +# is executed (the CLI's bootstrap gate fail-closes on an unprovisioned host and the admin helper +# refuses a non-root caller, so neither can be run for its help output here). Validates the repo +# sources directly, falling back to the installed pair outside a checkout (a page may be gzipped +# there). set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# read_man : the page text with troff's escaped hyphens (\-\-project\-claim) flattened, so +# every extraction below matches plain option and command spellings. +read_man() { + case "$1" in + *.gz) zcat "$1" ;; + *) cat "$1" ;; + esac | sed 's/\\-/-/g' +} + +# usage_text