Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
af26486
docs(assets): bump a shipped asset's version once per release
p4nda Sep 4, 2026
cb1b035
docs(skill): narrow the prose checker's shape rules to what a regex c…
p4nda Sep 4, 2026
40b1c6e
docs(skill): read docstrings and block comments, and tie an object's …
p4nda Sep 4, 2026
b478894
docs: state the router and README invariants as mechanisms
p4nda Sep 4, 2026
3ba2241
docs(skill): say how to act on a finding, not just how to find one
p4nda Sep 4, 2026
e872471
docs(skill): give the doc/code conflict rule one home and three direc…
p4nda Sep 4, 2026
679f4aa
docs(rules): state the component rules as mechanisms
p4nda Sep 4, 2026
d501a86
docs(skill): note that --staged reads only the added half of a split …
p4nda Sep 4, 2026
f4dc7d6
docs: name the absent input in the operator-facing docs and man pages
p4nda Sep 4, 2026
f7c71fc
fix(skill): stop a shell case pattern from opening a block comment
p4nda Sep 4, 2026
14d4cd0
docs(src): name the absent input in the CLI, the stop helper, and the…
p4nda Sep 4, 2026
0ac9cec
docs(skill): rule that a rewrite keeps the claim, and a check that re…
p4nda Sep 4, 2026
1f229f1
docs(src): sweep the remaining helpers, and restore the invariants ea…
p4nda Sep 4, 2026
c4e5fa3
docs(build): sweep the installer, packaging, and the SELinux tooling
p4nda Sep 4, 2026
34e58ef
docs(tests): name the absent input in the test headers
p4nda Sep 4, 2026
637a1cd
docs: finish the sweep across the SELinux policy, the Python helpers,…
p4nda Sep 4, 2026
476ab06
fix(docs): restore two claims the sweep overstated
p4nda Sep 4, 2026
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
22 changes: 11 additions & 11 deletions .claude/rules/agent-claude-code.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ different file set and a different question (what the harness may run), not beca
are unrelated.

`ai-tools-agents-claude-code-restricted` ships the wrapper, the manifest, the session-env fragment,
the two resolver libraries, and the agent's config directory. It adds no sudoers rule: it inherits
the two resolver libraries, and the agent's config directory. It does not add a sudoers rule: it inherits
the single `%ai-ops` grant on the shared shim.

## What the manifest declares
Expand All @@ -32,7 +32,7 @@ the single `%ai-ops` grant on the shared shim.
| `npm_package` | `@anthropic-ai/claude-code` | `ai-tools-bootstrap`, `nvm-update` — what to install |
| `launcher` | `claude` | `ai-tools-launcher-symlink` (which link it may write), `ai-tools-run` (which executables may start a session) |
| `display_name` | `Claude Code` | the launch banner, the unit description |
| `handback` | `hooks` | `ai-tools-run` — this agent converges the tree itself, so the shim adds no session-end sweep |
| `handback` | `hooks` | `ai-tools-run` — this agent converges the tree itself, so the shim does not add a session-end sweep |
| `config_dir` | `.claude` | the control-plane mode/label/seeding set, and `→ ai_tools_home_t` |
| `skills_dir` / `subagents_dir` | `skills` / `agents` | where shared assets are symlinked in ([shipped-assets](shipped-assets.rule.md)) |
| `entrypoint_fcontext` | a regex ending `…/@anthropic-ai/claude-code/bin/claude\.exe` | `ai-tools-relabel-agent` — which file takes `ai_tools_exec_t` |
Expand Down Expand Up @@ -60,9 +60,9 @@ Which link a component addresses is a deliberate choice per component, not an in
| `ai-tools-launcher-symlink` | writes **[1]**, validated as **[2]** | the only writable control-plane link; `/opt/ai-tools/bin` is `0551`, so the sandbox reaches it only through this root helper |
| `claude.sh` | reads **[1]**, one `readlink` to **[2]** | full resolution would traverse the `700` package directory as the *operator*, an EACCES that aborts the wrapper silently under `set -e` |
| `ai-tools-run` | re-validates **[2]**, execs it | **[2]** is the allowlist shape: an exact `MAJOR.MINOR.PATCH` directory plus one path component an enabled manifest claims |
| the SELinux transition | fires on **[3]** | `execve` resolves symlinks; the label that matters is the one on the inode actually executed |
| the SELinux transition | fires on **[3]** | `execve` resolves symlinks; the label that matters is the one on the inode that is executed |

The one-hop constraint in `claude.sh` exists solely to avoid that EACCES. It carries no coupling to
The one-hop constraint in `claude.sh` exists solely to avoid that EACCES. It does not carry any coupling to
sudoers matching, which targets the fixed path `/opt/ai-tools/bin/ai-tools-run`.

**[3] is a hardlink, not the package's only name for the binary.** The npm package declares the
Expand Down Expand Up @@ -99,7 +99,7 @@ and they reach **[3]** two different ways:
`entrypoint_fcontext` as a local `semanage fcontext` rule and relabels the files a
`find -regex` over that pattern returns (`relabel.lib.sh`).

The two strategies agree only while the pattern describes where the package actually puts its
The two strategies agree only while the pattern describes where the package puts its
executable. The guarantee that does **not** depend on that agreement is the important one: a label
the transition would not honour is caught by the preflight on the resolved inode, so the failure is
a refused launch, never an unconfined session.
Expand All @@ -109,7 +109,7 @@ a refused launch, never an unconfined session.
`ai-tools-relabel-agent` closes the gap between them without changing which side applies the label.
The declared pattern stays the **apply** mechanism, because a `semanage fcontext` rule is what makes
a type survive a later `restorecon`; resolution is the **check**, so the helper's exit status answers
the question the operator actually asked — will the next launch be confined?
the question the operator asked — will the next launch be confined?

For each enabled agent it resolves `/opt/ai-tools/bin/<launcher>` the same way the preflight does
(`realpath -e`, as root), applies the declared rule, and reconciles the two through the pure
Expand All @@ -118,10 +118,10 @@ For each enabled agent it resolves `/opt/ai-tools/bin/<launcher>` the same way t
| state | verdict | outcome |
|---|---|---|
| the launcher resolves to a file the pattern covers | `ok` | labelled and verified |
| nothing resolves and the pattern matched nothing | `none` | the agent is not provisioned; nothing to label |
| the launcher does not resolve and the pattern does not match a file | `none` | the agent is not provisioned; no entrypoint to label |
| the launcher **resolves** to a file the pattern does **not** cover | `stale` | **reported and the run exits non-zero** |

`stale` is the case a repackaged upstream produces — the pattern matches nothing while the chain
`stale` is the case a repackaged upstream produces — the pattern does not match the file while the chain
still resolves, so the preflight's verdict is `unverifiable` and the launch is refused. The relabel
names that cause and says the fix is upstream of it (update the agent package, whose manifest has
stopped describing where its own executable installs), rather than reporting success and sending the
Expand Down Expand Up @@ -157,7 +157,7 @@ It gates in this order, each step refusing before the next can matter:
unreadable package directory), one `readlink`, then string-only validation that the target is an
absolute, `..`-free path matching the versioned shape.
4. **Print-and-exit short-circuit** — `--version`/`-v`/`--help`/`-h` as the *sole* argument skips
every CWD gate and runs with the sandbox home as `WorkingDirectory`. Such a run touches no
every CWD gate and runs with the sandbox home as `WorkingDirectory`. Such a run stays out of the
working tree, so no project grant is implied.
5. **Protected-paths backstop**, then the **allowlist** (exclusions first, since `!` overrides
allows), both on the `realpath`-canonicalized CWD.
Expand Down Expand Up @@ -186,7 +186,7 @@ and safety guidance) or `--system-prompt-file <path>` (mode `replace`).
- Claude Code reads the file **verbatim** — not processed, not comment-stripped — so it holds prompt
text only. The shipped default is therefore **empty**, `0640 root:SANDBOX_GROUP` (a custom prompt
may be proprietary, so not world-readable; the wrapper only `stat`s it as the operator, and the
confined binary reads it as the sandbox account). Uncommenting the pointer alone changes nothing.
confined binary reads it as the sandbox account). Uncommenting the pointer alone leaves the launch unchanged.
- **`replace` sets the request's `system` field, not the whole model context.** It does not remove
the tool definitions or the `CLAUDE.md` context Claude Code injects as `<system-reminder>` blocks;
those ride in separate request fields. "Only the file reaches the model" is not reachable through
Expand Down Expand Up @@ -219,7 +219,7 @@ is never read, so the file cannot inject unrecognised environment.
token with control bytes, options with no anchoring `ANTHROPIC_BASE_URL`, or a missing/untrusted
pointer file — the fragment `exit`s the launch, which is clean because it is sourced before the
unit is created and before the sweep trap is installed. A fully inert file (the shipped default)
applies nothing; a non-local endpoint with no token warns but still applies.
is applied as-is; a non-local endpoint with no token warns but still applies.
- **Precedence.** These are process environment variables, so a Claude Code settings `env` block
(authoritatively `/etc/claude-code/managed-settings.json`) setting the same name wins. The shipped
settings set no `ANTHROPIC_*` key, so the endpoint file governs by default and
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/authoring.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ one-file-per-rule mapping.
domain is a few specific files (the handback daemon/client/units), a recursive tree
where the domain is a directory (`.claude/**`), or a single file where the domain is one
library (`log.lib.sh`). There is no uniform template to force.
- **`paths:` matches what the rule actually describes** — neither over-claiming (a broad
- **`paths:` matches what the rule describes** — neither over-claiming (a broad
glob that also sweeps in unrelated files: audit logs, build artifacts, generated output)
nor pointing at a file that does not exist in the repo.
- **A file may belong to several domains.** Its path then appears in several rules, and
Expand Down
24 changes: 12 additions & 12 deletions .claude/rules/claude-settings.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ see [updater](updater.rule.md)) and the `HOME`/`PATH`/`CLAUDE_CONFIG_DIR` pins (
The two arrays sort a Bash command into one of three observable outcomes: **runs
without asking** (`allow`), **asks first** (unlisted — the default), or **refused**
(`deny`). None of this is a capability boundary — whatever runs still executes as
`SANDBOX_USER` confined by `ai_tools_t`, and a tool absent from the host simply fails
`SANDBOX_USER` confined by `ai_tools_t`, and a tool absent from the host fails
to resolve. The lists manage the **operator-visibility surface**: what is silent, what
is mediated by a prompt, and what the agent must raise with the operator in
conversation. JSON carries no comments, so the per-entry rationale lives here.
conversation. JSON does not carry comments, so the per-entry rationale lives here.

### Runs without asking (`allow`)

An entry earns its place by being **frequent** and **inspection-only**: it discloses
nothing beyond what the harness's dedicated read tools (Read/Grep/Glob) already access
only what the harness's dedicated read tools (Read/Grep/Glob) already access
without any Bash prompt, or it processes data already in hand.

**Project VCS state** — the working set every session touches:
Expand All @@ -61,11 +61,11 @@ without any Bash prompt, or it processes data already in hand.
### A rewritten command is what these rules match

The `PreToolUse` filter hook may narrow a Bash command before it runs
([filters](filters.rule.md)). It returns no permission decision, so the three outcomes above are
([filters](filters.rule.md)). It does not return a permission decision, so the three outcomes above are
decided on the **rewritten** command. Two consequences bound what a rule may do:

- A rule that only inserts arguments after the leading words leaves every entry here matching as
written — `Bash(git log *)` covers `git log --format=… -- src/x.c`, so a narrowing rule needs no
written — `Bash(git log *)` covers `git log --format=… -- src/x.c`, so a narrowing rule does not need a new
allow entry of its own.
- A rule that changes the leading command word is matched as that new command, and an entry broad
enough to cover a general-purpose wrapper (`Bash(<wrapper> *)`) is broader than the
Expand Down Expand Up @@ -98,7 +98,7 @@ work deleted from the tree.
| `git push --force*` | The remote's history for every other clone. The pattern also covers `--force-with-lease`, which narrows the race but still overwrites. |
| `git push -f *` | The short spelling of the same. |
| `git reset --hard*` | The working tree and index, including changes never committed. |
| `git clean -f*` | Untracked files — the ones no commit and no reflog can bring back. |
| `git clean -f*` | Untracked files, which no commit and no reflog can bring back. |

The criterion is **destruction with no undo**, so the refusal holds regardless of target: a
scratch branch and `main` are denied alike, because a deny rule matches a command string and
Expand Down Expand Up @@ -219,7 +219,7 @@ and hook declarations. Layering and override are under "Control-plane integrity"

Both put more of a session in front of the operator watching it: `showThinkingSummaries` re-shows
the thinking blocks Claude Code hides by default, and `verbose` shows Bash and command output in
full rather than truncated. They cost terminal space and nothing else — the session's authority is
full rather than truncated. They cost terminal space alone — the session's authority is
identical either way — and what they buy is that the operator confirming an action sees the
reasoning that produced it and the output it produced, which is the difference between approving a
command string and approving what the command did.
Expand Down Expand Up @@ -336,9 +336,9 @@ file.

The command runs the merge on a throwaway copy first, so the list it shows is the exact set of
declarations the real merge adds rather than a promise of one. It then confirms, writes the dated
`.bak`, names that backup, and offers to drop the `.rpmnew` against what is actually left: the
`.bak`, names that backup, and offers to drop the `.rpmnew` against what is left: the
cleanup prompt defaults to yes once the two files match, and to no while the permission rules still
differ. A refusal on this path needs no `.shipped` sidecar — the `.rpmnew` is that baseline, and
differ. A refusal on this path does not need a `.shipped` sidecar — the `.rpmnew` is that baseline, and
the throwaway copy is where the refused merge's own copy lands and is discarded.

`jq` is a hard runtime dependency of every hook this agent ships, not a convenience: each
Expand All @@ -353,10 +353,10 @@ filtering. The agent package `Requires: jq` for that reason.
only on operator-owned ones; a deny rule matches the command string, not the target's
owner, so it would break the valid majority to suppress an occasional EPERM — and that
EPERM is informative (it names the file as the operator's; the agent asks instead of
retrying). A deny here also reduces no surface: mode changes are reachable through
retrying). A deny here also leaves the surface unchanged: mode changes are reachable through
`install -m`, `cp -p`, `setfacl`, `os.chmod`, …, and the abuse-shaped forms
(`777`/`o+w`/`+s`) are already reverted by the handback's world-bit stripping while
setuid on a sandbox-owned file escalates nothing. The same reasoning keeps a
setuid on a sandbox-owned file does not grant a new privilege. The same reasoning keeps a
"safe subset" like `chmod +x *` out of `allow`: the allow list stays inspection-only
so its criterion stays crisp.
- **Filtering `allow` to the host's installed tools** (at install or after): an entry for
Expand All @@ -376,7 +376,7 @@ filtering. The agent package `Requires: jq` for that reason.

## Deferred

The deny list and optional-group enablement are kept in sync **by hand** — nothing links
The deny list and optional-group enablement are kept in sync **by hand** — no code links
`enable-group` to relaxing the matching deny entry, so a group enabled on its own has no
effect at the tooling layer. A durable fix derives the deny set from the loaded policy
groups, or has `enable-group` adjust `settings.json`, so the two layers cannot drift.
18 changes: 9 additions & 9 deletions .claude/rules/cli-grammar.rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ CLI spelling with an unambiguous URI on the other side.
| `list` is the **zero-argument default** on a collection | `ai-tools-admin operators` lists them |
| A bare noun with no `list` yet prints its verbs | it MUST NOT default to a **mutating** verb |
| A global, one-shot or infrastructure-level action goes top-level or under `system` | `system post-upgrade`, `system bootstrap` |
| Switches are **descriptive and long** | `--dry-run`, not `-n`; new surface adds no short flags |
| Switches are **descriptive and long** | `--dry-run`, not `-n`; new surface does not add short flags |
| `--help` and `--version` stay options | both `-h` and `--help` print the full help and ignore other arguments |

Verb-noun ordering is a **convention rather than a rule with one correct answer**: clig.dev
Expand Down Expand Up @@ -87,7 +87,7 @@ command takes yet. `ai-tools-bootstrap` and `ai-tools-dotnet setup` already hold
the name is what obligates it.

**Scope defaults to the minimum that works.** A bare `bootstrap` does the recommended minimal
setup, so an operator running it for the first time chooses nothing. Widening it — `system
setup, so an operator running it for the first time does not choose a scope. Widening it — `system
bootstrap` reaching every enabled integration rather than the toolchain alone — is an explicit
opt-in, projecting as a request field (`{"scope": "full"}`) rather than a second command. The
switch spelling is open; `--scope full` follows the descriptive-long-switch rule, while a bare
Expand All @@ -104,7 +104,7 @@ carries no `/admin` prefix (below). There are two typed commands:
| `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.
leave the host as it is, 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 @@ -147,7 +147,7 @@ one changes a security contract rather than a user surface.
## When a resource takes the `system` domain

`ai-tools-admin` refuses a non-root caller, which already tells the reader the surface is
privileged, so `system` marks no privilege and most resources do without it. A resource takes the
privileged, so `system` does not mark privilege and most resources do without it. A resource takes the
prefix when either test holds:

- its name **collides** with a resource a public API would plausibly expose, or
Expand All @@ -160,11 +160,11 @@ first-class administration concern owned by the SELinux domain.

**Being admin-only is not a test, and neither is returning more detail.** `operators` and the
anticipated `proxies`, `mcps` and `services` are all admin-managed and all stay flat, since their
names collide with nothing. A richer response for a privileged caller is a **view**, never a
names are unambiguous on their own. A richer response for a privileged caller is a **view**, never a
prefix.

Depth is the second reason not to over-prefix: Azure caps a URI at collection/item/collection and
Zalando limits nesting to one level. A custom method costs nothing against that budget, so
Zalando limits nesting to one level. A custom method does not add a level against that budget, so
`selinux groups enable <name>` projects to `/selinux/groups/{name}:enable` — a prefix, one
collection, one item. Adding `system` on top would deepen the **path** for a command an operator
types to unblock a build.
Expand Down Expand Up @@ -198,7 +198,7 @@ action is side-effect free, in which case `GET`.

| Form | Reads as |
|---|---|
| `/system/entrypoints:relabel` | a custom method on the **collection** — the action takes no identifier |
| `/system/entrypoints:relabel` | a custom method on the **collection** — the action does not take an identifier |
| `/system/entrypoints/{agent}:relabel` | a custom method on **one instance** |
| `/system/entrypoints/relabel` | a sub-resource *named* `relabel`, which corrupts the hierarchy |

Expand Down Expand Up @@ -255,7 +255,7 @@ and three of the four are root-only, so the last two fold into `ai-tools-admin`

`ai-tools-bootstrap` and `ai-tools-dotnet` lose their standalone names and their `%{_sbindir}`
symlinks with the move, in both `install.sh` and the RPM. `ai-tools-admin` ships in
`ai-tools-base` and needs nothing provisioned to run, so a host reaches `system bootstrap` before
`ai-tools-base` and runs on an unprovisioned host, so a host reaches `system bootstrap` before
the toolchain it installs exists. `system bootstrap` belongs to base and `dotnet bootstrap` to the
integration package that owns the domain.

Expand Down Expand Up @@ -285,7 +285,7 @@ keeping every name (`ai-tools project-claim`) settles both and commits to no hie

## Why not

- **A deprecation alias beside a renamed command.** The repo carries no migration shims: the code
- **A deprecation alias beside a renamed command.** The repo does not carry migration shims: the code
reflects the final state and dev hosts are cleaned by hand. A renamed command is renamed.
- **`--` as a marker for an unsettled surface.** It reads as an option, which is the collision
itself. Projects gating an unstable surface use an explicit namespace instead — `kubectl alpha`,
Expand Down
Loading