Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/claude-settings.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 15 additions & 9 deletions .claude/rules/cli-grammar.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>` 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 <name>` 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 <pkg...>` | `dotnet tools install <pkg...>` |
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions .claude/rules/cli.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>`. `operator.conf` is `644`,
up-front message pointing at `sudo ai-tools-admin operators add <user>`. `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
Expand Down Expand Up @@ -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 <when>]` — report what has refused, been rejected, been stranded, or been
Expand Down
10 changes: 5 additions & 5 deletions .claude/rules/confinement.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<group>.pp`) alongside the core in `/usr/share/selinux/packages/ai-tools/`, and
`sudo ai-tools-admin selinux enable-group <name>` `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 <name>` `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 <name>` 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 <name>` (which compiles from `.te`/`.fc`, then
loads) plus the `avc/` bring-up loop. Promoting one to stable means marking it `stable` in the
Expand Down
6 changes: 3 additions & 3 deletions .claude/rules/launch.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<launcher>` 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
Expand Down
4 changes: 2 additions & 2 deletions .claude/rules/providers.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions .claude/rules/tests.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading