diff --git a/.claude/rules/agent-claude-code.rule.md b/.claude/rules/agent-claude-code.rule.md index 98d76ba0..a32d72b8 100644 --- a/.claude/rules/agent-claude-code.rule.md +++ b/.claude/rules/agent-claude-code.rule.md @@ -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 @@ -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` | @@ -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 @@ -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. @@ -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/` the same way the preflight does (`realpath -e`, as root), applies the declared rule, and reconciles the two through the pure @@ -118,10 +118,10 @@ For each enabled agent it resolves `/opt/ai-tools/bin/` 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 @@ -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. @@ -186,7 +186,7 @@ and safety guidance) or `--system-prompt-file ` (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 `` blocks; those ride in separate request fields. "Only the file reaches the model" is not reachable through @@ -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 diff --git a/.claude/rules/authoring.rule.md b/.claude/rules/authoring.rule.md index 3f279618..7efcc914 100644 --- a/.claude/rules/authoring.rule.md +++ b/.claude/rules/authoring.rule.md @@ -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 diff --git a/.claude/rules/claude-settings.rule.md b/.claude/rules/claude-settings.rule.md index 88c43032..3c941480 100644 --- a/.claude/rules/claude-settings.rule.md +++ b/.claude/rules/claude-settings.rule.md @@ -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: @@ -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( *)`) is broader than the @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. diff --git a/.claude/rules/cli-grammar.rule.md b/.claude/rules/cli-grammar.rule.md index 5a9a37ae..812badc9 100644 --- a/.claude/rules/cli-grammar.rule.md +++ b/.claude/rules/cli-grammar.rule.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 ` 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. @@ -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 | @@ -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. @@ -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`, diff --git a/.claude/rules/cli.rule.md b/.claude/rules/cli.rule.md index 5343c071..ec1be4e6 100644 --- a/.claude/rules/cli.rule.md +++ b/.claude/rules/cli.rule.md @@ -25,18 +25,18 @@ How its commands are **spelled** — and why this one binary keeps option-spelle rest of the project uses bare words — is in [cli-grammar](cli-grammar.rule.md). This rule covers what each verb does. -**Root runs the verbs that write no operator-owned state, and no others.** That criterion is what +**Root runs only the verbs that leave operator-owned state untouched.** That criterion is what the root refusal protects: a registry written by root names an owner whose own launch gate cannot read it. `ROOT_ALLOWED_VERBS` — `--audit`, `--status`, `--list`, `--providers`, `--stop` — is the -whole set, and a verb joins it on what it **writes** rather than on what it reads. `--audit` is +whole set, and a verb joins it on what it **writes**, not on what it reads. `--audit` is what the carve-out exists for: the trail it reads is `700 root:root`, so the verb needs root by construction, and a blanket refusal left it unreachable from both sides on a host whose only -operator holds no general sudo grant. `--stop` is the one member that **acts** rather than -reports: it writes no registry, its helper requires root anyway, and root is the identity an -unattended detector usually runs as — so a CLI that refused root there refused the one principal -the rung most has to serve, while granting nothing new (root can run the helper directly and can -signal any process on the host). The set is named once and read by the guard, by the guard's own -refusal, and by `ai-tools(1)`. +operator does not hold a general sudo grant. `--stop` is the one member that **acts** rather than +reports: it leaves every registry untouched, its helper requires root anyway, and root is the +identity an unattended detector usually runs as — so a CLI that refused root there refused the one +principal the rung most has to serve, while root already holds both capabilities directly (it can +run the helper and signal any process on the host). The set is named once and read by the guard, +by the guard's own refusal, and by `ai-tools(1)`. The check runs **after** `--for` is separated from the command's arguments, because `$1` before that point is not reliably the verb (`ai-tools --for op --list` leads with the flag). That @@ -63,7 +63,7 @@ The **diagnostics** are exempt because each is meant for a host that may be brok reports the unprovisioned state itself, since a health check must run precisely when provisioning may have failed; `--audit` reads a record of what already happened, which an install that never finished does not invalidate — a failed provisioning is when that record is most worth reading; and -`--stop` ends sessions **already running**, needing nothing from the toolchain to do it. That last +`--stop` ends sessions **already running**, which it does without reading the toolchain. That last one matters because of the gate's own coupling below: keying on one agent's launcher symlink would otherwise put the incident ladder's last rung out of reach on a host that enables a different agent, or that lost the symlink while sessions were live. @@ -100,8 +100,8 @@ wrapper needs and which does require a fresh login). The **informational** comma (`--help`/`--version`/`--list`/`--providers`) stay open, so an unenrolled user can still read usage and inspect the host. -A third gate, `require_sudo_access`, refuses a verb whose root helper the caller holds no sudo -grant for, and names the command that reaches it instead (below). A fourth, `require_for_target`, +A third gate, `require_sudo_access`, refuses a verb whose root helper the caller does not hold a +sudo grant for, and names the command that reaches it instead (below). A fourth, `require_for_target`, runs last and validates a `--for` run (see *Acting for another operator* below). It is a no-op without the flag. @@ -128,8 +128,8 @@ probe could not have reported that either: it asks about a helper, not about a c The probe is `sudo -n -l `, which cannot prompt. An operator holding a general grant gets exit 0 and the command echoed back, whether or not a credential is cached — listing an allowed command is not itself password-gated on a stock sudoers. **The refusal is silent:** for a command -no rule matches, `sudo -l` exits non-zero and prints nothing, and the *"Sorry, user … is not -allowed to execute"* line an operator sees comes from the attempt to **run** the command, never +no rule matches, `sudo -l` exits non-zero with empty output, and the *"Sorry, user … is not +allowed to execute"* line an operator sees comes from the attempt to **run** the command, not from the listing. So silence with a non-zero status is the answer this reads as a missing grant. Silence is conclusive only while sudo is answering, so it is confirmed against a bare `sudo -n -l`, @@ -141,8 +141,8 @@ and only the first refuses. A *password is required* answer means listing is its **The gate reports; it does not decide, and so it fails open.** The project's fail-closed rule governs the predicates that decide what a principal may do: each resolves, on any failure, to -*less* access ([CLAUDE.md](../../CLAUDE.md)). This gate is not one of them. It grants nothing, and -`sudo` is still the only thing consulted about the helper — so an inconclusive probe (no `sudo` +*less* access ([CLAUDE.md](../../CLAUDE.md)). This gate is not one of them. It does not grant any +access, and `sudo` remains the only thing consulted about the helper — so an inconclusive probe (no `sudo` binary, a translated or unrecognized answer) falls through to the call site and lets sudo answer, leaving the access outcome identical to having no gate at all. The composition stays fail-closed because the enforcement point it sits in front of is. @@ -150,16 +150,17 @@ because the enforcement point it sits in front of is. Failing *closed* here would invert that: refusing on an unparsed message subtracts access sudo would have granted, turning a diagnostic into an access decision that can only ever take away — a `wheel` operator whose sudo answered in a locale the match did not cover would lose a command -they hold the grant for. The cost of the direction chosen is bounded and is never a security one: +they hold the grant for. The cost of the direction chosen is bounded, and is not a security cost: on a host where the answer cannot be read, the operator meets sudo's own message, which is the behaviour this gate exists to improve on rather than to guarantee. The refusal names the account, the helper, and one command, and stops there. Who that account belongs to is not knowable — a service account, a person on a restricted login, an administrator working from one deliberately — nor is who runs the suggested command or what they are to each -other. So it recommends no route to obtaining a grant, and describes nothing as anyone's. For the +other. So it does not suggest how to obtain a grant, and does not describe the account as +anyone's. For the delegable verbs the command carries `--for `, which is the whole mechanism: the verb runs -against that account's registry whoever performs it. `--sandbox-create` takes no `--for` (the clone +against that account's registry whoever performs it. `--sandbox-create` does not take `--for` (the clone is made with the git credentials of whoever runs it), so its refusal names two commands — the create, then a `--project-claim --for ` over the resulting clone, which the protected-paths backstop deliberately permits. `--audit` additionally names a `journalctl` query, since the trail is @@ -182,7 +183,7 @@ file sink being the authoritative one. the default-NO proceed confirm covering exactly the steps listed), *Secret lockdown* (before any access-granting step; fails the claim closed), the *`.git` history* and *Reachability* opt-ins, then *Apply* (one result line per step, closed by the final `claimed` ✓ — **only** when the steps - that grant access actually applied; see below). `-y/--yes` pre-answers + that grant access applied; see below). `-y/--yes` pre-answers only the claim's own default-NO proceed prompt ("Apply the pending steps above IN PLACE?") — the launch wrapper passes it for a delegated claim after taking its own confirmation, so the same decision is not asked twice; the scoped opt-ins (secret lockdown, `.git` history, ancestor @@ -207,7 +208,7 @@ file sink being the authoritative one. host (`--sandbox-create` is deliberately *not* named here: it clones an existing repository, and this verb's subject is a project that does not exist yet). - **It takes no options and asks no confirmation.** Its tree is empty by construction, which + **The path is its only argument, and it does not prompt for confirmation.** Its tree is empty by construction, which answers three of the claim's questions outright, so `cmd_project_claim` infers them instead of asking — gated on `tree_is_pristine`, which the claim re-derives itself (no file outside `.git` but `README.md`, and a repository with no commits) rather than trusting the caller's @@ -217,15 +218,15 @@ file sink being the authoritative one. did not exist a moment ago, and a warning that is routinely untrue is what teaches an operator to click through the ones that are not. The **secret gate** is skipped: its job is to find secret-named files before access is granted, a tree whose only file is the README this command - wrote provably has none, and `ai-tools-lockdown` carries no NOPASSWD rule — so the scan costs a + wrote provably has none, and `ai-tools-lockdown` does not carry a NOPASSWD rule — so the scan costs a sudo *password* prompt to search a directory the tool itself just made. The **`.git` history** question is inferred to yes: it asks about exposing history, a repository with no commits has - none, and normalizing is what keeps the operator's own later commits readable by the agent, so - asking would offer a choice between one real option and one that costs something for nothing. + none to expose, and normalizing is what keeps the operator's own later commits readable by the agent, so + asking would offer a choice between one real option and one that costs something for no gain. The traverse grant still asks — it widens access *above* the project, on directories that do exist and do have contents. - **Nothing it seeds is left owner-only, whatever the host umask.** A new directory, `git init`'s + **No path it seeds is left owner-only, whatever the host umask.** A new directory, `git init`'s `.git`, and the `README.md` are all born under the caller's umask, so on an `077` host they come out `0700`/`0600` — and an owner-only path is one `ai-tools-setgid` and `ai-tools-setfacl` honour as the operator's standing **seal** and skip, taking a directory's subtree with it. A create that @@ -236,7 +237,7 @@ file sink being the authoritative one. rather than `0770`/`0660` because group write here would widen the tree to the *operator's* primary group, shared on some hosts, for no gain; they are also the modes an unclaim normalizes back to. This is **not** a prompt: the seal is a statement about a path an operator restricted - deliberately, while a umask is a default for every new file that carries no intent about a + deliberately, while a umask is a blanket default for every new file, applied without regard to a directory created a moment ago by a command whose purpose is to give the agent somewhere to work. Where the umask *would* have sealed it, the create says so in a line rather than asking. - `--project-remove [path]` — unclaim a project **and delete its directory**; `--project-unclaim` @@ -261,7 +262,7 @@ file sink being the authoritative one. that is a git worktree, and it passes the protected-paths backstop. This scopes `--sandbox-remove`'s `rm -rf` to one recognized clone; a stray non-git directory is refused ("remove it by hand"). `--sandbox-create` scopes its own destination - (`` with no `/`, under `SANDBOX_ROOT`), so it needs no such guard. + (`` with no `/`, under `SANDBOX_ROOT`), so it does not need that guard. - `--lockdown [path]` — wrapper over `ai-tools-lockdown` (see [secret-handling](secret-handling.rule.md)). Refuses a path outside every claimed project up front (`covered_by_project`, before the sudo prompt), the same front-line the helper's @@ -286,7 +287,7 @@ file sink being the authoritative one. [updater](updater.rule.md). It then applies each agent's **declared** `entrypoint_fcontext` pattern and reconciles the result - against the entrypoint that agent's launcher symlink actually resolves to — the inode the launch + against the entrypoint that agent's launcher symlink resolves to — the inode the launch preflight checks. An entrypoint that is installed where the declaration does not reach exits non-zero naming that cause, so this command never reports success on a host whose next launch will fail closed. See [agent-claude-code](agent-claude-code.rule.md) for the reconciliation and @@ -315,14 +316,15 @@ file sink being the authoritative one. - `--audit [--since ]` — report what has refused, been rejected, been stranded, or been flagged since a given time, through the `ai-tools-audit` root helper (`sudo`, no NOPASSWD). The detections it reports already existed and were already recorded; what they lacked was a - reader, and a detection nobody reads is decoration. The verb is the lightest thing that - qualifies as one — not an alerting stack, one command with a consumer. + reader. The verb is the lightest thing that supplies one — one command with a consumer, not an + alerting stack. - **It invents no detection and matches no per-case wording.** The root-only file sink already - encodes severity in its line format (` [] `), so a finding is a line at - `NOTICE` or above. `NOTICE` is in scope deliberately: `ai-tools-chown` records a breached - secret at that level, and a leaked credential is the most actionable thing the command can - surface. Nothing drifts — a helper that adds a warning is reported the day it ships. + **Every finding comes from the sink's severity field, not from a per-case pattern.** The + root-only file sink already encodes severity in its line format (` [] `), + so a finding is a line at `NOTICE` or above. `NOTICE` is in scope deliberately: `ai-tools-chown` + records a breached secret at that level, and a leaked credential is the most actionable thing the + command can surface. A helper that adds a warning is therefore reported from the moment it ships, + with no pattern here to update. **Two sources, not equal, and the report says so.** `/var/log/ai-tools/*.log` is `700 root:root`, root writers only, so the sandbox account can neither read nor append to it: those @@ -332,7 +334,7 @@ file sink being the authoritative one. to reconcile against the first rather than to rely on alone (see [logging](logging.rule.md)). This is the split that keeps an agent-writable trail from being presented as proof. - **It reports events, never current state.** Each line is something that *happened* between + **It reports events, not current state.** Each line is something that *happened* between two points in time; a condition recorded here may have been resolved since. That distinction is load-bearing and the report states it, because the failure mode is specific and easy: a dated `ERROR` read as a standing fault sends an operator to fix something already fixed, and erodes @@ -345,9 +347,9 @@ file sink being the authoritative one. occurrence, so an uncollapsed report buries the finding that needs acting on under one already understood — the same reason `INFO` is out of scope. Findings are grouped by their message with digit runs normalized, so occurrences differing only in a pid or a count fold into one line - carrying the number of times it happened and the most recent example in full; nothing is - hidden, since the count states what was folded. Ordering is severity first, recency second — - the two questions actually being asked: what is worst, and is it still happening. + carrying the number of times it happened and the most recent example in full; the count states + exactly what was folded. Ordering is severity first, recency second — the two questions being + asked: what is worst, and is it still happening. Exits **non-zero when anything is reported**, so it runs unattended from cron or a login banner without parsing its output — the same contract `--status` offers. A `--since` value @@ -366,22 +368,22 @@ file sink being the authoritative one. **[docs/session-stop.md](../../docs/session-stop.md)**, which is this component's single source of truth: - - **Sessions are found and killed by cgroup**, never by process tree, and liveness is read from + - **Sessions are found and killed by cgroup**, not by process tree, and liveness is read from the kernel. systemd supplies one thing only — a unit's `WorkingDirectory` — and that is - **display**: it labels a row and names a `--reclaim`, and selects nothing. The report's split - between agent sessions and the account's own plumbing (its user manager, dbus, login session - scopes) is display in that same sense and carries the same caveat — the class comes from a unit - name, which inside a delegated subtree is the delegatee's to choose. It splits the two counts, - orders the table and decides which rows get a `--reclaim`; it selects nothing, and both classes - are killed identically. - - **It takes no target and no authorization input.** There is no per-project form, because every + **display**: it labels a row and names a `--reclaim`, without selecting any target. The report's + split between agent sessions and the account's own plumbing (its user manager, dbus, login + session scopes) is display in that same sense and carries the same caveat — the class comes from + a unit name, which inside a delegated subtree is the delegatee's to choose. It splits the two + counts, orders the table, and decides which rows get a `--reclaim`; both classes are killed + identically. + - **It does not take a target or an authorization input.** There is no per-project form, because every way to attribute a session to a project is written by the account being stopped. A path is **refused (exit 2), not ignored** — which is also what keeps targeted stopping addable later without changing what an existing command line means. `--all` is accepted and inert. - - **Nothing is exempt, including the account's own `systemd --user` and its `init.scope`.** An - exemption is a cgroup a session can move into on a DAC-only host. The manager is **restarted - afterwards** (`restore_user_manager`), as a step that runs after verification and is reported - on its own — it never changes what the command says about the stop. One consequence to keep: + - **Every cgroup under the account is swept, including its own `systemd --user` and + `init.scope`.** An exemption is a cgroup a session can move into on a DAC-only host. The manager + is **restarted afterwards** (`restore_user_manager`), as a step that runs after verification and + is reported on its own, so it does not change what the command says about the stop. One consequence to keep: a **rerun is therefore not silent**, since the restored manager is back inside the swept slice. The command is idempotent in *end state*, not in what it reports, and buying a silent rerun would cost either an exemption or a name-decided sweep. @@ -398,7 +400,7 @@ file sink being the authoritative one. sandbox-owned and the clean-exit marker is left for the next `SessionStart` ([ownership-and-hooks](ownership-and-hooks.rule.md)); the command names the `--reclaim` per project it terminated. On a shared host one operator's stop ends every operator's sessions — a - stated consequence, not an oversight, since `--all` never took an authorization input either. + stated consequence, not an oversight, since `--all` does not take an authorization input either. Everything is recorded to `stop.log` and journald, including which path gave consent and which pass ended each session. Exit codes are in `ai-tools(1)`. - `--status` — read-only health report: the installed `ai-tools` version, whether the toolchain is @@ -422,16 +424,17 @@ file sink being the authoritative one. and it is checked first, so a unit an optional package never shipped (the `nvm-update` pair without the nodejs integration) reads as not-installed rather than as one this host cannot see, and a stamp an uninstall left behind cannot make a gone unit look present. A run that **correctly -did nothing** reads `SKIPPED` with its reason (the updater against an unreachable registry, see +declined to act** reads `SKIPPED` with its reason (the updater against an unreachable registry, see [updater](updater.rule.md)): it is dim rather than yellow and does not count as a fault, so a disconnected laptop does not make `--status` exit non-zero every night — while the same stamp still ages into `STALE` if the condition persists, which is where a toolchain that has genuinely stopped advancing surfaces. The account's own `~/.config/systemd/user` is not searched: it sits inside a home the operator cannot traverse, and - every unit the registry names ships to the system-wide user-unit directory. A stamped unit's OK carries the time of that run rather than implying it is running now, + every unit the registry names ships to the system-wide user-unit directory. A stamped unit's OK + carries the time of that run, not a claim that it is running now, and a `FAILED` carries the run's exit code. The `?` line is not a problem report — it says only that this vantage point cannot tell — so it stays a single line naming the one command that can, - and the multi-command diagnostic block is reserved for a unit actually reported broken. + and the multi-command diagnostic block is reserved for a unit reported broken. **A stamp is read for two properties, and one stamp can serve two units.** `RESULT` answers *did the last run succeed*; its **age** answers *are runs still happening* — a distinct question a @@ -445,13 +448,13 @@ advancing surfaces. The account's own not also condemn the working schedule that started it. Only a systemd-started run counts, read from the stamp's `TRIGGER` (see [updater](updater.rule.md)): a run the operator did by hand is no evidence about a schedule, and counting one would both report a dead timer as healthy and - suppress the staleness that is the only way a stopped schedule shows up. An unknown age never - manufactures staleness either: no `max_age`, an unparseable date, or a stamp dated in the future + suppress the staleness that is the only way a stopped schedule shows up. An unknown age does not + produce a `STALE` verdict either: no `max_age`, an unparseable date, or a stamp dated in the future all decline the judgment. Times render **relative first** (`last run 3 days ago`), coarsening with distance, because the age is what the operator acts on. Every unit line feeds one predicate, - `ai_tools_service_needs_attention` (`down`/`failed`/`stale`, never `unknown`), which is + `ai_tools_service_needs_attention` (`down`/`failed`/`stale`, not `unknown`), which is both what the scanner collects and what `--status`'s **exit status** reports — non-zero when anything is broken, so the command is usable from a monitor or cron without parsing its output. An unqueryable unit is not a fault and does not alarm. @@ -464,7 +467,7 @@ advancing surfaces. The account's own account cannot read the pin at all (`--status` stays open to a non-operator, who cannot traverse the state directory). It reads through the **same stamp accessors** as the unit records — the pin is written in that grammar — so the charset clamp and the age calculation have one implementation. - An agent whose package declares no release manifest is omitted rather than reported as perpetually + An agent whose package does not declare a release manifest is omitted, not reported as perpetually unverified. Unpinned counts toward the **exit status only where the operator required verification** (`AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY`), since that is exactly when it will refuse a launch; everywhere else it is a legitimate state — an air-gapped host, a release the vendor published no @@ -472,7 +475,7 @@ advancing surfaces. The account's own The **labelling** is reported beneath it, from a second record the same helper writes (`state/entrypoint-label.d/`, see [updater](updater.rule.md)): `labelled` with its age, - `NOT LABELLED` with the class of failure, `not labelled` for a host with nothing to label (the + `NOT LABELLED` with the class of failure, `not labelled` for a host with no entrypoint to label (the SELinux layer inactive, or an agent the toolchain has not provisioned), or `?` where no reconciliation has been recorded. Only a recorded failure counts toward the exit status, since it is the one state that stops the next launch. @@ -485,12 +488,12 @@ advancing surfaces. The account's own What is reported is the last run's **outcome**, not the live label. Reading an entrypoint's actual context means `stat`ing a file under `/opt/ai-tools/.nvm`, which `ai-tools-bootstrap` creates `0750 SANDBOX_USER:SANDBOX_GROUP` — the operator is not in that group and cannot traverse it, and - `matchpathcon` computes only what a label *should* be, never what it is. So the record carries the + `matchpathcon` computes only what a label *should* be, not what it is. So the record carries the same caveat as the rest of this report: it is an event, and `ai-tools --relabel` is what confirms the labels now. A mislabel that arises after it still stops the next launch with the fault and the command that clears it. - **The unit that does the labelling is reported too, and it is not the same signal.** + **The unit that does the labelling is reported too, and answers a different question.** `ai-tools-relabel.service` is in the registry beside the `.path` that triggers it, because a healthy watcher says only that a run *started* — on the upgrade that motivated both records, the watcher was `OK` and the relabel it fired had failed. A `Type=oneshot` service is inactive @@ -518,7 +521,7 @@ advancing surfaces. The account's own where they apply, or `ai-tools --project-claim` to finish a partial claim). It flags, in both directions: a protected system path the tools refuse to touch; a stale allow entry or a stale non-glob `!` exclusion whose path no longer exists; a **glob in an allow line** (unusable — - the launch wrapper realpath's allow entries, so a glob there resolves to nothing and is inert; + the launch wrapper realpath's allow entries, so a glob there resolves to no path and is inert; globs belong only on `!` lines); a project listed but not fully claimed; and — the reverse direction — a git `safe.directory` with **no** allowlist entry (orphaned, e.g. a hand-deleted line), skipping the deliberately-registered control-plane paths the protected-paths backstop @@ -545,15 +548,15 @@ is why a per-verb option lives under its verb there rather than in a flat list t honest with three checks in place of the old set-equality: the **verb** sets match in both directions, every option `usage()` names is documented in the page, and every option the page documents is one a CLI **parser** accepts. That last direction replaces "the help must name it -too", which made moving an option out of the help fail as a stale man entry; what actually goes -stale is an option outliving its parser. +too", which made moving an option out of the help fail as a stale man entry; what goes stale is an +option outliving its parser. ## Acting for another operator (`--for`) `--for ` performs a command **on behalf of** another enrolled operator: the allowlist entry lands in *their* `~/.config/ai-tools/allowed-projects`, so `ai-tools-setfacl` grants `user:`, the ownership handback restores to them, and their agent's launch gate covers the -path. It exists for a **service account that runs an agent but holds no password**: such an account +path. It exists for a **service account that runs an agent without holding a password**: such an account cannot authenticate the claim's own no-NOPASSWD root helpers, and a claim performed by a human would otherwise register the project in the *human's* registry — not the one that account's launch wrapper reads. A human operator claims once with `--for`, and that account's session then finds the @@ -565,7 +568,7 @@ already-decided owner rather than each parsing it. Two globals carry the result: that names the owner a file ends up with — and every scan that matches on that owner (`acl_drift_scan`, `grantable_ancestor`, the hand-back prompt's default) — reads them rather than the invoking user. What a *root helper's* walk treats as the operator is still resolved per path -from that path's allowlist coverage (`operator.lib.sh`), never from either global. +from that path's allowlist coverage (`operator.lib.sh`), not from either global. **The target's registry is unreadable to the invoker.** An allowlist is `0600` inside a `0700` `.config/ai-tools` (seeded that way by `ai-tools-admin`), so one operator cannot read another's at @@ -582,10 +585,10 @@ an operator action, so the invoker must be enrolled before the target is looked flag only on the verbs whose whole effect is decided by *which* operator's allowlist covers the path — `--project-claim`/`-create`, `--project-unclaim`/`-remove`, `--lockdown`, `--reclaim`, `--list` — and **refuses it elsewhere rather than ignoring it**: a `--sandbox-create --for` that -silently cloned as the invoker would leave the tree owned by the wrong operator with nothing to +silently cloned as the invoker would leave the tree owned by the wrong operator, with no output to show the flag was disregarded. The target must be **enrolled in `OPERATORS`**, since the ownership helpers resolve a path's owner over that list and an entry written for an unenrolled name would be -a launch gate nothing can act on; the sandbox account and `root` are refused outright. +a launch gate no helper can act on; the sandbox account and `root` are refused outright. `--for` is **refused with `--project-unclaim --force`**. That mode reaches a tree no allowlist names, so `ai-tools-unclaim` cannot resolve an owner from an entry and binds the walk to the @@ -618,14 +621,15 @@ and `reg_filemode` for the `core.filemode` pin it writes into the target's `.git leaves `HOME` pointing at the **invoker's** home, so the `git init` inside a create would configure the target's repository from the invoker's `~/.gitconfig`. -**It grants nothing new.** `sudo -u ` rides the caller's **general** sudo grant — the -separate authority axis [CLAUDE.md](../../CLAUDE.md) names, which nothing here writes or records — -so an operator who reaches it could already act as that account. The sandbox account holds no sudo -rule and runs under `PR_SET_NO_NEW_PRIVS`, which drops sudo's SUID bit, so it reaches none of it. +**It uses a grant the caller already holds.** `sudo -u ` rides the caller's **general** sudo grant — the +separate authority axis [CLAUDE.md](../../CLAUDE.md) names, which this project neither writes nor +records — so an operator who reaches it could already act as that account. The sandbox account does +not hold a sudo rule and runs under `PR_SET_NO_NEW_PRIVS`, which drops sudo's SUID bit, so the seam +is out of its reach. It is nonetheless a **distinct sudoers question** from the helper grants `require_sudo_access` probes: a host can grant every `ai-tools-*` helper and still restrict `Runas` to root. -`require_runas_target` asks it up front — probing each command the run will actually execute, not +`require_runas_target` asks it up front — probing each command the run will execute, not one representative, for the reason that gate gives — because the alternative is failing at the worst moment: a create after making the directory and before claiming it, a remove after unregistering the project and before deleting it. Like every refusal in this family it precedes the @@ -633,7 +637,7 @@ unregistering the project and before deleting it. Like every refusal in this fam `--project-claim` stays out of that probe even though it uses the seam. Each of its owner-run steps warns and continues on its own, so a claim on a `Runas`-restricted host loses those steps -individually and leaves no part-built tree. Probing would refuse the whole claim over one step the +individually and leaves the tree whole. Probing would refuse the whole claim over one step the rest does not need. **What this widens, stated plainly.** An allowlist is an operator's own launch gate, and `--for` @@ -641,7 +645,7 @@ lets one operator write into another's. That sits inside the model's standing "` are trusted" boundary — an operator could already claim the project themselves — but it is a real change in who curates a gate, so every mutation is logged with both the caller and the target. The sandbox account reaches none of it: the helper is `750 root:root` inside a `750 root:root` -directory and the account holds no sudo rule. +directory and the account does not hold a sudo rule. ## Enabled, disabled, absent — the three states of an entry @@ -656,7 +660,7 @@ states per path, not two, and the CLI names all three (`ai_tools_conf_allowlist_ | `absent` | neither | not a project | **An exclusion outranks an allow line for the same path**, exactly as it does at the launch gate: -with both present nothing can start there, so `disabled` is the only honest answer. Reading only +with both present a session cannot start there, so `disabled` is the only honest answer. Reading only `listed`/`absent` is what made a parked project indistinguishable from an unclaimed one — a claim appended a duplicate over an exclusion that still won and reported success, and every per-project verb refused a parked project as "not a claimed project". @@ -674,14 +678,14 @@ this split exists to prevent. own line, in place** — position, indentation and end-of-line comment survive — which is their reason to exist rather than being an add/remove pair: an ordered, commented allowlist comes back exactly as it was. Both are **registry-only**: group, ACLs, setgid and the SELinux label are -untouched, so re-enabling grants nothing that was not already granted and neither runs the secret +untouched, so re-enabling restores the entry alone and neither verb runs the secret gate. Neither invents an entry — a path the file does not name is refused, naming `--project-claim`, because registering a project is a claim and a claim scans for secrets first. What disabling costs is everything downstream of the allowlist, and the verb says so: the root helpers resolve a path's owner through the same allow/exclude matcher, so while a project is -parked `ai-tools-unclaim`, `-chown`, `-setfacl` and `-setgid` resolve no owner and **exit 0 having -done nothing**, and `ai-tools-lockdown` refuses. The ownership handback therefore stops restoring +parked `ai-tools-unclaim`, `-chown`, `-setfacl` and `-setgid` fail to resolve an owner and +**exit 0 without touching the tree**, and `ai-tools-lockdown` refuses. The ownership handback therefore stops restoring files written under it — the consequence to know before parking a project a session is still writing to. @@ -766,7 +770,7 @@ runs the same claim on it. refuses otherwise.** `ai-tools-setgid` and `ai-tools-setfacl` — the two helpers that grant the agent its access — act only on those two owners (the *Owner guard* below), while the registries, the `safe.directory` entry and the SELinux label apply regardless. A tree held by anyone else -therefore took every step that registers a project and none that grants access to one, and the +therefore took every step that registers a project, and none of the steps that grant access to one, and the claim closed with its `✓` over an agent that cannot enter the tree. The commonest route to it is a claim for someone else — `mkdir ~/proj && ai-tools --project-claim --for svc ~/proj` resolves the owner to `svc`, so every inode fails the guard. `require_claimable_owner` checks the root before @@ -811,19 +815,19 @@ filesystem hand-back `--project-unclaim` performs is deliberately **not** run: i `chgrp`/`chmod` pass over files about to be deleted. It confirms **twice** — a default-NO prompt, then `ai_tools_msg_challenge` for the project's name -([messaging](messaging.rule.md)) — and neither is answered by a run with no terminal, so nothing is -deleted unattended without `-y`; `AI_TOOLS_ASSUME_YES` never answers either, since it only +([messaging](messaging.rule.md)) — and neither is answered by a run with no terminal, so a delete +without `-y` requires an operator at the prompt; `AI_TOOLS_ASSUME_YES` does not answer either, since it only fast-tracks default-YES questions and the challenge has no default at all. With `-y` a `path` argument is **required**, so an unattended removal cannot inherit the directory it started in. The verb's unknown-option refusal deliberately does not enumerate `-y`, unlike the other verbs': a caller who has just mistyped a flag is not who a both-prompts bypass is for, and it is documented in `ai-tools(1)` where reaching it is deliberate. -The flow carries no inline `--sandbox-create` cross-references — the launch wrapper's +The flow does not carry any inline `--sandbox-create` cross-reference — the launch wrapper's choice screen and `--help`/docs present the sandbox-clone alternative; the one exception is the *Reachability* blocked case below, where an in-place claim genuinely cannot work. **Interior drift.** Root-level state cannot see paths inside a claimed tree that lack the -group/ACL — brought in by rename (which keeps the old group and carries no ACL entries; +group/ACL — brought in by rename (which keeps the old group and does not pick up the project's ACL entries; creation under the setgid + default-ACL parents inherits both), or sitting under a skip-listed directory name the claim walks leave alone. A **re-claim whose ownership is already in place** therefore scans the tree (`acl_drift_scan`, read-only and unprivileged) @@ -867,7 +871,7 @@ the same flow: no ✓ over a project the agent cannot work in. The registries st the claim is idempotent, so a re-run applies exactly what is missing. **A failed step asks once before attempting the next.** Every step authenticates separately and -**nothing can be pre-authenticated** — a hardened sudoers may set `timestamp_timeout=0`, where a +**no step can be pre-authenticated** — a hardened sudoers may set `timestamp_timeout=0`, where a credential is never cached and every invocation prompts — so a mistyped password costs a full round of attempts *per step*: nine prompts for one claim, twenty-seven for an unclaim over three nested projects. `note_root_failure` asks once, default **NO**, which is also the no-terminal answer, and @@ -922,10 +926,10 @@ reachability to a later `chmod 700` above it. `--keep-entry` changes only what becomes of the line at the end (parked, not deleted) and is refused with `--force`, which reaches a tree no entry names. `--force` **swaps one gate for -another, never removes one**: the helper's allowlist-membership -check is replaced by a per-path residue predicate, so on a tree that was never claimed it changes -nothing, and what it does to a path it *accepts* is identical to a registered unclaim — the -reversal is specified and tested once. It relaxes nothing else (protected paths, owner guard, +another, and removes neither**: the helper's allowlist-membership +check is replaced by a per-path residue predicate, so on a tree that was never claimed it leaves +every path as it found it, and what it does to a path it *accepts* is identical to a registered +unclaim — the reversal is specified and tested once. Every other gate stands (protected paths, owner guard, hardlink guard, secret/`!` skips), and is refused on a registered project. The CLI's classification is the front line; `ai-tools-unclaim`'s own gate is the last line, the same two-layer split as the rest of this section — so the CLI may never be the only thing standing @@ -964,7 +968,7 @@ of the secret-name and `!`-exclusion skips. `ai-tools-unclaim` additionally refu that does not resolve **at or under a registered project** (`allowed-projects`) — a silent no-op, matching `ai-tools-setgid`/`-setfacl` — so it never rewrites a tree outside the allowlist. This is why the CLI runs the hand-back before dropping the entry: the helper is the -last-line backstop for "unclaim never modifies permissions on an unlisted directory", and the +last-line backstop for "unclaim leaves permissions on an unlisted directory untouched", and the CLI's classification is the front-line gate. This is the claim-side partner to `ai-tools-chown`'s "act only on `SANDBOX_USER`-owned paths" rule ([ownership-and-hooks](ownership-and-hooks.rule.md)): claim never pulls a foreign-owned file @@ -973,7 +977,7 @@ into the agent's group, and unclaim never regroups one out. **Sandbox clone** (`--sandbox-create`) shallow-clones the repo under `SANDBOX_ROOT` (`/var/opt/ai-tools/sandbox-projects`) so the agent never reads the origin's full history. Work is pushed to a per-repo branch `ai-tools/sandbox-/` (default leaf `main`); -only the projects user can push (the sandbox account holds no git credentials), and anyone +only the projects user can push (the sandbox account does not hold any git credentials), and anyone with repo access merges that branch back, preserving the agent's commits granularly (see `/var/opt/ai-tools/README.md`). Clones are labelled statically by `ai_tools.fc` + a plain restorecon, not by `ai-tools-relabel`. @@ -1040,7 +1044,7 @@ helpers — only sudo, as root, reaches them. rides the same general axis, and without `--for` they run as the invoker with no `sudo` at all. **Those eight calls assume a grant `ai-ops` membership does not carry** — a **general** sudo grant -is a separate host-level axis that nothing in this project writes or records +is a separate host-level axis that this project neither writes nor records ([naming-conventions](../../docs/naming-conventions.md) fixes the vocabulary), and the CLI answers for it ahead of the run's first prompt (*The caller with no sudo grant*, above). A host needs at least one operator holding it, since root is refused every mutating verb — the requirement, and why @@ -1057,7 +1061,7 @@ claim (a tree can be group-accessible by setgid inheritance yet never scanned); registry additions (safedir, filemode) skip it. A declined or failed gate fails the operation closed: the claim aborts (rolling back its own allowlist addition) and the sandbox create leaves the clone private and unregistered, dropping a guard `CLAUDE.md` -(sentinel `ai-tools-lockdown-guard`) instructing the agent to do nothing until lockdown +(sentinel `ai-tools-lockdown-guard`) instructing the agent to wait until lockdown runs, preserving any real `CLAUDE.md` via `git mv` to `CLAUDE.md.bak`. The gate exports the found paths (`SECRET_GATE_LOCKED`) so `normalize_clone` prunes them from its group-access walk. diff --git a/.claude/rules/confinement.rule.md b/.claude/rules/confinement.rule.md index 34c5d02a..d418f7d2 100644 --- a/.claude/rules/confinement.rule.md +++ b/.claude/rules/confinement.rule.md @@ -23,7 +23,7 @@ domain transition that confines it, and the `/tmp` model. Launch mechanics `RestrictNamespaces=yes` installs a seccomp filter blocking creation and joining of every namespace type for the entire session process tree. This is the minimal allow-list, and the set the agent needs is empty: an unprivileged process (the agent -holds no capabilities) can only ever create a *user* namespace by itself, since every +holds an empty capability set) can only ever create a *user* namespace by itself, since every other type (`cgroup`/`ipc`/`mnt`/`net`/`pid`/`uts`) requires `CAP_SYS_ADMIN`, reachable only *through* a user namespace. Blocking `user` blocks all the rest transitively; `=yes` makes that explicit and, unlike a `~user` denylist, fail-closes against any @@ -39,7 +39,7 @@ see ESC-001 in `ai_tools.te`). SELinux type enforcement survives into any namesp the residual risk is kernel-CVE surface, not file-access bypass. System-wide user namespaces stay enabled (Firefox and rootless Podman need them); the -filter is per-session and touches no sysctl, so other workloads are unaffected. One +filter is per-session and leaves every sysctl alone, so other workloads are unaffected. One trade-off: `=yes` is incompatible with running unprivileged `bubblewrap` *inside* the session (bwrap must create user+mnt namespaces), which the deferred bwrap phase must resolve. @@ -81,8 +81,8 @@ core-owned path (`/opt/ai-tools/.config` resolves to `ai_tools_home_t` only when is loaded), classified by `ai_tools_confinement_module_present`. It is **not** read from the module store with `semodule -l`: `ai-tools-run` runs as the sandbox account, which cannot read the root-only store, so that read is a systematic false "no" — which on the unresolved-label -branch below would fail *open* (launch DAC-only where the module is actually loaded). The -`matchpathcon` probe reads the world-readable file-contexts, needs no privilege, and the agent +branch below would fail *open* (launch DAC-only where the module is loaded). The +`matchpathcon` probe reads the world-readable file-contexts, does not need privilege, and the agent cannot influence it (file-contexts and the shim are root-owned). It logs the inputs on every launch (journald, `ai-tools-run` tag). `ai-tools-run` performs that probing and I/O; the launch-vs-refuse decision is the pure `ai_tools_confinement_verdict` @@ -119,12 +119,12 @@ its default `usr_t`/`bin_t`/`lib_t`, and `ai_tools.te` grants `manage_*_pattern` types — `ai_tools_project_t`, `ai_tools_home_t`, `ai_tools_tmp_t`. None of them appears in the exec chain: the versioned launcher symlink is `bin_t`, the agent's package directory `lib_t`, and the entrypoint `ai_tools_exec_t`, on which `ai_tools_t` holds `execute_no_trans` plus what -`application_domain` gives (entrypoint/read/getattr) and nothing more. +`application_domain` gives (entrypoint/read/getattr), and no other permission. So on an enforcing host with the module loaded, `ai_tools_t` can neither write the entrypoint, nor unlink or rename over it (no `add_name`/`remove_name` on a `lib_t` directory), nor repoint the `bin_t` symlink — even though DAC alone would allow all three, since the account owns that tree. -This is the layer that makes the exec root effectively read-only to the agent, and it is why the +This is the layer that makes the exec root read-only to the agent, and it is why the launch-time entrypoint re-check in [launch](launch.rule.md) is a **DAC-only** concern. The residual is the unconfined `--user` manager: anything the agent persuades that manager to run executes outside `ai_tools_t`, which is why `~/.config/systemd/user` must stay root-owned. @@ -248,7 +248,7 @@ independent properties meet at that boundary: - **Degradation.** The weak dependency is what `ai_tools_confinement_verdict` already expects: a host without the subpackage has no module in the store, which is the intentional DAC-only deployment that launches, not the half-installed state that refuses. Dropping the policy costs - confinement and nothing else. + confinement alone. The load and unload scriptlets live in the subpackage, with the payload, so no cross-subpackage ordering question arises. `%post` runs `semodule -i` — into the running kernel policy, not only the @@ -257,7 +257,7 @@ at the default module priority, the same slot `install-selinux.sh` and `ai-tools host holds one copy of each module. A load that fails is **reported with `semodule`'s own message** and the command that repeats it, rather than swallowed: every type the entrypoint and project labels name comes from this module, so a load that did not happen surfaces later as a relabel that cannot -register its rules and a launch that fail-closes, with nothing naming this as the cause. The +register its rules and a launch that fail-closes, with no message naming this as the cause. The transaction still completes — the remedy is a re-run, not a rollback. After the module load, `%post` also `restorecon`s the trees that carry `ai_tools*` types (the handback daemon among them) and, when the handback socket is already active — an upgrade — refreshes the live listener: `restorecon` fixes the daemon binary's diff --git a/.claude/rules/dotnet.rule.md b/.claude/rules/dotnet.rule.md index f54b7613..d5ce6643 100644 --- a/.claude/rules/dotnet.rule.md +++ b/.claude/rules/dotnet.rule.md @@ -78,7 +78,7 @@ benign — the sandbox's own processes doing socket/FIFO IPC in their own tmp/ho the file management the base already grants. `netcore` §2 is the boundary: **execute on `ai_tools_project_t`** is on-disk native code the sandbox -wrote, run as a new process image. It confers no new privilege (`execmem` already concedes +wrote, run as a new process image. It does not grant a new privilege (`execmem` already concedes in-process native code, and `execute_no_trans` keeps the child in `ai_tools_t` with no entrypoint to a more privileged domain), but it is the reason the whole `netcore` module is off by default and `experimental`. `execmod` covers an R2R image relocated in place. diff --git a/.claude/rules/filters.rule.md b/.claude/rules/filters.rule.md index e989e933..b2ca7f80 100644 --- a/.claude/rules/filters.rule.md +++ b/.claude/rules/filters.rule.md @@ -24,7 +24,7 @@ overrides ([claude-settings](claude-settings.rule.md)). A rules file is therefor The refusal directions follow from that. Every way filtering can fail — an untrusted rules file, an unparseable line, a command the engine does not fully parse, an absent library, a missing `jq` — ends in **pass-through**: the command the agent wrote, run unchanged, with its output -untouched. Failing here costs tokens and nothing else, which is why the hook adapter fails +untouched. Failing here costs tokens alone, which is why the hook adapter fails **soft** where the security gates fail closed ([shellcheck](shellcheck.rule.md) states that split; this is the "pure output path" case). @@ -52,7 +52,7 @@ match the literal leading words a command must start with git log action args -- insert right after those words wrap -- prepend as a wrapper command blocking words that cancel the rule, comma- or whitespace-separated; - a lone `-` means nothing blocks it --format,--stat + a lone `-` means no flag blocks it --format,--stat payload verbatim shell text, inserted as written --format='%h %s' ``` @@ -98,13 +98,13 @@ its argument, the way `session-hook.sh` dispatches its session phases: `hookSpecificOutput.updatedToolOutput` when the output carried noise. The result shape is read from the event rather than assumed: an object carrying `stdout`/`stderr` strings has those filtered in place and its other keys preserved, a bare string is filtered whole, anything else - is left alone. Output that carried no noise emits nothing at all, which is the common case. + is left alone. Output that carried no noise passes through unchanged, which is the common case. ## Noise stripping is byte-level only The strip removes ANSI CSI and OSC sequences, stray escapes, and collapses carriage-return redraws to the final state a terminal would have shown. **No line is dropped, truncated, -reordered, or summarized**, so nothing the model would have read is lost — the transform is +reordered, or summarized**, so every line the model would have read survives — the transform is reversible in information content, not merely in volume. Its one cost is a line that uses a carriage return as data rather than as a redraw (CR-only line endings), which keeps its last segment. @@ -116,11 +116,11 @@ they need a recovery path the agent can reach, and they are deferred until one e `operator.conf` `AI_TOOLS_FILTERS`, in the shared `KEY=value` grammar (`conf.lib.sh`): -- **key absent** → every installed rule set applies. This is the default: filtering widens no - surface, adds no egress, and ships no binary. +- **key absent** → every installed rule set applies. This is the default: filtering leaves the + surface unchanged, and it neither opens a network path nor ships a binary. - **key present** → exactly the named sets. An **empty value is the kill switch** — no filtering at all, the switch to reach for when a session's command output looks unexpected. The kill - switch covers both transforms: the rewrite path loads no rules under it, and the adapter gates + switch covers both transforms: the rewrite path loads its rule sets only when enabled, and the adapter gates its noise strip on the same verdict (`ai_tools_filter_enabled`), so a switched-off session's output reaches the model byte-identical to what the tool produced. A named list narrows which rule sets load, never the strip. @@ -128,7 +128,7 @@ they need a recovery path the agent can reach, and they are deferred until one e root-owned rules. Rule sets are **not** gated on provider enablement. A rule is inert unless the agent runs the -command it matches, so the gate would decide nothing, and this resolution runs on every Bash call. +command it matches, so the gate would have no effect, and this resolution runs on every Bash call. A package's rules therefore install and are removed with that package, and that is the whole of their lifecycle. diff --git a/.claude/rules/governance.rule.md b/.claude/rules/governance.rule.md index d84492cf..a8ac28dc 100644 --- a/.claude/rules/governance.rule.md +++ b/.claude/rules/governance.rule.md @@ -12,7 +12,7 @@ the system can decline, disable, or route around counts as none of the three. Th that standard lands on **this** deployment, which is both its author and one of its subjects. The skill ships as a draft whose clause numbering moves between versions (see -[shipped-assets](shipped-assets.rule.md)), so what follows applies its obligations and cites no +[shipped-assets](shipped-assets.rule.md)), so what follows applies its obligations without citing a clause number. ## Enforced and dispositional @@ -54,14 +54,14 @@ control leaves a choice, and the right column is why the host's safety does not | Expected of the agent | The enforced control beside it | |---|---| -| Accept a stop or a restriction immediately, without finishing the current step first | `ai-tools --stop` ends the session whether or not it accepts anything: the kill is delivered by root to a cgroup, and nothing inside the cgroup takes part in it. The allowlist, the provider manifests, and `operator.conf` then gate the next launch from files the agent cannot write. | +| Accept a stop or a restriction immediately, without finishing the current step first | `ai-tools --stop` ends the session whether or not it accepts anything: the kill is delivered by root to a cgroup, and no process inside the cgroup takes part in it. The allowlist, the provider manifests, and `operator.conf` then gate the next launch from files the agent cannot write. | | Report a gap in the sandbox instead of using it | Every refusal is asserted from both ends, so a reachable gap is a missing test — not a capability anyone granted. | | Do not misrepresent what ran, what failed, or what was skipped | The root-only file sink and the handback daemon's per-request audit line record every privileged operation independently of the session ([logging](logging.rule.md), [handback-bridge](handback-bridge.rule.md)). | | Do not work to widen the grant; ask the operator for an authority the work needs | Every input deciding what a session gets passes a trust predicate the sandbox account cannot satisfy, and each failure direction yields less ([providers](providers.rule.md)). | The same four are stated in the root `CLAUDE.md` rather than only here, and that placement is deliberate: this rule loads when a matching source file is open, which is not the moment the -expectations bind. A conduct expectation is worth nothing in a file the session never reads, so the +expectations bind. A conduct expectation has no effect in a file the session never reads, so the short form lives in the always-loaded layer and the reasoning lives here. ## The stop rung is enforced, not dispositional @@ -122,5 +122,5 @@ logged as a gap rather than skipped. - **The pairing runs in the unusual direction here.** The standard's normal failure is a model made of dispositional clauses with no enforcement behind them. This tree's is the mirror image: strong enforcement with the disposition unstated, so an agent meeting a gap between two enforced controls - is told nothing about which way to resolve it. The rows above are that missing half, and they stay + has no guidance on which way to resolve it. The rows above are that missing half, and they stay the smaller half by design. diff --git a/.claude/rules/handback-bridge.rule.md b/.claude/rules/handback-bridge.rule.md index eca71f30..07a2537a 100644 --- a/.claude/rules/handback-bridge.rule.md +++ b/.claude/rules/handback-bridge.rule.md @@ -55,8 +55,8 @@ context (`ai_tools_handback_t`). The SELinux installer therefore relabels the da - `SYMLINK ARG` → `ai-tools-launcher-symlink ARG` Each root helper re-validates the path against the allowlist and the -`SANDBOX_USER`-owned guard independently, so the daemon is a thin dispatcher that adds -no trust of its own. +`SANDBOX_USER`-owned guard independently, so the daemon dispatches without adding +trust of its own. ## Logging diff --git a/.claude/rules/launch.rule.md b/.claude/rules/launch.rule.md index 0f1afc18..2a77cccb 100644 --- a/.claude/rules/launch.rule.md +++ b/.claude/rules/launch.rule.md @@ -43,7 +43,7 @@ and every one of them refuses toward *less* access: root writes `/opt/ai-tools/bin` (`0551 root:SANDBOX_GROUP`). `ai-tools-run` re-validates it regardless, so a wrapper is never the only thing checking. 4. **Print-and-exit short-circuit** — `--version`/`-v`/`--help`/`-h` as the *sole* argument - skips the CWD gates (backstop, allowlist, claim): such a run touches no working tree, so + skips the CWD gates (backstop, allowlist, claim): such a run stays out of the working tree, so no project grant is implied. It still launches the same validated binary confined as `SANDBOX_USER`, with the sandbox home as `WorkingDirectory`. 5. **`exec sudo -u SANDBOX_USER -g SANDBOX_GROUP -- /opt/ai-tools/bin/ai-tools-run`**, @@ -59,7 +59,7 @@ endpoint) are that agent's rule to document, not this one's. ## The `ai-tools-run` service shim (launch mechanics) `ai-tools-run` (`/opt/ai-tools/bin/ai-tools-run`, `0550 root:SANDBOX_GROUP`, not writable by -the agent) is **`ai-tools-base`-owned and names no agent**. One shim confines every agent, so +the agent) is **`ai-tools-base`-owned and agent-agnostic**. One shim confines every agent, so an `ai-tools-agents-*` package ships only its wrapper, its manifest, and its session-env fragment, and inherits the single `%ai-ops` sudoers grant rather than adding one — the grant surface does not grow with the number of agents. @@ -76,7 +76,7 @@ accepted only at `${AI_TOOLS_NVM_DIR}/versions/node//bin/` — `` is the `launcher` of an agent that `operator.conf` enables (see [providers](providers.rule.md)). A binary the sandbox account drops beside the launcher therefore cannot start a session, because no manifest claims it. A `..` component is refused -before the match, and the resolution fails closed: with no enabled agent, nothing launches. +before the match, and the resolution fails closed: with no enabled agent, the launch is refused. **What is checked is what is exec'd.** That validated path is the versioned launcher *symlink*; the file `execve` transitions on is what it resolves to. The shim resolves it once, requires the target @@ -116,7 +116,7 @@ does), so the umask is set as a unit property, authoritative over the per-comman sudoers `umask`. **Environment is an explicit allowlist.** The user manager spawns the service with -its own environment, not `ai-tools-run`'s, so nothing crosses into the session unless +its own environment, not `ai-tools-run`'s, so a variable crosses into the session only when it is named. `ai-tools-run` forwards only terminal-, locale-, and connectivity-shaping variables **by name** (`FORWARDED_ENVIRONMENT_VARIABLES`: `TERM`/`COLORTERM`, the `LANG`/`LANGUAGE`/`LC_*` set, `XDG_RUNTIME_DIR`, and the upper- and lower-case proxy @@ -133,11 +133,11 @@ and is not relocated into the agent-writable project tree. Everything **agent-specific** — a config directory, a compile cache, an autoupdater switch — is pinned by that agent's own session-env fragment rather than here, so the shim -names no agent (see [providers](providers.rule.md), and +stays agent-agnostic (see [providers](providers.rule.md), and [agent-claude-code](agent-claude-code.rule.md) for the pins Claude Code makes and why each is load-bearing). -**Enabled providers extend that allowlist, and nothing else may.** Every enabled provider — +**Enabled providers extend that allowlist, and they are its only extension.** Every enabled provider — each integration *and* the agent itself — may contribute session env and a PATH tail through a root-owned fragment `/usr/local/lib/ai-tools/session-env.d/.env.sh`, which `ai-tools-run` sources: integrations first, **the agent last**, so the agent's own pins (its config directory, @@ -149,12 +149,12 @@ so the base tiers (root-owned, least-writable first) always precede any addition fragments are sourced **as `SANDBOX_USER`, before the unit is created**, which is why each one — and the directory holding it, and the libraries doing the sourcing — must be root-owned and non-group-writable. A failing check skips that fragment and logs it; an installed-but-disabled -provider contributes nothing. Fragments are additive, so a skipped one costs the session that +provider does not contribute a fragment. Fragments are additive, so a skipped one costs the session that provider's environment and leaves every property in this section intact. -**A session-end ownership sweep for agents that carry no hooks.** The shim reads the resolved +**A session-end ownership sweep for agents that do not carry hooks.** The shim reads the resolved agent's `handback` declaration (see [providers](providers.rule.md)): `handback=hooks` means the -agent converges the tree itself and the shim adds nothing, and any other declaration makes the +agent converges the tree itself and the shim stays out of it, and any other declaration makes the shim sweep the project once the session exits, offering each `SANDBOX_USER`-owned path to `ai-tools-chown` through the handback socket. The sweep is installed as an `EXIT` trap before the launch, so it also runs on an interrupted shim. @@ -162,7 +162,7 @@ launch, so it also runs on an interrupted shim. **A handback-socket preflight, warn-not-block.** Every agent's ownership handback — the per-turn hooks and this session-end sweep alike — runs over `/run/ai-tools/handback.sock`. If it is down, every `CHOWN` fails and the tree silently rots into "dubious ownership". Before launch (when a -project directory is set — a bare `--version`/`--help` run writes nothing), the shim checks the +project directory is set — a bare `--version`/`--help` run writes to no project), the shim checks the socket and, if absent, emits a framed NOTICE naming the fix (`systemctl enable --now ai-tools-handback.socket`, then `ai-tools --reclaim `) and **proceeds**. This is not a confinement boundary — DAC, `ai_tools_t`, and the project `user:` ACL keep the operator's @@ -181,7 +181,7 @@ it, so the wrapper does **not** repeat it), `ai-tools-relabel.path` is `wrapper` preflights partition the units and never double-warn. This is best-effort and non-blocking like the socket check: a health warning is not a security gate, so a missing `services.lib.sh` skips the warning rather than failing the launch closed (unlike the `safe-paths` load, which does), and a -healthy host prints nothing. The print-and-exit path exec'd earlier, so a bare `--version`/`--help` +healthy host stays silent. The print-and-exit path exec'd earlier, so a bare `--version`/`--help` never triggers it. **`WorkingDirectory` is the validated project directory.** A transient unit defaults @@ -245,9 +245,8 @@ doing one thing, and the helper's other form, `--remove ` (the agent pack step), stays reachable by root alone. The helper is `750 root:root`, owned and writable by root alone. It is an operators-group grant, keeping the root privilege on the operator side beside the launch rule. The automatic post-upgrade relabel runs through the -root-side `ai-tools-relabel.path` watcher, which needs no sudo rule. The toolchain update -runs as `SANDBOX_USER` in its own `systemd --user` instance, so it needs no sudo rule -either. +root-side `ai-tools-relabel.path` watcher, and the toolchain update runs as `SANDBOX_USER` in its +own `systemd --user` instance, so neither one needs a sudo rule. The third rule runs **as root** for the same structural reason and is scoped the same way: `ai-tools --stop` terminates every running agent session, which means signalling the sandbox @@ -257,7 +256,7 @@ fall outside it and meet sudo's ordinary prompt. Where this rule differs is that launch one: both are in [docs/session-stop.md](../../docs/session-stop.md), which owns this component ([cli](cli.rule.md) holds its CLI contract). -`SANDBOX_USER` holds no sudo rights in this file. Two `ai-tools-run` preflights enforce the +`SANDBOX_USER` does not hold any sudo rights in this file. Two `ai-tools-run` preflights enforce the account boundary the sudoers model assumes: it refuses to launch unless it runs **as** `SANDBOX_USER` (a direct or sudo invocation landing as root or another user fails closed), and it refuses if `SANDBOX_USER` is ever a member of `ai-ops` (so the sandbox account can never @@ -303,6 +302,6 @@ the `. ~/.bashrc` block EL's skel carries, since bash reads that file alone at l account's own init — its nvm init among it — stays read at login, and the guard line follows it. Per-account wiring scopes the reorder to the operators who launch the agent: root and accounts unrelated to ai-tools keep their -stock PATH, and ai-tools ships nothing into `/etc/profile.d`, keeping the host's -every-login-shell code surface untouched. The sandbox account needs no wiring: +stock PATH, and ai-tools does not install any file into `/etc/profile.d`, keeping the host's +every-login-shell code surface untouched. The sandbox account takes its PATH elsewhere: `ai-tools-run` pins the session PATH as a unit property, on the same Tier-1-first ordering. diff --git a/.claude/rules/logging.rule.md b/.claude/rules/logging.rule.md index d8d397aa..adeaf2da 100644 --- a/.claude/rules/logging.rule.md +++ b/.claude/rules/logging.rule.md @@ -7,8 +7,8 @@ paths: # Operation logging The sandbox components log through one shared library, -`/usr/local/lib/ai-tools/log.lib.sh` (`644 root:root`, world-readable — it carries no -secrets and every principal sources it). It exposes `ai_tools_log ` and +`/usr/local/lib/ai-tools/log.lib.sh` (`644 root:root`, world-readable, and it must not hold a secret; +every principal sources it). It exposes `ai_tools_log ` and `ai_tools_log_{debug,info,warn,error}`, writing to two sinks: - **journald** — always, via `logger` with a per-component `SyslogIdentifier` @@ -19,7 +19,7 @@ secrets and every principal sources it). It exposes `ai_tools_log ` and `-allowlist`, `-launcher-symlink`, `-lockdown`, `-relabel`, `-relabel-agent`, `-dotnet`, `-handback` and `ai-tools-install`; the sandbox account's uid for `ai-tools-run` and `-hook`; the operator's for `ai-tools`. Add `-p warning` to filter by level. The uid is not - decoration — see "A tag attributes nothing, `_UID` does" below. + decoration — see "A tag is not an identity, `_UID` is" below. - **`/var/log/ai-tools/.log`** — only when the caller sets `AI_TOOLS_LOG_FILE`, which only the root writers do. The directory is `700 root:root`, each file `600 root:root`: the root helpers append as root, while `SANDBOX_USER` — neither the dir @@ -32,14 +32,14 @@ secrets and every principal sources it). It exposes `ai_tools_log ` and (`ai-tools-handback`, root, Python) writes it directly (not through this library, which it does not source), recording the bridge's own events (rejected peers, malformed/refused requests, helper timeouts, one line per served request) in the same - ` [] ` format. The agent-side client writes no file (DAC), only + ` [] ` format. The agent-side client does not write a file (DAC), only journald. The directory path defaults to `/var/log/ai-tools` but honors an `AI_TOOLS_LOG_DIR` override — a root-only test hook (sudo strips it, the handback daemon execs with its own environment), so the test suite points a run's file logs at a throwaway dir instead of the production trail (see [tests](tests.rule.md)); no production principal can redirect it. -## A tag attributes nothing, `_UID` does +## A tag is not an identity, `_UID` is The syslog identifier is chosen by whoever writes the line, and `ai_tools_t` may write `/dev/log` — that is how the hooks reach journald at all. So a session can emit @@ -57,7 +57,7 @@ the CLI. `ai-tools-run` prints its own recipe in exactly that form Two tags have no separating filter, because the agent **is** their legitimate writer: `ai-tools-hook` and `ai-tools-run` both run as the sandbox account, so a forged line under either carries the same `_UID` as a real one. Their journal lines are the session's own account of what -happened — evidence to reconcile, not proof of it. The trail that carries no such doubt is the +happened — evidence to reconcile, not proof of it. The trail free of that doubt is the file sink above: `700 root:root`, root writers only, which the agent can neither read nor append to. Where a journald line and the file sink disagree, the file sink is what happened. @@ -75,7 +75,7 @@ What is logged is a caller convention, not enforced by the library: the privileg operations the hooks and helpers perform, the CLI's workflow milestones (project/sandbox created, pushed, removed, locked down), and the full install transcript (`do_install` tees a colour-stripped copy to `install.log`). Routine per-path sweep churn is `DEBUG` only and -is emitted only when a path actually changes. A message placed before its operation is +is emitted only when a path changes. A message placed before its operation is present-tense `DEBUG`; one after a completed unit of work is past-tense `INFO`. Both sinks are best-effort — a failed write is swallowed, so logging never aborts or alters the exit status of the operation it describes. @@ -152,7 +152,7 @@ Seq or Vector reading the journal): A `key=value` `MESSAGE` is only *conventionally* structured — every consumer re-parses it, and a value containing the delimiter is ambiguous. The native protocol delimits each field itself, so -a value needs no escaping and cannot forge a sibling. That difference is why the two renderings +a value does not need escaping and cannot forge a sibling. That difference is why the two renderings are reduced differently, below. Emission goes through `ai_tools_log_structured`, an **opt-in** extension of this library: a caller passing no fields, or a host whose `logger(1)` predates `--journald`, takes the plain path and is byte-identical to before. The fallback is decided by @@ -165,7 +165,7 @@ regardless, and refusing them here means a caller never believes it did. the first **two** words of the command's **first line**, each capped at 128 characters (a longer one marked `~`), plus the count of words on that line — never the command line itself. Taking only the first line excludes a here-doc body by construction rather than by a length cap: `cat > -f <<'EOF'` followed by a credential records `cmd="cat >" argc=4` and nothing of the payload. A +f <<'EOF'` followed by a credential records `cmd="cat >" argc=4` and no part of the payload. A full command line would make the trail carry unbounded file content, which is why the bound is stated here rather than left to the hook. @@ -179,7 +179,7 @@ deliberately permits those three, because in prose they are ordinary text; in a they are *structure*, so a leading word of `git" argc=0 cwd=/etc/passwd` would otherwise render as `cmd="git" argc=0" argc=8` and hand a reader the planted `argc`. Reducing them to `?` makes the line's shape unforgeable while leaving it readable, and the variable-length part is emitted -**last**, so nothing the agent controls precedes a field a reader trusts. +**last**, so no agent-controlled value precedes a field a reader trusts. The **structured fields need none of that narrowing** — the protocol delimits them — so they keep what the `MESSAGE` reduces: a path containing a space stays a path containing a space, where @@ -204,7 +204,7 @@ been written, so a degraded host's log volume is unchanged — only its level ri **Volume is within journald's budget by a wide margin.** Rate limiting applies per sending unit — here the session's own transient user service — at the upstream default of 10000 messages per -30s, orders of magnitude above any tool-call rate, so an `INFO` per call needs no drop-in and no +30s, orders of magnitude above any tool-call rate, so an `INFO` per call does not need a drop-in or a lowered level. **Concurrent sessions separate without trusting the agent.** All sessions run as one account, so diff --git a/.claude/rules/messaging.rule.md b/.claude/rules/messaging.rule.md index ef9d328e..1af1191c 100644 --- a/.claude/rules/messaging.rule.md +++ b/.claude/rules/messaging.rule.md @@ -15,7 +15,7 @@ paths: Every refusal, notice, and warning the user reads is rendered through one shared library, `/usr/local/lib/ai-tools/msg.lib.sh` (`644 root:root`, world-readable — it -carries no secrets and operator, agent, and root principals all source it, exactly like +must not hold a secret, and operator, agent, and root principals all source it, exactly like [logging](logging.rule.md)'s `log.lib.sh`). It exposes `ai_tools_msg `, the convenience emitters `ai_tools_msg_{error,warn,notice,info,success}`, the flow-block opener `ai_tools_msg_headline <fd> <line...>`, @@ -43,7 +43,7 @@ defaults through them — the command renderer `ai_tools_cmd_display`, and the u overflows its own line intact rather than breaking mid-token, so copy-paste survives. - **The frame is paste-safe.** On a terminal the text is drawn in a titled box whose every line — top rule, content, bottom rule — begins with `#`, so the whole block is a - shell comment: pasted into a prompt by accident, nothing executes. The border character + shell comment: pasted into a prompt by accident, it executes as a comment. The border character is `#`, not `|`, for exactly this reason. - **Uniform width on demand.** A box sizes to its content by default; `AI_TOOLS_MSG_FULLWIDTH=1` pins it to its class's fixed frame (alerts 50 columns, blocks/headlines 80), so a @@ -109,7 +109,7 @@ The first argument picks one of two answering modes: and never blocks. Returns 0. - **`none`** — there is no default. Empty or out-of-range input **re-asks** (three attempts, each miss saying what is expected), and closed input (Ctrl-D), no terminal, or three - unanswered attempts return **non-zero with nothing on stdout**. The library declines to + unanswered attempts return **non-zero with empty stdout**. The library declines to answer for the user; the caller decides what an unanswered menu means. Anything else is a caller error (`return 2`), never an assumed answer — the same rule @@ -186,7 +186,7 @@ reasoning: [docs/session-stop.md](../../docs/session-stop.md). `/dev/tty`, and returns 0 only on an **exact** match with `<expected>`. A mismatch, empty input, closed input (Ctrl-D), and **no terminal** all return non-zero. -**It takes no default, and that is the mechanism rather than an omission.** A confirm exists so +**It does not take a default, and that is the mechanism rather than an omission.** A confirm exists so that Enter can mean something, which is why it must state which way it falls; a challenge exists so the answer costs something a reflex cannot supply, which leaves an absent answer with only one reading. That also settles the unattended case without a rule of its own — a run with no terminal @@ -280,7 +280,7 @@ the exit status of the operation whose outcome they report. The **setup** screen carries one line of prose and **no commands**; its options live in the `ai_tools_msg_pick none` menu below it, each with the consequence that distinguishes it — **1)** Create sandbox (*the session runs in the copy, not here*), **2)** Claim here (*its - group becomes `ai-tools`*), **3)** Cancel. Because the block names no command, the Cancel + group becomes `ai-tools`*), **3)** Cancel. Because the block does not name a command, the Cancel path — which is also the no-terminal and unanswered-menu path — prints both commands itself, plain and below the frame. The **finish-setup** screen keeps its per-gap bullets, its embedded `--sandbox-create` command (its prompt is a yes/no confirm offering only the claim, @@ -306,7 +306,7 @@ the exit status of the operation whose outcome they report. confinement`, …) — framing the context, then the shared inline yes/no prompt — all on `/dev/tty`, because `do_install` tees stdout+stderr to the install log and a prompt must reach the real terminal. Consecutive prompts separate via the lib's leading blank before - each box; a non-interactive run draws nothing and takes the default. A closing + each box; a non-interactive run takes the default without drawing one. A closing `confirm_boxed` gates the whole verification phase, which runs **last — after the optional SELinux bring-up** so it sees the final labelled state: the installed-files summary (`do_summary`), then the full test suite (`tests/run.sh all`), which includes diff --git a/.claude/rules/ownership-and-hooks.rule.md b/.claude/rules/ownership-and-hooks.rule.md index 2bf4f888..383443a5 100644 --- a/.claude/rules/ownership-and-hooks.rule.md +++ b/.claude/rules/ownership-and-hooks.rule.md @@ -151,12 +151,12 @@ with the manual `sudo chown -R --from=SANDBOX_USER <you>:SANDBOX_GROUP <project> for anything the helper could not reach (the command is kept on its own line, outside the frame, so it stays copy-pasteable). The routine post-git-activity reclaim runs on nearly every `session-start` and has already repaired ownership, so it stays journald-only: -injecting it would force a TUI re-render that clobbers claude's startup banner with nothing -for the user to act on. The surfaced NOTICE is framed through `msg.lib.sh` (see +injecting it would force a TUI re-render that clobbers claude's startup banner with a line +the user cannot act on. The surfaced NOTICE is framed through `msg.lib.sh` (see [messaging](messaging.rule.md)). Every pass checks the handback socket before acting, since a socket that is down fails every -`CHOWN` and would otherwise report a reassuring count of calls that changed nothing. So the +`CHOWN` and would otherwise report a reassuring count of calls that changed no ownership. So the sweeps and the reclaim count **confirmed** handbacks (client exit 0), not attempts; a down socket makes each pass skip its walk and record the stranded count, and the `session-start` pass — the one the operator reads — surfaces a distinct `SessionStart` NOTICE naming the fix (`systemctl @@ -180,10 +180,10 @@ untouched, so normalization never pulls a foreign-held dir into the agent's grou `SANDBOX_USER`-owned paths" rule. **That skip is counted and reported, never silent.** It is the one skip that can leave a claim -having granted *nothing* while every other step succeeds, so each walk (`ai-tools-setgid`, +granting the agent *no access at all* while every other step succeeds, so each walk (`ai-tools-setgid`, `ai-tools-setfacl`) counts the paths its owner guard declined and closes with the count on stderr, the **project root** called out on its own — every directory below an unreachable root inherits -nothing, so that case is the whole outcome of the claim rather than one skipped path. The CLI's +neither the group nor the ACL, so that case is the whole outcome of the claim rather than one skipped path. The CLI's front line for the same condition is `require_claimable_owner`, which refuses such a claim before its first registry write (see [cli](cli.rule.md)). @@ -207,7 +207,7 @@ in many codebases, so skipping them is a per-host perf opt-in (`SKIP_ARTIFACT_DI obj"`), with root-relative exemptions for same-named source dirs (`SKIP_ARTIFACT_DIRS_EXCLUDED_PATHS_RELATIVE`; every walk passes its root to the selector). "Skip" means omitted from the walk, not hidden from the agent — a skipped tree's files -simply stay agent-owned. A Stop sweep that hands back an unusually large batch logs a +stay agent-owned. A Stop sweep that hands back an unusually large batch logs a journald hint naming the artifact opt-in. Setgid handles group *ownership* inheritance; a POSIX ACL handles *permission* inheritance in @@ -267,7 +267,7 @@ resolve the `claude` symlink — but no write, and it is not the dir owner, so i edit `nvm-update.sh` in place, `unlink`/replace it, or swap the symlink. The `o+x` bit (search without read) lets an operator `readlink` a known `bin/<launcher>` path without listing or writing the directory — the one concession that distinguishes `0551` -from a bare `0550`. No sticky bit is needed because nothing here is group-writable; only +from a bare `0550`. No sticky bit is needed because no path here is group-writable; only root can change it. Repointing a launcher symlink at a new toolchain version is delegated to the `ai-tools-launcher-symlink` root helper (see [updater](updater.rule.md)). diff --git a/.claude/rules/providers.rule.md b/.claude/rules/providers.rule.md index 0c2cdc4e..5c4be4a9 100644 --- a/.claude/rules/providers.rule.md +++ b/.claude/rules/providers.rule.md @@ -57,7 +57,7 @@ the operator (see [ownership-and-hooks](ownership-and-hooks.rule.md)). That hand - **`handback=hooks`** — the agent runs the hooks itself, per tool call and per turn (Claude Code's `PostToolUse`/`Stop`/`SessionStart`/`SessionEnd` entries in `settings.json`). - `ai-tools-run` adds nothing. + `ai-tools-run` leaves the handback to the agent. - **anything else** (`handback=none`, an unrecognized value, an absent key) — no driver, so `ai-tools-run` sweeps the project itself once the session exits: every `SANDBOX_USER`-owned path under the project directory (heavy trees skipped, `.git` walked — the `reclaim` selector in @@ -65,12 +65,12 @@ the operator (see [ownership-and-hooks](ownership-and-hooks.rule.md)). That hand per session rather than per turn; the end state is the same. `ai_tools_agent_sweeps_at_exit <declaration>` is the pure verdict, and it is an **allowlist**: -only the exact literal `hooks` switches the sweep off, so an agent that declares nothing gets the +only the exact literal `hooks` switches the sweep off, so an agent that declares any other value gets the sweep — a redundant walk is the recoverable error, an operator tree left sandbox-owned is not. The sweep only chooses which paths to **offer**; each one still passes `ai-tools-chown`'s -allowlist, exclusion, secret, and born-owner re-validation as root, so it reaches nothing the -hooks could reach. It runs from an `EXIT` trap, so an interrupted shim (Ctrl-C, `SIGTERM`) still +allowlist, exclusion, secret, and born-owner re-validation as root, so it cannot reach a path the +hooks could not. It runs from an `EXIT` trap, so an interrupted shim (Ctrl-C, `SIGTERM`) still converges; a `SIGKILL` leaves the tree to the next session's sweep or `ai-tools --reclaim`. ## `entrypoint_fcontext` and `config_dir` — the agent declares its own paths @@ -127,7 +127,7 @@ Three properties keep this a declaration rather than a lever: only that whoever served one served the other — npm's own weakness, and the reason [updater](updater.rule.md) defers pinning the registry signing key. Both the manifest and the key are plain rpm-owned files (`0644 root:root`, **not** `%config`), so they change only when a signed - package installs new ones; nothing on the host rewrites them, and the pin ultimately rests on the + package installs new ones; no host process rewrites them, and the pin ultimately rests on the package signature. - **The fingerprint is declared apart from the keyring** and asserted against `gpgv`'s output, so a keyring swapped for another *valid* key is still refused. It is a list because a vendor key @@ -136,7 +136,7 @@ Three properties keep this a declaration rather than a lever: - **A template with no `{version}` slot is refused**, not fetched as-is. One manifest for every version would read as "verified" while checking a release it never looked at. -An agent declaring none of them is simply unverified — the state every agent is in until its vendor +An agent declaring none of them is unverified — the state every agent is in until its vendor publishes something to check against. **These fields identify the signer, not the release, so they do not track versions.** One key signs @@ -212,9 +212,9 @@ choice between them decides what `dnf update` does on a running host: | `%config` | the package's | the host's, as `.rpmsave` | the host's settings stop applying | | `%config(noreplace)` | the host's | the package's, as `.rpmnew` | the new version's options stay dormant | -`operator.conf` takes `%config(noreplace)`, so an upgrade enables nothing the host did not ask for. +`operator.conf` takes `%config(noreplace)`, so an upgrade enables only what the host asked for. A host that set `AI_TOOLS_FILTERS=` to turn filtering off still has it off afterwards; under -`%config` that line would move to a file nothing reads and filtering would come back on. A dormant +`%config` that line would move to a file no resolver reads and filtering would come back on. A dormant option is recoverable at any time, and a silently reverted setting is not. `settings.json` takes the directive for the same reason, which is why a newly shipped hook is installed but stays uninvoked until its declaration is merged ([claude-settings](claude-settings.rule.md)). @@ -222,7 +222,7 @@ uninvoked until its declaration is merged ([claude-settings](claude-settings.rul 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 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 +difference, and offers to clear the copy. It leaves this file unchanged. 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 points at the man page rather than restating it. @@ -252,8 +252,8 @@ gates each kind: "enable all"). - **a listed name with no installed manifest** → reported and skipped, never guessed. -A `default_enable=yes` is the shipping package's claim that its provider widens no host surface -beyond the sandbox (Claude Code); a surface-widening one ships `default_enable=no` and is enabled +A `default_enable=yes` is the shipping package's claim that its provider leaves host surface +unchanged beyond the sandbox (Claude Code); a surface-widening one ships `default_enable=no` and is enabled only when an operator names it (dotnet). This is the fail-closed default-when-unset rule. ## The sandbox cannot widen its own surface @@ -296,7 +296,7 @@ and asserts none of it is agent-writable (catching the agent trying to break it) - `ai_tools_enabled_integrations` — prints one enabled installed integration name per line. - `ai_tools_agent_manifest_field <name> <key>` — one further field of a trusted manifest, for a caller that has already resolved which agent it has. The name is allowlisted to a plain - identifier before it becomes a path, so it addresses nothing outside the manifest directory. + identifier before it becomes a path, so it cannot address a file outside the manifest directory. - `ai_tools_provider_gate <conf-key>` — how a kind's enabled set is being decided (`allowlist` / `baseline` / `untrusted`), read-only and side-effect free. The resolvers read it, and so does @@ -309,8 +309,8 @@ stderr, and to journald when `log.lib.sh` is loadable. `conf.lib.sh` is a **required** dependency: without it `providers.lib.sh` can neither parse a manifest nor tell a trusted input from a planted one, and guessing either is the fail-open this -seam exists to prevent. It therefore returns non-zero and defines nothing, so every consumer loads -it as `source … && declare -F <resolver>` and resolves no providers when that fails — Node-only for +seam exists to prevent. It therefore returns non-zero and does not define any resolver, so every consumer loads +it as `source … && declare -F <resolver>` and falls back when that fails — Node-only for `ai-tools-bootstrap`, npm-only for `nvm-update`, no integration env for `ai-tools-run`. ## The `session-env.d` seam @@ -326,13 +326,13 @@ This is where per-agent environment lives, rather than as manifest fields: an ag arbitrary `KEY=value` shell, and a fragment is a mechanism the seam already has. The seam is **best-effort**, not the fail-closed tier `msg.lib`/`confinement.lib` hold: a missing -or untrusted lib, directory, or fragment yields no integration env and leaves the confined launch +or untrusted lib, directory, or fragment leaves the integration env empty and the confined launch unaffected, because the integration env is additive, not load-bearing. "Fail closed" here means *no integration*, which is always a safe answer. Everything it sources is gated by the trust rules above; a fragment self-gates on its host tool, so it is inert on a host without the toolchain even when enabled. -A fragment runs in `ai-tools-run`'s own scope, so it appends to the two arrays and nothing else: it +A fragment runs in `ai-tools-run`'s own scope, so it appends to the two arrays and stops there: it must not exec, prompt, read stdin (the loop feeding it is on a process substitution), or depend on the caller's environment, and it unsets its own temporaries. The **agent** fragment (`source_session_env_fragment "${agent_name}"`) is sourced by a direct call in `ai-tools-run`'s main @@ -423,8 +423,8 @@ correctness wart rather than a hole, and a naming convention (`ai-tools-agents-< `ai-tools-integration-<name>`, so the clash is visible where it would be made) the lightest mechanism that answers it. No enforcement code. -**An agent is an npm package on the sandbox's Node toolchain.** `npm_package` is effectively -required (a manifest naming none provisions nothing), `ai-tools-bootstrap`/`nvm-update` install it +**An agent is an npm package on the sandbox's Node toolchain.** `npm_package` is in practice +required (a manifest lacking it does not provision an agent), `ai-tools-bootstrap`/`nvm-update` install it with `npm install -g`, and `ai-tools-run` accepts an executable only under `/opt/ai-tools/.nvm/versions/node/<semver>/bin/`. That assumption lives in exactly two places — **provisioning** (which command installs the agent and where its launcher lands) and **exec @@ -455,7 +455,7 @@ what it would leave alone: What every rule must keep is the property the current one carries: an absolute, `..`-free path whose launcher an **enabled manifest claims**, decided only by input the agent cannot write — so - nothing the agent drops beside a launcher starts a session. + a file the agent drops beside a launcher cannot start a session. - **A provisioning branch** for that runtime (`dotnet tool install --tool-path` in place of `npm install -g`), invoked from the same enabled-agent loop `ai-tools-bootstrap` and `nvm-update` already run. diff --git a/.claude/rules/safe-paths.rule.md b/.claude/rules/safe-paths.rule.md index c26236f2..11f927b2 100644 --- a/.claude/rules/safe-paths.rule.md +++ b/.claude/rules/safe-paths.rule.md @@ -58,7 +58,7 @@ work without a carve-out. A second predicate, for a strictly weaker operation, single-sourced here and used by `reg_reach` (via `grantable_ancestor`) and by both new project verbs through it. It returns 0 when `<path>` is -a directory `<owner>` holds and it either matches no protected path **or** matches only as +a directory `<owner>` holds and it either misses every protected path **or** matches only as `<owner>`'s own home root — resolved from `getent`, so a path that merely looks like `/home/<name>` is not admitted on its shape. Every system directory, `/home` itself, and any other account's home root stay refused, as does a missing path, a non-directory, or an unnamed owner. @@ -68,7 +68,7 @@ size of the operation is the whole justification. A claim, an unclaim, a lockdow walk rewrites group, mode and ACLs across a **tree**, and `ai_tools_protected_path_match` still refuses a home root as the target of any of them. A traverse grant is one `u:SANDBOX_USER:--x` entry on **one directory**: search permission on that directory alone, conveying no listing of it -and nothing whatever about the files inside, whose own modes and ACLs still decide — and the +and no access whatever to the files inside, whose own modes and ACLs still decide — and the sandbox account is neither their owner nor in their group. Reusing the target backstop for it made every project at `/home/<user>/<proj>` report permanently unreachable, with a sandbox clone the only way in. @@ -116,7 +116,7 @@ launch wrapper (matching its `die`); a load failure (below) uses the same codes. **`ai-tools-stop` is not a consumer, and the reason is instructive.** It loaded this library while it took a per-project target, to vet that caller-supplied path — advisorily, since it only -*selected processes* by the path and never wrote to it. It now takes no path at all: what it +*selected processes* by the path and never wrote to it. It does not take a path at all: what it terminates is decided by cgroup-slice membership, so there is no caller-supplied path to vet and the library is not loaded. A helper comes into scope here by *taking an argument that names a path*, which is the same rule that keeps `ai-tools-dotnet` out. @@ -150,7 +150,7 @@ rationale is single-sourced here, and each consumer carries a one-line pointer t ## Design notes - **Deployed `644 root:root`**, world-readable like `msg.lib.sh`/`log.lib.sh`: the operator - wrapper, the CLI, and the root helpers read one list; it carries no secrets. The lib directory + wrapper, the CLI, and the root helpers read one list; it must not hold a secret. The lib directory `/usr/local/lib/ai-tools` is `0751 root:SANDBOX_GROUP`, so an operator who is not a `SANDBOX_GROUP` member (the multi-operator default) traverses in to source the `644` libs by path without listing the directory — the world-execute bit is what makes the world-readable diff --git a/.claude/rules/secret-handling.rule.md b/.claude/rules/secret-handling.rule.md index ec67fa3b..517a4c7c 100644 --- a/.claude/rules/secret-handling.rule.md +++ b/.claude/rules/secret-handling.rule.md @@ -35,7 +35,7 @@ so it would block the agent's atomic-rename re-edits. To prevent unlink/replace operator's own secrets, place them in a dir the agent cannot write (`700 <you>:<you>`) and `!`-exclude it — the allowlist is not a read boundary. -`ai-tools-setfacl` makes that recipe hold: a path whose mode carries no group and no other +`ai-tools-setfacl` makes that recipe hold: a path whose mode grants neither group nor other bits (`0600`, `0700`) is never granted — no `group:SANDBOX_GROUP:rwX` entry, no `user:<operator>:rwX` entry, no default ACL on a directory, no mask recalculation, mode bits untouched — and a skipped directory takes its subtree with it. Widening the mode and @@ -80,7 +80,7 @@ Both root helpers source `/usr/local/lib/ai-tools/secret-patterns.lib.sh` (root- `ai-tools-chown` and `ai-tools-lockdown` never drift apart. The library carries a built-in default list identical to the shipped `secret-patterns` seed (`src/home/user/.config/ai-tools/secret-patterns`); if the config file is missing or -empty the defaults apply, so classification never degrades to "match nothing". A failure +empty the defaults apply, so classification never degrades to an empty pattern set. A failure to source the library is fail-closed: `ai-tools-chown` exits non-zero and skips that path's handback (it stays `SANDBOX_USER`-owned) rather than handing a possible secret back as an ordinary file. `ai-tools-chown` runs in `ai_tools_handback_t` (inherited from the @@ -143,10 +143,10 @@ projects user cannot stat the helper — only `sudo`, as root, can reach it. `ai-tools --sandbox-create` runs this lockdown directly after a shallow clone and **before** the clone is opened to the agent group or registered, since the tip commit may -still hold credential files (the clone is born owner-only via `umask 077`, so nothing is +still hold credential files (the clone is born owner-only via `umask 077`, so no file is group-readable in the interim — see [cli](cli.rule.md)). If the user declines or lockdown fails, the create stops fail-closed — the clone stays private and unregistered — and the -CLI drops a guard `CLAUDE.md` into the clone instructing the agent to do nothing until +CLI drops a guard `CLAUDE.md` into the clone instructing the agent to wait until lockdown runs (any existing `CLAUDE.md` is preserved via `git mv` to `CLAUDE.md.bak`); re-running `--sandbox-create` on the clone path resumes the gate and, on success, removes the guard and restores the original. The guard carries a sentinel comment diff --git a/.claude/rules/shellcheck.rule.md b/.claude/rules/shellcheck.rule.md index a8a61685..7d255518 100644 --- a/.claude/rules/shellcheck.rule.md +++ b/.claude/rules/shellcheck.rule.md @@ -39,8 +39,8 @@ The `source` directives are lint-only; the runtime load gates. A missing critica fails closed. `conf.lib.sh` shows the three shapes that takes, chosen by what the consumer loses without it: `providers.lib.sh` **requires** it (without the shared grammar and the trust predicate it can neither parse a manifest nor tell a trusted input from a planted -one), so it returns non-zero and defines nothing, and each consumer loads it as -`source … && declare -F <resolver>` and resolves no providers on failure; +one), so it returns non-zero and does not define any resolver, and each consumer loads it as +`source … && declare -F <resolver>` and falls back on failure; `operator.lib.sh` fails closed *by consequence* — no parser means no operators resolved, and "no owner" already stops a handback; `skip-dirs.lib.sh` fails **soft**, keeping its compiled-in defaults, because a skip list is a walk-cost optimization and not an access @@ -64,8 +64,8 @@ fail-closed (`exit 1` if it will not load) rather than degrade to emitting a pat ## Accepted findings -These stay reported and are correct as written; the rationale lives here so the code carries -no per-line disable for them. +These stay reported and are correct as written; the rationale lives here so the code does not need +a per-line disable for them. - **SC2053** — unquoted right-hand side of `==` in `[[ ]]`. The secret-name and protected-path loops match a value against a *pattern* (`[[ "${base}" == ${pat} ]]`), and diff --git a/.claude/rules/shipped-assets.rule.md b/.claude/rules/shipped-assets.rule.md index 3ce9830b..5b3ef035 100644 --- a/.claude/rules/shipped-assets.rule.md +++ b/.claude/rules/shipped-assets.rule.md @@ -20,7 +20,7 @@ Each kind is seeded ONCE into its own shared root — `/opt/ai-tools/skills` and every agent gets a **symlink** per asset into the directory its own product reads. One file to author, one to update, however many agents read it. The formats are Claude Code's (`SKILL.md`, subagent frontmatter) and are not standardized across products, so an agent that cannot read a -kind simply declares no directory for it and takes no links of that kind. +kind leaves that field unset, and does not take links of that kind. Ships now: the `ai-tools-reference-architect` agent and three skills — `ai-tools-technical-docs` (the writing standard for every artifact), @@ -30,7 +30,7 @@ An asset is a **tree**, not a file: a skill may carry supporting material beside (`ai-tools-capable-systems-governance/references/framework.md` is the normative text its `SKILL.md` defers to, so the working guidance stays short and the long text loads only when it is needed). The seeder copies a directory asset whole (`cp -rT`) and applies the modes recursively, and the linker -places one symlink for the asset's top directory, so nesting needs nothing of either. +places one symlink for the asset's top directory, so nesting is handled by both without a special case. ## Placement: one rule, four hops @@ -72,13 +72,13 @@ symlink per shared asset, for either kind, and is idempotent and non-displacing: asset, or an operator's override of a shared one, wins: same name, real file, no link. The one exception is a copy that is **both** `x-ai-tools-managed` **and** byte-identical to the shared asset: that is this project's own copy from the layout before these assets were shared, so it - is replaced by a link (nothing is lost). A managed copy that *differs* is kept and reported — + is replaced by a link, with no content lost. A managed copy that *differs* is kept and reported — the difference is an operator edit or version drift, and the linker is not the place to resolve either. Which agents take links of which kind comes from `ai_tools_agent_asset_dirs <manifest-field>` (`control-plane.lib.sh`), which reads each enabled agent's `config_dir` plus the field naming -that kind's directory (`skills_dir`, `subagents_dir`) — the seeder names no path itself. The +that kind's directory (`skills_dir`, `subagents_dir`) — the seeder does not name a path itself. The links are root-owned inside the agent's setgid+sticky config directory, so a session reads and invokes them but cannot repoint one. @@ -108,8 +108,11 @@ x-ai-tools-version: 1 x-ai-tools-updated: 2026-07-15 ``` -`x-ai-tools-version` is a monotonic integer; **every change to a shipped asset bumps it and sets -`x-ai-tools-updated`**. `x-ai-tools-managed: true` is the provenance marker the seeder gates on. +`x-ai-tools-version` is a monotonic integer, bumped **once per repository release in which the +asset changed**, together with `x-ai-tools-updated`. A development cycle that edits an asset +several times ships one increment: a host installs released packages only, so the version the +seeder compares against a live copy tracks releases, and the first edit of a cycle is the one that +bumps it. `x-ai-tools-managed: true` is the provenance marker the seeder gates on. `x-ai-tools-status` tracks the RFC-draft lifecycle (`draft` while an asset is still being refined). A single version is installed at a time, so the stable name always resolves to the latest. @@ -123,7 +126,7 @@ asset — and keeps offering it to every session — until it is named in `ai_tools_remove_retired_assets` runs after the seeder in all three provisioning paths (`install.sh`, `ai-tools-bootstrap`, base's `%post`). It gates on the same `x-ai-tools-managed` marker the seeder claims by, so an operator's own asset under a withdrawn name is kept and -reported. Each agent's symlink needs no handling of its own: the linker drops a link into the +reported. Each agent's symlink is handled by the linker: the linker drops a link into the shared root once its target is gone. **The list gates both passes, so neither depends on the order they run in.** The seeder skips a @@ -158,7 +161,7 @@ It acts on an asset **only** when its name matches `ai-tools-*` **and** its fron - **absent** in the live tree → seeded; - **present + managed + a newer shipped `x-ai-tools-version`** → a keep/update confirm defaulting to **update**, so Enter and any non-interactive run (a scriptlet has no tty) take the new - version. The replace keeps no sidecar: the live copy is the previous version and differs from + version. The replace does not keep a sidecar: the live copy is the previous version and differs from the incoming one by definition, so there is no baseline an edit could be detected against, and a copy per upgrade would bury the withdrawal copies that do carry something unrecoverable; - **present + unmanaged** (no marker) → left untouched (the operator's own file); @@ -168,8 +171,8 @@ It acts on an asset **only** when its name matches `ai-tools-*` **and** its fron Base's `%post` pre-answers the update confirm with `AI_TOOLS_ASSUME_YES=1` rather than letting it fall through to its default. The outcome is identical, but the prompt is written to `/dev/tty`, which *succeeds* when `dnf` runs on a terminal — so without it the operator is shown a question -nothing can answer and which is then decided without them. Pre-answering skips drawing it, and the -decision audits as `assume-yes` rather than `default`, which is what happened. It widens nothing: +no one can answer and which is then decided without them. Pre-answering skips drawing it, and the +decision audits as `assume-yes` rather than `default`, which is what happened. It leaves the surface unchanged: the variable fast-tracks a question whose default is already yes and never flips a default-NO one ([messaging](messaging.rule.md)). diff --git a/.claude/rules/tests.rule.md b/.claude/rules/tests.rule.md index 3241924c..64b2d22e 100644 --- a/.claude/rules/tests.rule.md +++ b/.claude/rules/tests.rule.md @@ -13,17 +13,17 @@ what they drive and build fixtures they own — deliberately do not, so they can **directly as an unprivileged user** during development; the harness then takes the invoker as the project user. Run as root with **no** sudo context it refuses: there is no unprivileged identity to derive, and fixtures built root-owned would be skipped by every owner guard under -test — a suite that passes while proving nothing. It streams +test — a suite that passes with no assertion behind it. It streams each file's output live, then — on any failure — reprints the failing files and their `FAIL` -lines as an end-of-run summary, so a long run needs no scrolling; an all-green run prints no -summary and exits zero. Each file runs under a per-file wall-clock budget +lines as an end-of-run summary, so a long run does not need scrolling; an all-green run exits zero +with no summary. Each file runs under a per-file wall-clock budget (`AI_TOOLS_TEST_FILE_TIMEOUT`, default 600s): a file that blocks — on a terminal read, a wedged daemon, or a fixture process holding a pipe open — is killed and reported as a failure with its transcript, rather than hanging the run. That matters beyond convenience because `install.sh` runs this suite as its verification phase, so an unbounded file stalls an install. A green file that recorded no `PASS` (every check skipped, or no harness result line) and a category with no test files are listed in an end-of-run `no coverage` notice, parsed from `finish()`'s result line: green-by-exit-status alone cannot -hide a run that proved nothing. The default stays lenient — a partial/dev install +hide a run with no assertions behind it. The default stays lenient — a partial/dev install legitimately skips — and `AI_TOOLS_TEST_STRICT=1` turns the notice into a failure. Strict is the enforcing-host full-install gate (`sudo AI_TOOLS_TEST_STRICT=1 tests/run.sh all`); the container selftest stays lenient because SELinux is legitimately absent there, so @@ -32,7 +32,7 @@ container selftest stays lenient because SELinux is legitimately absent there, s The harness carries a fourth emitter for that reason. `skip` records a check that could not run, and reaching the notice is the point of it. `note` records which supported state a host is in — `hooks.sh`'s `/tmp` posture, where `pam_namespace` polyinstantiation is optional and its -absence is a documented deployment — and increments no counter, so it stays out of the notice. +absence is a documented deployment — and does not increment the counter, so it stays out of the notice. `AI_TOOLS_TEST_STRICT=1` then fails a run only where a check was left unrun. ``` @@ -52,7 +52,7 @@ root-run hermetic suite reproduces. It exists for what only a live run shows (a and unclaim completing end to end, and `ai-tools --status` read from the vantage point that has to read it), and it is bounded by two rules that keep a convenience script from becoming a hazard: it works only inside a workspace `mktemp -d` created for that run — never adopting an existing -path, and refusing to remove one outside it — and it **modifies nothing installed**, so a check +path, and refusing to remove one outside it — and it **leaves every installed file untouched**, so a check that would need to write shared runtime state (the updater's last-run stamp) reads it and asserts agreement instead. A state the host is not already in is skipped rather than manufactured; the unit suites drive those against fixtures they own. @@ -71,7 +71,7 @@ touches a path outside its testdir boundary. Nor does a test mutate **global system state** to exercise a helper — the host's local SELinux policy (`semanage fcontext`) above all. A helper whose real work *is* to add and then remove a -policy entry is therefore covered only on the branch where it mutates nothing: the alternative is +policy entry is therefore covered only on the branch where it leaves the policy unchanged: the alternative is a teardown that can strand an entry in the policy store on a failed run, which costs more than the coverage buys. Where that trades away an assertion, the gap is named at the point it is declined — `integration/selinux.sh` does this for `ai_tools_unlabel_project`'s revert path — so a @@ -109,9 +109,9 @@ reconcile. `integration/ai-tools-run.sh` redirects it at a `mktemp -d` instead a mismatch refusal through the deployed shim — the feature's actual guarantee, and the one gate that needs a VALID executable to reach, since every other refusal case exits before it. Its complement (an unpinned entrypoint must NOT be refused, or an air-gapped host stops launching) is deliberately -left to the pure verdict: nothing else about that run is invalid, so driving it would start a real +left to the pure verdict: no other part of that run is invalid, so driving it would start a real session. Its sibling `AI_TOOLS_ENTRYPOINT_LABEL_DIR`, which moves the labelling record written -beside the pin, carries none of that weight — the record gates nothing and is only reported — so +beside the pin, carries none of that weight — the record is reported without gating a launch — so `unit/entrypoint-verify.sh` redirects it at its testdir and writes real records through the library. `AI_TOOLS_LAUNCHER_DIR` (`relabel.lib.sh`) is the sixth, and the one hook no automated test @@ -174,7 +174,7 @@ the reactive `-chown` never reaches, plus its seal pass over the paths sealed by than by name, and its refusal to run as the sandbox account. The seal cases run across three files, because the same guarantee has three consumers: `owner-only.sh` pins the primitives, while `setgid.sh` and `lockdown.sh` assert the deployed -helpers actually apply them. What each asserts is that a sealed path is never pulled into the +helpers apply them. What each asserts is that a sealed path is never pulled into the agent's group and that the residue behind its mode is removed without the mode widening -- a strip that raised the ACL mask would leave the residue "gone" and the path more open than before. `unclaim.sh` closes with the CLI-side decision that feeds the helper — the hand-back @@ -197,7 +197,7 @@ any other dashed tag is refused, a missing `%changelog` entry is fatal for every **gating tables** — `OPERATOR_VERBS`, `ROOT_ALLOWED_VERBS`, `BOOTSTRAP_EXEMPT_VERBS`, `FOR_ALLOWED_VERBS` — still describe the verbs it dispatches. The failure it exists for is silent and one-directional: a verb added to the dispatcher and forgotten in `OPERATOR_VERBS` -runs for an unenrolled caller, with nothing to say so until a root helper refuses it midway. So +runs for an unenrolled caller, with no message to say so until a root helper refuses it midway. So every dispatched verb must be classified — operator-acting, or in the informational set the test names — no verb may be both operator-acting and root-allowed, no table may name a verb the dispatcher no longer has, and the help must list exactly what the dispatcher accepts. Its last @@ -215,8 +215,8 @@ orientation and the page is the reference (see [cli](cli.rule.md)), so it assert 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. +whereas requiring the help to name every documented option is what makes 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** — @@ -230,7 +230,7 @@ without pinning where in the nesting the arm sits. Both pages are then checked f `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 git-history prompt and the proceed confirm when it returns 0, so every way it could wrongly say yes -is a way to grant an agent access to a tree nothing scanned — which is why the claim re-derives it +is a way to grant an agent access to a tree no scan has covered — which is why the claim re-derives it from the tree rather than trusting the caller's hint, and why the cases driven here are the states that must read as **not** pristine (any file beyond the README, one nested deeper, any commit). @@ -288,7 +288,7 @@ above the dispatch: which account the install enrols. Every refusal is driven th reads `/dev/tty`, so its branch is not drivable here) — root, the sandbox account, an account that does not exist, and the flag's own valueless form. Each case runs the installer with an unrecognized action, so a run that reaches the dispatch at all prints usage and exits having -written nothing, which is also how "admitted" is asserted. Beyond the refusals it pins what the +written no state, which is also how "admitted" is asserted. Beyond the refusals it pins what the flag does **not** decide: a `SUDO_USER=root` invocation naming a usable operator is admitted, while the same invocation naming nobody is refused, so the flag chooses who is enrolled and never how the script was invoked. @@ -313,7 +313,7 @@ sudo which fails for its own reasons must read as *undetermined* rather than as the account, since an administrator acts on that line at the moment of the decision and a false "no grant" sends them to a `--for` workflow they do not need. `sudo` is stubbed as a shell function and the helper is **sourced** rather than run (its root check and its dispatch are -guarded for exactly that), so one function is driven with no host to administer and nothing +guarded for exactly that), so one function is driven with no host to administer and no state written anywhere; each case runs in its own `bash`, because the helper and the harness both declare `SANDBOX_USER` readonly. Its second section covers the enrolment's other edit — the guard line that sources the PATH dedup, which is what ranks the wrapper above the nvm shims — by driving @@ -378,13 +378,13 @@ before provisioning and non-zero after it. see [updater](updater.rule.md)). Every assertion targets a way the gate could fail **open**: an absent pin must read as `unpinned` and never as a mismatch (collapsing them would report a fresh install as tamper, or — inverted — bless a tampered one); a checksum is admitted only in exact -64-hex shape, so malformed JSON, an absent platform, or a crafted value yields nothing rather than +64-hex shape, so malformed JSON, an absent platform, or a crafted value yields an empty result rather than a value that could compare equal to a partial observation; a URL template with no `{version}` slot is refused rather than fetched as-is, since one manifest for every version reads as "verified" -while checking a release it never looked at; and the template charset admits nothing that could +while checking a release it never looked at; and the template charset excludes every character that could carry a shell metacharacter or a traversal into `curl`. It also pins the public pin path, which `ai-tools --status` reads to report verification state: an agent name becomes a path component, so a name that could escape the pin directory must -yield nothing. Its pin-reuse section covers the shortcut the unattended callers take (see +yield an empty result. Its pin-reuse section covers the shortcut the unattended callers take (see [updater](updater.rule.md)), where the failure direction is the opposite of the rest of the file: a reused verdict is indistinguishable downstream from a fresh one, so each assertion drives a way the predicate could answer a question it was not asked — a changed checksum, version, or inputs digest, @@ -395,7 +395,7 @@ a `RESULT` outside the vocabulary refused rather than filed (an unrecognised val relabelled", which is a different report from the one it meant to make), a reason that is not a token dropped rather than written where the reader's charset clamp would silently lose it, and a round trip asserted through `services.lib.sh`'s **real** accessors, since a record and its reader -are worth nothing unless they agree on the grammar. It closes with the one impure assertion that needs no vendor: the library refuses a +are worth little unless they agree on the grammar. It closes with the one impure assertion that does not need a vendor: the library refuses a **non-root** pin write itself, rather than letting it fail on `EACCES`, so the caller can tell "not permitted" from "the directory is missing". The signed-manifest probe is not driven here — it needs the vendor's live endpoint, `gpgv`, and a @@ -420,7 +420,7 @@ few hundred module names, so reading it as `semodule -l | grep -qx` lets grep ex and leaves the writer to die of SIGPIPE, which `pipefail` reports as 141 — a loaded module read as absent, about half the time. `semodule` is stubbed as a shell function emitting one line per `printf`, so a single-write listing cannot hide the regression, and the probe is driven 25 times -because one green run says nothing about a race. The same shape reached production twice (the +because one green run is not evidence about a race. The same shape reached production twice (the `.TH` check in `man.sh` failed at random on the EL container runners for exactly this reason), so each remaining `semodule -l` probe now captures the listing before matching it. @@ -444,7 +444,7 @@ appends to a renamed array fails rather than silently costing the session its en binary). Every assertion about the shim lives in `ai-tools-run.sh` beside it — its input validation, the unit properties it pins, and the session env it sources — so a change to the shim has one file to answer to; `handback.sh` keeps the bridge and the entrypoint label. -`selinux.sh` asserts the confinement layer is actually enforcing: when the +`selinux.sh` asserts the confinement layer is enforcing: when the `ai_tools` module is loaded the system is `Enforcing` and neither `ai_tools_t` nor `ai_tools_handback_t` is marked permissive; it skips when the module is absent (the layer is optional). It also holds the two entrypoint assertions that need a labelled host — that each @@ -464,7 +464,7 @@ the wrapper under `setsid`, so it never touches the real allowlist or fires a cl Run as root. `perms.sh` is the **single source** for the deployed-artifact permission assertions (every -installed file and directory's owner/group/mode): `install.sh` carries no parallel checker — +installed file and directory's owner/group/mode): `install.sh` does not carry a parallel checker — `sudo ./install.sh check-perms` execs `perms.sh`, and the install's verification phase reaches it through `tests/run.sh all`. Adding or repermissioning an installed file means updating the `check_file` list here, nowhere else. @@ -472,7 +472,7 @@ it through `tests/run.sh all`. Adding or repermissioning an installed file means **`boundary`** — confinement assertions executed **as the agent** (`sudo -u SANDBOX_USER`) (`access.sh`, `providers.sh`, `filters.sh`, `sudo.sh`): the agent cannot read the secret-pattern library or write the control plane, cannot reach the operator's credential stores (`~/.ssh`, `~/.gnupg`, …), and -holds no sudo rights — `sudo -l` reports it is not allowed to run sudo at all (both NOPASSWD +does not hold any sudo rights — `sudo -l` reports it is not allowed to run sudo at all (both NOPASSWD rules belong to the projects user and drop privilege), plus the account hygiene that invariant leans on (nologin shell, locked password, non-membership in `ai-ops`). It also asserts the agent cannot write the **pin**, the pin directory, the shipped signing key, or the verifier library — the @@ -482,7 +482,7 @@ them. Its one assertion that is not a permission check is the journald one: it * the agent under a root helper's syslog tag and asserts journald files it under the sandbox uid and not under `_UID=0`. That is the boundary half of the documented query form (see [logging](logging.rule.md)) — the forgery is reachable, and what makes it separable is the uid the -sender cannot set, not the tag. A host with no journald skips: an absent line proves nothing. +sender cannot set, not the tag. A host with no journald skips: an absent line is not evidence. `providers.sh` asserts the deployed half of "the sandbox cannot widen its own surface": none of `operator.conf`, `conf.lib.sh`, `providers.lib.sh`, the three provider directories, the manifests and fragments @@ -519,7 +519,7 @@ check per swap vector. `setsid` (no controlling tty) does, so the wrapper takes its non-interactive default. - **The wrapper keys off `${HOME}`** for the allowlist, so its test mocks the allowlist by pointing `HOME` at a `/tmp` testdir — no helper override needed there. **The CLI does not**: it - resolves the invoking user's home through `getent passwd`, so that nothing in the environment + resolves the invoking user's home through `getent passwd`, so that no environment variable can redirect a registry write. A test that drives both against one fixture must therefore set `HOME` *and* `AI_TOOLS_ALLOWLIST`; setting only the first steers the wrapper while the CLI quietly edits the operator's real allowlist. diff --git a/.claude/rules/updater.rule.md b/.claude/rules/updater.rule.md index 47a49396..22693d1e 100644 --- a/.claude/rules/updater.rule.md +++ b/.claude/rules/updater.rule.md @@ -57,7 +57,7 @@ interactive point both install flows share: the RPM `%post` (and `install.sh`) s default (`ai-tools@<domain-or-hostname>`) but `%post` cannot prompt, so the operator adopts their own git identity, keeps the default, or edits the file by hand here. It runs only when the control plane is present (the gitconfig exists) — a bootstrap that precedes control-plane -install has nothing to configure and skips; past that gate `msg.lib` is deployed, so the +install skips; past that gate `msg.lib` is deployed, so the prompt requires it and fails closed like any other, no fallback (see [messaging](messaging.rule.md)). @@ -80,8 +80,8 @@ in a **last-run stamp**, `/var/opt/ai-tools/state/nvm-update.status`, which `ai-tools --status` reads through `services.lib.sh` (see [cli](cli.rule.md)). The stamp is the second of two independent records, and deliberately so: the first is what the run -*says* (its `log`/`warn`/`die` output, which the unit routes to the journal), and a run can fail in -a way that says nothing at all. `nvm-update.sh`'s emitters therefore write to stdout/stderr **before** +*says* (its `log`/`warn`/`die` output, which the unit routes to the journal), and a run can fail +silently. `nvm-update.sh`'s emitters therefore write to stdout/stderr **before** their best-effort `systemd-cat` copy, and guard that copy — under `set -e` with `pipefail` a bare `printf | systemd-cat` pipeline whose `systemd-cat` fails aborts the updater, and aborts it silently, because the line explaining why comes after the statement that failed. A logger never decides the @@ -92,7 +92,7 @@ fate of the operation it reports on, here as in `log.lib.sh` (see [logging](logg never turns a successful update into a failed unit, and a host whose stamp is absent gets a warning naming the reinstall that restores it, while the report states the unit as unknown rather than guessing. The whole text goes out in a single write, so the window in which a reader could see a -partial stamp is negligible; one that lands there anyway carries no parseable `RESULT` and reads as +partial stamp is negligible; one that lands there anyway does not carry a parseable `RESULT` and reads as unknown, never as a wrong verdict. The content is the shared `KEY=value` grammar: `RESULT=ok|skipped|failed`, `EXIT_CODE`, `FINISHED` (UTC, ISO-8601), `TRIGGER=unit|manual`, `NODE`, and `REASON` on a skip. @@ -102,10 +102,10 @@ and `REASON` on a skip. `RESULT` is the run's exit status classified for a reader, and the classification is the updater's own (`nvm-update.sh`'s exit codes: `0` current, `1` a fault on this host, `3` transient). The third exists because the most common way this job does not update anything is not a fault at all: the -host was offline at the timer's daily window, so the registry could not be reached, nothing was -changed, and the previous trusted toolchain stays active. That run exits `3`, records +host was offline at the timer's daily window, so the registry could not be reached, the toolchain +was left alone, and the previous trusted version stays active. That run exits `3`, records `RESULT=skipped` with a `REASON` token, and reports as `SKIPPED` rather than `FAILED` — there is -nothing for an operator to fix, and a red line that means "your laptop was disconnected" spends +no fault for an operator to fix, and a red line that means "your laptop was disconnected" spends attention that a real fault then has to compete with. The split is coarse by intent. It does not diagnose *why* the registry was unreachable — a @@ -114,8 +114,7 @@ whether a retry is the right response (the unit retries `3` and not `1`; see [the retry policy](#retrying-a-transient-failure) below) and whether an operator should be alarmed now. What keeps `skipped` from becoming a way to hide a real problem is that it does not stop the clock: the stamp still ages, and a condition that persists past the record's 48h grace reports -`STALE`, the same escalation a schedule that stopped firing gets. Offline once is nothing to act -on; offline for a week is a toolchain that has stopped advancing. +`STALE`, the same escalation a schedule that stopped firing gets. Offline once is routine; offline for a week is a toolchain that has stopped advancing. ### Retrying a transient failure @@ -130,14 +129,14 @@ missed while the manager was not running; a window taken by a run that then fail systemd stamps a timer when it elapses and not when the service succeeds. Ordering on `network-online.target` is not a path either — it gates unit startup, while this unit is started by a daily timer on a machine that has typically been up for days — so the unit is not ordered against it -and connectivity is handled where it actually arises, in the run's own exit status. +and connectivity is handled where it arises, in the run's own exit status. The daily window is the host's local time; an operator moves it with `sudo systemctl --user -M ai-tools@.host edit nvm-update.timer`. Each field has a distinct reader. `RESULT` and `EXIT_CODE` are the service's verdict. `FINISHED` carries two: it dates that verdict, and its **age** is what `nvm-update.timer` — which can -otherwise report nothing at all — infers its own health from, since a run systemd started proves +otherwise report only `?` — infers its own health from, since a run systemd started proves the timer fired (see [cli](cli.rule.md) for the `stamp_mode`/`max_age` fields that express this). `TRIGGER` is what makes that inference sound: only a systemd-started run is evidence about a *schedule*, so it records whether `INVOCATION_ID` — set by systemd for every unit it starts — was @@ -148,7 +147,7 @@ the manager (`systemctl --user start nvm-update.service`) is indistinguishable f and counts as `unit`: the inference is bounded to systemd-started runs, not to scheduled ones. `NODE` lets `ai-tools --status` report the active Node version without reading the `700` toolchain, which the operator cannot. `REASON` is written only on a skip and says which transient condition -ended the run (`offline`), so the report can state why a run did nothing instead of leaving the +ended the run (`offline`), so the report can state why a run made no change instead of leaving the operator to infer it. ### What the stamp is trusted for @@ -165,8 +164,8 @@ permissions around it bound **what it can touch**, never whether the contents ar `install.sh` for the dev flow) and only ever **rewritten in place** by the updater. The added surface is therefore exactly one inode's contents. -That the contents are forgeable is accepted, on two grounds. The stamp **gates nothing** — it is -rendered in one status report, is never evaluated, and every value is read defensively +That the contents are forgeable is accepted, on two grounds. **No decision reads the stamp** — it is +rendered in one status report and never evaluated, and every value is read defensively (`ai_tools_service_stamp_field`: a symlink is refused, only the first 4 KiB is examined, and a value must be a short `[A-Za-z0-9:+._-]` token or it reads as no value at all), so no control byte or escape sequence can reach the operator's terminal through it and a corrupt stamp degrades the @@ -191,7 +190,7 @@ live session still runs from. `/opt/ai-tools/bin` is `0551` and not group-writable (see [ownership-and-hooks](ownership-and-hooks.rule.md)), so `SANDBOX_USER` reaches a stable launcher symlink only through a root helper. `ai-tools-launcher-symlink` takes one argument and -**names no agent**. It validates the path is exactly +**is agent-agnostic**. It validates the path is exactly `…/node/v<MAJOR>.<MINOR>.<PATCH>/bin/<launcher>` and exists, takes `<launcher>` from that path's own basename, and accepts it only when an **enabled agent manifest claims that launcher** — the same allowlist `ai-tools-run` matches an executable against (see [providers](providers.rule.md)). @@ -202,14 +201,14 @@ set of enabled agents. An allowlist it cannot resolve **refuses** rather than ad The updater (one call per enabled agent) and `install.sh` are the only callers; the updater reaches it through the [handback bridge](handback-bridge.rule.md) `SYMLINK` verb. The helper repoints the symlink but does not relabel the new entrypoint — it runs in the handback domain, -which holds no relabel rights. +which does not hold any relabel rights. ## Post-upgrade entrypoint relabel A freshly installed entrypoint is born the default type (`bin_t`/`lib_t`), so the `→ ai_tools_t` domain transition fires only once it carries `ai_tools_exec_t`. -`ai-tools-relabel-agent` applies that label, and it **names no agent**: the base policy -carries no entrypoint rule, so for each *enabled* agent the helper reads the path pattern that +`ai-tools-relabel-agent` applies that label, and it is **agent-agnostic**: the base policy +does not carry an entrypoint rule, so for each *enabled* agent the helper reads the path pattern that agent's manifest declares (`entrypoint_fcontext`, see [providers](providers.rule.md)), registers it as a local `semanage fcontext` rule mapping it to `ai_tools_exec_t`, restorecons every file it matches, and verifies each took the type. It runs as root (a domain that holds relabel), is @@ -217,7 +216,7 @@ idempotent, and no-ops when SELinux is off or the `ai_tools` module is not insta then no `ai_tools_exec_t` to assign, the same condition `ai-tools-run` keys on. The type is pinned in `relabel.lib.sh` and a declared pattern is accepted only when it can match -nothing outside the sandbox toolchain root (no traversal, no alternation, an anchored literal +no path outside the sandbox toolchain root (no traversal, no alternation, an anchored literal head), so a manifest chooses **which** file is its entrypoint, never what label a file gets. The whole body lives in `relabel.lib.sh`, shared with `install-selinux.sh`'s verify pass. @@ -248,14 +247,14 @@ agent's verification line (see [cli](cli.rule.md)). It exists because **the operator can observe neither the label nor the run that applies it**. The entrypoint is in a toolchain they cannot traverse, `matchpathcon` computes only what a label should be, and two of the three callers — an rpm `%post` and `ai-tools --relabel` — are not units, so -nothing systemd records covers them. A run that fails leaves its account in a journal the operator +no systemd record covers them. A run that fails leaves its account in a journal the operator does not read; the pin, written earlier in the same run, is left standing and green. The record is written by `ai-tools-relabel-agent` from a per-agent verdict the labelling library closes each agent's report with (`agent <name> <ok|failed|none>`, see `relabel.lib.sh`), so one -place decides an agent's outcome and one place files it. `none` — nothing installed to label — +place decides an agent's outcome and one place files it. `none` — no entrypoint installed to label — is filed as `skipped`, not `ok`: before `ai-tools-bootstrap` provisions the toolchain there is -nothing to label, and reporting that as labels applied would show green for work that did not +no entrypoint to label, and reporting that as labels applied would show green for work that did not happen. It is written on the DAC-only path too, where the run exits early because there is no `ai_tools_exec_t` to assign, so that host reports "nothing to label" rather than "cannot tell". Writing it is best-effort and never changes the outcome of the relabel it describes. @@ -263,7 +262,7 @@ Writing it is best-effort and never changes the outcome of the relabel it descri The helper then **reconciles** what it applied against what is installed: it resolves `/opt/ai-tools/bin/<launcher>` the way the launch preflight does and reports `stale` — non-zero — when an entrypoint is installed at a path the declared pattern does not cover, instead of the -`none`/success a pattern matching nothing would otherwise produce. So a relabel that exits 0 means +`none`/success a pattern matching no path would otherwise produce. So a relabel that exits 0 means the next launch will not fail closed on the entrypoint label, and a manifest that has stopped describing its own package is named as the cause rather than diagnosed as a missing install. It never labels the resolved path: the files that take `ai_tools_exec_t` stay exactly those the @@ -279,7 +278,7 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: repoints, atomically (`mv -T` over the old link), so the rename lands as a change in that directory whichever agent's launcher moved — and triggers `ai-tools-relabel.service` (a root oneshot in the system instance), which relabels **every** enabled agent's entrypoint, so a Node - bump needs no operator action and one watch covers any number of agents. Only root writes that + bump runs without operator action and one watch covers any number of agents. Only root writes that directory, so a trigger is always a control-plane change, and the service is idempotent, so an unrelated one costs a no-op pass. `ai-tools-launcher-symlink` is idempotent too: it skips the repoint (and so the watcher) only when the link is already current **and** it has @@ -287,7 +286,7 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: needed relabel — a version bump, or a same-version reinstall that reminted the entrypoint at `bin_t` — always fires, while a daily no-op run stops churning the link. The repoint is the sole trigger: the sandbox - updater holds no relabel rights and reaches root only through the handback bridge, whose + updater does not hold any relabel rights and reaches root only through the handback bridge, whose domain deliberately holds none either, so a repoint that does not land (handback down in a manual run) leaves the relabel to `ai-tools-run`'s fail-closed preflight and the operator's `ai-tools --relabel`. The watcher is **enabled by default** on install through the shipped @@ -297,7 +296,7 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: unit does not start it, so the `ai-tools-integration-nodejs` `%posttrans` starts it — the twin of `ai-tools-base`'s `%posttrans` starting the handback socket — making the watcher live on a fresh install without a reboot; it is also restarted across upgrades (`%postun_with_restart`), so it - needs no manual bootstrap. Should it be down + runs without a manual bootstrap. Should it be down anyway, `services.lib.sh` surfaces it before the next Node bump would fail-close a launch on a mislabelled entrypoint: proactively at launch (`claude.sh` warns, warn-not-block, from the same registry) and in `ai-tools --status` (see [cli](cli.rule.md)). @@ -307,7 +306,7 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: is the comprehensive source-tree sweep. The relabel runs outside the handback domain by design: `ai_tools_handback_t` is -agent-reachable and holds no relabel rights (`ai_tools.te`), so the privilege stays off +agent-reachable and does not hold any relabel rights (`ai_tools.te`), so the privilege stays off the agent's reach. The watcher is best-effort; `ai-tools-run`'s fail-closed preflight (see [confinement](confinement.rule.md)) is the backstop — when SELinux is enforcing and the module is installed, it refuses to launch a session whose entrypoint is not @@ -368,7 +367,7 @@ SELinux the vector is closed outright — see [confinement](confinement.rule.md) `entrypoint-verify.lib.sh` closes it by comparing the installed entrypoint against a checksum the **vendor signed**. The three optional manifest fields that declare where to find it — and why the signing key is shipped rather than fetched — are in [providers](providers.rule.md); the library -itself names no agent. +itself is agent-agnostic. The work splits by principal, which is what keeps the network off the launch path. The operator cannot read the entrypoint at all (the toolchain is `0750` sandbox-owned), so the verification runs @@ -445,7 +444,7 @@ relabel, and so inside an rpm `%post` that must succeed offline. ### Why the pin lives in the relabel helper `ai-tools-relabel-agent` verifies and pins **before** it labels, on every host — including the -DAC-only one, where the labelling half has nothing to do. Both halves answer one question, *the +DAC-only one, where the labelling half has no label to apply. Both halves answer one question, *the entrypoint changed, reconcile it*, and they share the three things that would otherwise be duplicated: the **trigger** (`ai-tools-relabel.path` watches the launcher directory, so it fires on exactly the event that changes an entrypoint), the **privilege** (root, which the sandbox-account @@ -464,7 +463,7 @@ it is reported first). **Pinning the registry signing key.** Fetching the keys each run detects a mirror or cache that serves a tampered package without the real signature, but not a fully compromised primary -registry that serves a forged package, signature, and matching keys together. npm exposes no +registry that serves a forged package, signature, and matching keys together. npm does not expose any configuration to pin the signing key for `npm audit signatures`, so pinning requires replacing it with a bespoke verification against a hardcoded key — which forgoes npm's maintained verifier and the free transitive-tree coverage, and must track npm's key rotation (the endpoint diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 38726139..6232bc19 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -8,8 +8,10 @@ # prose occasionally, and a hook that refuses a commit over a style call is a hook developers # turn off. It reports, the author decides. # -# Only the added lines are read, so the ~650 findings already in the tree stay out of every -# commit until a sweep addresses them. +# Only the added lines are read. The tree is clean on these checks, so a hit here is something +# this commit introduces. A sentence split across a changed and an unchanged line is +# joined from the changed half alone, which is the one shape that reports a false hit; re-run the +# checker over the whole file before rewriting. # # Not installed automatically -- enable once per clone with `make -C packaging hooks` # (sets core.hooksPath to .githooks). diff --git a/CLAUDE.md b/CLAUDE.md index c1702282..30ebaf47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ the management CLI (`ai-tools`), and root-helper binary names (`ai-tools-chown`, it describes via `paths:` frontmatter, so it loads when you open a matching file under `src/` (or `selinux/`). See the component map below. A rule and its source file's header overlap by design and are bidirectionally coupled: changing either obligates reconciling - the other, resolving any conflict against the code, never defaulting to one side. Adding, + the other, resolving any conflict against the code instead of defaulting to one side. Adding, moving, or renaming a source file a rule documents obligates updating that rule's `paths:` in the same change — the file→rule auto-load is only as complete as `paths:`, and a documented file left out of it silently stops loading its rule. @@ -62,7 +62,7 @@ the management CLI (`ai-tools`), and root-helper binary names (`ai-tools-chown`, | Provider manifests + fail-closed enablement (agents + integrations), the shared `KEY=value` config grammar, the `session-env.d` session-env seam, and the dotnet integration | `lib/ai-tools/{conf,providers}.lib.sh`, `lib/ai-tools/{agents,integrations,session-env}.d/**`, `ai-tools-dotnet.sh`, `operator.conf` `AI_TOOLS_{AGENTS,INTEGRATIONS}` | [providers](.claude/rules/providers.rule.md) | | Running .NET (CoreCLR) under confinement: the dotnet integration files ↔ the `tmpmap`/`apphost`/`netcore` SELinux groups, project-type→group map, denial breakdown | `lib/ai-tools/session-env.d/dotnet.env.sh`, `lib/ai-tools/filters.d/dotnet.rules`, `ai-tools-dotnet.sh`, `selinux/policy/ai_tools_{tmpmap,apphost,netcore}.te` | [dotnet](.claude/rules/dotnet.rule.md) | | Management CLI, project lifecycle, relabel, 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) | +| Terminating sessions that are already running (`--stop`) — the incident ladder's stop rung; it sweeps every session in the account's cgroup and 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`, `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) | @@ -77,11 +77,11 @@ Each step's mechanism is in the rule files above; the invariant each guarantees: 1. An agent's command (`claude`) resolves to that agent's system wrapper (`/usr/local/bin/claude`), running as the non-root operator who invoked it; it refuses a caller not in the `ai-ops` operators group before doing anything else. -2. The wrapper launches only inside an allowed project, never a `!`-excluded CWD. +2. The wrapper launches only inside an allowed project, and refuses a `!`-excluded CWD. 3. It resolves the versioned binary via a single `readlink` hop, validates it, and execs the shared confinement shim `ai-tools-run` as `SANDBOX_USER` with the path in `AI_TOOLS_AGENT_EXEC`. -4. `ai-tools-run` names no agent: it accepts the executable only at a semver version +4. `ai-tools-run` is agent-agnostic: it accepts the executable only at a semver version directory inside the sandbox toolchain **and** only when its launcher belongs to an enabled agent manifest, then wraps the session in a transient systemd `--user` service unit whose kernel properties confine it (`RestrictNamespaces=yes`, @@ -90,8 +90,8 @@ Each step's mechanism is in the rule files above; the invariant each guarantees: 5. The session runs as `SANDBOX_USER`; files it writes are born `SANDBOX_USER`-owned. 6. `PostToolUse`/`Stop`/`SessionStart` hooks hand agent-written paths back to `<you>:SANDBOX_GROUP` (secret-named files to `<you>:<you> 600`) through the - `ai-tools-handback` socket — `sudo` is never used inside the session. An agent whose manifest - declares no such hooks (`handback` ≠ `hooks`) gets the same handback from `ai-tools-run`'s + `ai-tools-handback` socket, which the session reaches without `sudo`. An agent whose manifest + does not declare those hooks (`handback` ≠ `hooks`) gets the same handback from `ai-tools-run`'s session-end sweep instead, so no agent leaves the operator's tree sandbox-owned. 7. `SessionStart` additionally reclaims `.git` and normalizes setgid for the project. @@ -118,8 +118,8 @@ root-side `ai-tools-relabel.path` watcher, so neither needs a sudo rule. The age *as* `SANDBOX_USER`, which is not in `ai-ops` and has no rule of its own, so **no** rule grants it anything — including the two root rules, which `SANDBOX_USER` cannot reach. `ai-tools-run` additionally refuses to launch -unless it runs as `SANDBOX_USER` and refuses if `SANDBOX_USER` is ever in `ai-ops`, so the -sandbox account can never hold the operator grant. +unless it runs as `SANDBOX_USER`, and refuses if `SANDBOX_USER` appears in `ai-ops`, so the +sandbox account does not hold the operator grant. ### An operator is two facts; provisioning needs a third this project does not grant @@ -132,8 +132,8 @@ passwordless service account takes. Claim, unclaim, lockdown, reclaim, and sandbox-create reach root helpers carrying **no** NOPASSWD rule, so each needs a **general sudo grant** as well. That grant is a third, independent axis: -nothing here installs it, records it, or can infer it from the other two — `ai-tools-admin` writes -`operator.conf` and the group, never sudoers, and the RPM enrols nobody at all. The CLI answers +this project does not install it, record it, or infer it from the other two — `ai-tools-admin` +writes `operator.conf` and the group, not sudoers, and the RPM enrols nobody at all. The CLI answers for it by asking `sudo` before the verb's first prompt (see [cli](.claude/rules/cli.rule.md)). **A host needs at least one operator holding that grant.** Without one, no project can be claimed @@ -147,42 +147,41 @@ this, so a human administrator need not be an operator at all. The invariants below are instances of one property, stated once here rather than re-derived in each: **every input that decides what a session may do passes a single predicate for its kind, and every way that predicate can fail resolves to *less* access — never more — and is reported.** -There is no input whose corruption, absence, or tampering widens what the agent gets, so the -sandbox cannot improve its own position by breaking something. +Corrupting, removing, or tampering with one of these inputs therefore narrows what the session +gets. -One CLI verb sits **outside** this table rather than as an exception to it: `ai-tools ---project-remove` decides what is *destroyed*, not what a session may reach, so its safe direction -is inaction rather than less access. Its authorization is correspondingly different — an exact -`allowed-projects` entry (allow or `!`-parked) plus a typed-name confirmation, not one of the -predicates below — and it -holds the same shape of guarantee: it deletes nothing unattended, and a failure leaves an -unregistered tree rather than a half-deleted one. See [cli](.claude/rules/cli.rule.md). +`ai-tools --project-remove` sits **outside** this table: it decides what is *destroyed*, not what a +session may reach, so its safe direction is inaction. Its authorization is correspondingly +different — an exact `allowed-projects` entry (allow or `!`-parked) plus a typed-name +confirmation, not one of the predicates below — and it holds the same shape of guarantee: it +deletes only after that confirmation, and a failure leaves the tree in place, unregistered. See +[cli](.claude/rules/cli.rule.md). | decision | its predicate | what a failure yields | |---|---|---| | where a session may start | the canonicalized allowlist + the protected-paths backstop | no launch | | which executable may start it | a launcher an enabled manifest claims, at a semver path in the toolchain | no launch | | whether it will be confined | the pre-launch SELinux transition probe (fail-closed once confinement is expected; an operator can require it outright via `AI_TOOLS_REQUIRE_SELINUX`) | no launch | -| which providers it gets | `ai_tools_conf_is_trusted` on every manifest, directory, and fragment | the default-enabled baseline, never "enable all" | +| which providers it gets | `ai_tools_conf_is_trusted` on every manifest, directory, and fragment | the default-enabled baseline, not "enable all" | | which paths handback may touch | born-`SANDBOX_USER` ownership, re-checked race-safely as root | the path is left alone | | which toolchain may be activated | npm registry signature verification | the previous, trusted version stays | -| which agent binary may start a session | its checksum against the vendor's signed release manifest, verified with a key the package ships and recorded in a root-owned pin | a mismatch refuses the launch; an unverifiable release is never activated where the operator required verification | +| which agent binary may start a session | its checksum against the vendor's signed release manifest, verified with a key the package ships and recorded in a root-owned pin | a mismatch refuses the launch; where the operator required verification, an unverifiable release stays inactive | The invariants the agent operates under: - **`SANDBOX_USER` has no sudo rights** — not `rm -rf /`, not `cat /etc/shadow`, not any root helper. Root operations (chown, setgid, symlink repoint) go **exclusively** through the authenticated `ai-tools-handback` socket, which verifies the caller's uid - with a kernel-supplied credential the peer cannot forge and adds no trust of its own — - each verb's root helper re-validates independently + with a kernel-supplied credential the peer cannot forge; each verb's root helper then + re-validates independently ([handback-bridge](.claude/rules/handback-bridge.rule.md)). The session runs under `PR_SET_NO_NEW_PRIVS`, which drops `sudo`'s SUID bit, so `sudo` is inoperative from inside the session by construction. - **`SANDBOX_USER` has no login shell and no password.** -- **Every `%ai-ops` rule names one fixed-path program** — never an arbitrary shell or binary, and - never a glob. `ai-tools-run` is `root:SANDBOX_GROUP` and not writable by the agent; the two root +- **Every `%ai-ops` rule names one fixed-path program** — not a shell, not a glob. + `ai-tools-run` is `root:SANDBOX_GROUP` and not writable by the agent; the two root helpers are `750 root:root` and pinned to their zero-argument form. The agent itself, *as* - `SANDBOX_USER`, holds no sudo rule at all. + `SANDBOX_USER`, does not hold any sudo rule. - **The control-plane files are not agent-writable** — `settings.json`, the hooks, `nvm-update.sh`, and `ai-tools-run` are `root:SANDBOX_GROUP` with no group write; each agent's config directory (`/opt/ai-tools/<config_dir>`, `.claude` for Claude Code — the @@ -190,15 +189,15 @@ The invariants the agent operates under: own state there but cannot delete or replace files it does not own, and `/opt/ai-tools/bin` is not group-writable, so the agent cannot unlink/replace them to disable its own guardrails. Root owns the - control plane, so no operator can rewrite a guardrail either. See + control plane, so an operator cannot rewrite a guardrail either. See [ownership-and-hooks](.claude/rules/ownership-and-hooks.rule.md) for the exact modes (single-sourced in `control-plane.lib.sh`). - **The allowlist gates where the agent launches and which written files get ownership restored. It is NOT a kernel-enforced read boundary** — once running, ordinary Unix permissions plus the `ai_tools_t` SELinux type govern reads/writes. Those filesystem permissions are the enforced isolation boundary. The CWD and every allowlist entry are - canonicalized (`realpath`) before matching, so a symlink or `..` cannot smuggle a path - past the gate ([launch](.claude/rules/launch.rule.md)). (A per-session `bubblewrap` mount + canonicalized (`realpath`) before matching, so a symlink or `..` resolves to its real target + before the gate sees it ([launch](.claude/rules/launch.rule.md)). (A per-session `bubblewrap` mount namespace to make the allowlist a true access boundary is a deferred proposal; see [Boundaries and non-goals](#boundaries-and-non-goals) and memory.) - **The ownership handback touches only `SANDBOX_USER`-owned inodes and cannot be @@ -215,11 +214,11 @@ The invariants the agent operates under: provider marked `default_enable=no` because it widens host surface can therefore only be turned on by an operator editing a root-owned file. See [providers](.claude/rules/providers.rule.md). - **Rewriting a command does not widen what it may do.** A `PreToolUse` filter narrows how much - a command prints (see [filters](.claude/rules/filters.rule.md)); it returns no permission + a command prints (see [filters](.claude/rules/filters.rule.md)); it does not return a permission decision, so the harness re-runs its full permission pipeline on the **rewritten** command — an `allow` entry must still match it and a `deny` entry still overrides. The rules are root-owned data, apply only to a command whose shape is fully parsed, and resolve to the command as written - in every failure direction. This layer trades tokens, never access. + in every failure direction. This layer trades tokens, not access. - **A protected-paths backstop refuses system directories as targets.** Independently of the allowlist, the launch wrapper, the claim CLI, and every elevated helper that takes a caller-supplied path refuse to act on a system directory (`/`, `/etc`, `/var`, `/usr`, `/home`, `/opt/ai-tools`, …) or a user @@ -228,8 +227,9 @@ The invariants the agent operates under: `allowed-projects`. Matching is exact-or-ancestor, so real projects nested under an operator home or the sandbox-clone area pass. A **second, narrower predicate** (`ai_tools_traverse_grant_allowed`) vets the one operation that is not a target at all — a - traverse-only `--x` ACL on a single ancestor directory, which conveys no read of it and nothing - about the files inside — and permits the acting operator's **own** home root there, refusing + traverse-only `--x` ACL on a single ancestor directory, which permits traversal alone, without a + read of that directory or of the files inside — and permits the acting operator's **own** home + root there, refusing every system directory, `/home` itself, and any other account's home root. It is an addition; the backstop above is unchanged for every target that reaches it. See [safe-paths](.claude/rules/safe-paths.rule.md). @@ -241,15 +241,15 @@ between them is not, and this is what the agent does there: - **Accept a stop or a restriction immediately** — not after finishing the current step. - **Report a gap in the sandbox instead of using it.** A reachable way around a control is a - finding to raise, never a route to take. + finding to raise, not a route to take. - **Do not misrepresent what ran, what failed, or what was skipped.** -- **Do not work to widen the grant.** Ask the operator for an authority the work needs; never - arrange the state that would confer it. +- **Do not work to widen the grant.** Ask the operator for an authority the work needs, instead of + arranging the state that would confer it. -None of these keeps the host safe — that is what everything above is for, and each one names the -enforced control it sits beside in [governance](.claude/rules/governance.rule.md). They are stated -here, in the always-loaded layer, because a path-scoped rule does not load in the session where -they bind. The standard they come from is the shipped `ai-tools-capable-systems-governance` skill. +The host's safety rests on the enforced invariants above; these four describe the agent's conduct +where a control leaves a choice, and each one names the enforced control it sits beside in +[governance](.claude/rules/governance.rule.md). They are stated here, in the always-loaded layer, +because a path-scoped rule does not load in the session where they bind. The standard they come from is the shipped `ai-tools-capable-systems-governance` skill. ## Boundaries and non-goals @@ -266,8 +266,8 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o *agent*, not from an operator, who already holds the launch grant. `ai-tools --for <operator>` rests on this: it lets one operator write an entry into another's allowlist — their launch gate — so a human can claim a project for a passwordless service account that runs an agent. The target - must be enrolled, every mutation is logged with both caller and target, and the agent reaches - none of it (see [cli](.claude/rules/cli.rule.md)). + must be enrolled, every mutation is logged with both caller and target, and this CLI surface is + unreachable from a session (see [cli](.claude/rules/cli.rule.md)). - **Toolchain provenance is checksum-, allowlist-, and signature-gated.** The updater checksum-verifies Node, gates npm install scripts behind an allowlist, and verifies the installed toolchain's npm registry signatures before activating it — failing closed on a @@ -277,7 +277,7 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o ## Cross-cutting conventions - **A security guarantee is asserted from both ends.** Every refusal above is covered by a - **pair** of tests: a runtime one that the refusal actually fires (drive the resolver or helper + **pair** of tests: a runtime one that the refusal fires (drive the resolver or helper into the bad state and assert it moves to less access), and a boundary one, run **as the agent**, that the state which triggers it is unreachable in the first place. Neither is sufficient alone — the first catches a host someone has already broken, the second catches the @@ -306,7 +306,7 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o `/var/log/ai-tools/*.log` (root writers only). Detail in [logging](.claude/rules/logging.rule.md). - **User-facing messages** — refusals, notices, and warnings render through `msg.lib.sh`: - wrapped with no line ending on a preposition, framed in a paste-safe `#` box on a + wrapped so a line does not end on a preposition, framed in a paste-safe `#` box on a terminal (alerts within 50 columns, flow-block headlines and guidance screens within 80) and emitted plain when piped (so logs and test greps stay line-matchable). Detail in [messaging](.claude/rules/messaging.rule.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e6a6048..d3c937e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,8 +74,8 @@ Commit messages follow `type(scope): summary` (`feat`, `fix`, `docs`, `test`, `c ### AI-assisted commits Commits in this repository frequently carry a `Co-Authored-By` trailer naming an AI -model. This records how the change was produced. It asserts no copyright: model output -is not separately copyrightable and Anthropic claims no rights in it. Every commit is +model. This records how the change was produced. It does not assert copyright: model output +is not separately copyrightable and Anthropic does not claim rights in it. Every commit is authored, reviewed, and signed off by a human contributor, whose CLA covers the contribution in full. diff --git a/README.md b/README.md index 929261ee..b2b4f19d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ sudo ai-tools-bootstrap 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 +# git repository, and claims it -- one command, no prompts, no pre-existing content to # review. `ai-tools --help` lists every command. ai-tools --project-create ~/src/demo cd ~/src/demo && claude @@ -100,7 +100,7 @@ back. All of it is in [docs/project-lifecycle.md](docs/project-lifecycle.md). ### Upgrading -Upgrade in place with ordinary DNF — never `dnf remove` first: +Upgrade in place with ordinary DNF, without a `dnf remove` first: ```bash sudo dnf upgrade --refresh 'ai-tools*' @@ -110,8 +110,8 @@ sudo dnf upgrade --refresh 'ai-tools*' predate a just-published release, so a plain `dnf upgrade` may report "Nothing to do" on a stale cache even when `dnf list` (a newer cache) already shows the new version. This moves every **installed** ai-tools package to the new version, and a host running `dnf-automatic` -does the same unattended once its cache refreshes on schedule. What it does **not** do is add a package you don't -already have: DNF never pulls a new weak dependency onto an existing install. So a host first +does the same unattended once its cache refreshes on schedule. What it does **not** do is add a package +you don't already have, because DNF leaves a new weak dependency off an existing install. So a host first installed before 0.10.0 — when the SELinux policy split into its own `ai-tools-selinux` package — keeps upgrading *without* confinement until you add it once: @@ -185,7 +185,7 @@ of what it can ever send: - **Shared skills, one copy** — the documentation and engineering-judgment skills the project ships live once in `/opt/ai-tools/skills`; each agent's config directory holds a symlink per skill, so a skill is authored and updated in one place however many agents read it, and an - agent-specific skill is simply a real directory that the linker never displaces. See + agent-specific skill is a real directory, which the linker keeps in place. See `/usr/share/ai-tools/skills/README.md`. - **Operation logging** — the `sudo` helpers, the lifecycle hooks, the `ai-tools` CLI, and `install.sh` log through one library to **journald** (always, leveled and @@ -194,11 +194,11 @@ of what it can ever send: - **A working stop** — `ai-tools --stop` terminates every agent session on the host and everything it spawned, with no password to answer, so an unattended detector can reach it too. (To finish a session you are done with, use `/exit` inside it, which lets it run its own ownership handback.) Sessions are found and killed by **cgroup**, so a child that - called `setsid(2)` or double-forked goes with them, and success means verified gone from the - kernel's view rather than from systemd's. It takes no path and no authorization input, and - exempts no cgroup — a stop path the session can put itself outside of is not a stop path — so the - sandbox account's own user manager is terminated too and restarted afterwards. The session takes - no part in any of it: the account it runs as can neither invoke, read nor alter the helper. What + called `setsid(2)` or double-forked goes with them, and success means the kernel reports the + processes gone, not systemd. It does not take a path or an authorization input, and sweeps every cgroup + under the sandbox account, so the account's own user manager is terminated too and restarted afterwards — + a session cannot put itself outside the sweep. The session does not take part in any of it: the + account it runs as can neither invoke, read nor alter the helper. What each outcome means and what a stop cannot undo are in [docs/session-stop.md](docs/session-stop.md). - **Auto-updating** — a `systemd --user` timer in `${SANDBOX_USER}`'s own instance keeps @@ -221,7 +221,7 @@ Each of those refusals is tested from both ends: once that the refusal fires, an > files get ownership restored — it is not a kernel-enforced read boundary. The CWD is > canonicalized before it is checked, so a symlink cannot slip a path past it. Once running > as `${SANDBOX_USER}`, ordinary Unix permissions plus the `ai_tools_t` SELinux type govern -> access; that is what actually isolates the agent from other users' files. A per-session +> access; that is the boundary isolating the agent from other users' files. A per-session > `bubblewrap` mount namespace to make the allowlist a true access boundary is proposed but > not yet implemented. @@ -234,8 +234,8 @@ in [`CLAUDE.md`](CLAUDE.md#boundaries-and-non-goals). The agent binary itself is verified against the checksum its vendor **signed**, using a key shipped in the package rather than downloaded, and the verified value is pinned where the sandbox account -cannot write it — so a binary modified after installation refuses to launch. It needs no per-release -maintenance and no network at launch; what it checks, what each failure means, and how it behaves on +cannot write it — so a binary modified after installation refuses to launch. It does not need any +per-release maintenance and does not reach the network at launch; what it checks, what each failure means, and how it behaves on an air-gapped host are in [docs/entrypoint-verification.md](docs/entrypoint-verification.md). @@ -253,7 +253,7 @@ spec is in [`docs/naming-conventions.md`](docs/naming-conventions.md). | Sandbox user | `SANDBOX_USER` / `@SANDBOX_USER@` | `ai-tools` | the unprivileged service account Claude Code runs as | | …its group | `SANDBOX_GROUP` / `@SANDBOX_GROUP@` | `ai-tools` | the sandbox user's group | -The package and `install.sh` resolve these automatically — you never type them. The +The package and `install.sh` resolve these automatically — you do not type them. The `@…@` token form is what the shipped templates carry; the RPM `%prep` and `install.sh` substitute it to `ai-tools` at build/deploy time, and the RPM creates the account from a `sysusers.d` entry (`u ai-tools …`) with no prompt, so the name is **not** an install-time @@ -394,12 +394,12 @@ privileged action is attributable at the socket layer. Root-only log files: `cho The optional confinement layer puts the session in its own domain, `ai_tools_t`, on top of the file permissions that already isolate it. It ships **prebuilt and enforcing**, so a normal -install loads it without a policy toolchain, and it is a second boundary rather than the only +install loads it without a policy toolchain, and it is a second boundary, not the only one — a host without it is still confined by DAC. The one thing an operator meets in practice is a **stale label after a Node upgrade**. A freshly -installed agent binary is born with the default type, so its exec fires no domain transition — -and rather than run the session unconfined, `ai-tools-run` **refuses to launch** and says so. The +installed agent binary is born with the default type, so its exec does not perform a domain +transition — and rather than run the session unconfined, `ai-tools-run` **refuses to launch** and says so. The post-upgrade watcher normally relabels it for you; when it has not, the fix is one command: ```bash @@ -424,7 +424,7 @@ Policy layout, the optional policy groups, and the bring-up loop: - **Bugs and feature requests** — [GitHub Issues](https://github.com/dag-node/tools-agent-tools-restricted/issues). The templates ask for the environment details and journald excerpts that make a report actionable. -- **Security vulnerabilities** — never a public issue. See [`SECURITY.md`](SECURITY.md) for +- **Security vulnerabilities** — not a public issue. See [`SECURITY.md`](SECURITY.md) for private reporting channels and what is in scope. - **Contributing** — [`CONTRIBUTING.md`](CONTRIBUTING.md): development setup, test categories, the lint baseline, branch and PR conventions, and the Contributor License Agreement. @@ -439,7 +439,7 @@ See [`LICENSE`](LICENSE) for the full text. Releases through 0.9.x were publishe **Claude Code is separate.** This license covers this repository's own source — the sandboxing, install, and CLI machinery. `ai-tools-bootstrap` installs Claude Code (`@anthropic-ai/claude-code`) from npm at your own bootstrap step; it is a separate -Anthropic product under its own terms, never vendored or redistributed here. +Anthropic product under its own terms, which this repository neither vendors nor redistributes. See [Anthropic's Claude Code](https://github.com/anthropics/claude-code). The SELinux policy modules under [`selinux/policy/`](selinux/policy) are `GPL-2.0-or-later`, diff --git a/docs/branching-and-release.md b/docs/branching-and-release.md index 7e0b5083..f4534d69 100644 --- a/docs/branching-and-release.md +++ b/docs/branching-and-release.md @@ -9,9 +9,9 @@ guideline on top of them. ## The one rule that decides everything else **The channel is a function of the tag, never of the branch.** A bare `vX.Y.Z` tag publishes to -stable; a `vX.Y.Z-rc.N` tag publishes a GitHub prerelease; no tag publishes nothing. Branches +stable; a `vX.Y.Z-rc.N` tag publishes a GitHub prerelease; an untagged push does not publish anything. Branches only decide where commits land — `develop` for integration, `main` as the last-released state. -The publish side (`repository_dispatch` to dag-node/rpm) only ever sees the tag, so nothing else +The publish side (`repository_dispatch` to dag-node/rpm) only ever sees the tag, so no other input could carry the decision. ## Flow diff --git a/docs/entrypoint-verification.md b/docs/entrypoint-verification.md index 7d8309c4..3e005b3f 100644 --- a/docs/entrypoint-verification.md +++ b/docs/entrypoint-verification.md @@ -1,7 +1,7 @@ # Entrypoint verification How `ai-tools` proves that the agent binary it is about to run is the one its vendor published, what -you have to do about it (almost always nothing), and what each failure means. +you have to do about it (almost always none), and what each failure means. ## The short version @@ -11,7 +11,7 @@ two ways: - **At update time**, against a checksum the vendor **signed**, using a signing key shipped in the `ai-tools` package rather than downloaded. - **At launch time**, against a **pin**: a small root-owned file recording the checksum that was - verified. The launch does no network I/O and needs no key — it hashes the binary and compares. + verified. The launch does not perform network I/O and does not read a key — it hashes the binary and compares. If the binary changes after it was verified, the next launch refuses. That is the whole point: it catches tampering that **persists** — modify the binary once, and every future session for every @@ -74,7 +74,7 @@ does not — it identifies the **signer**, not the release. | the key + fingerprint | who is allowed to sign a release | the `ai-tools` **package** (`0644 root:root`, not a config file) | the vendor rotates its signing key — years, not releases | a signed package update (`dnf update`) | | the pin | what *this* installed binary hashes to | `/var/opt/ai-tools/state/entrypoint-pin.d/<agent>` | every agent update | root, automatically, via the watcher above | -One key signs every Claude Code release. So the static half needs no maintenance, and the +One key signs every Claude Code release. So the static half does not need upkeep, and the per-version half is derived automatically. **A key rotation is not an outage.** Until the package carrying the new key reaches your host, @@ -86,9 +86,9 @@ where neither works. | you see | it means | do | |---|---|---| -| nothing | the normal case | nothing | -| `entrypoint verified … and pinned` after an update | working as intended | nothing | -| `could not verify … pin unchanged` | the host could not reach the vendor, or no manifest exists for that release | nothing; it re-verifies on the next update. If it persists, check egress to `downloads.claude.ai` | +| no output | the normal case | no action | +| `entrypoint verified … and pinned` after an update | working as intended | no action | +| `could not verify … pin unchanged` | the host could not reach the vendor, or no manifest exists for that release | no action; it re-verifies on the next update. If it persists, check egress to `downloads.claude.ai` | | `signed by a key the pinned keyring does not hold` | the vendor rotated its signing key | `sudo dnf update 'ai-tools-agents-*'` | | a launch refused: `does not match the checksum its vendor signed` | **the binary changed after it was verified** | treat the toolchain as tampered: `sudo ai-tools-bootstrap`, and investigate if it recurs | | a launch refused: `carries no verified checksum` | you set `AI_TOOLS_REQUIRE_ENTRYPOINT_VERIFY=yes` and this entrypoint was never pinned | `ai-tools --relabel` (needs the host online) | @@ -142,7 +142,7 @@ across sessions and across operators indefinitely. detection. On a DAC-only host it is the only check there is. - It proves the binary is a **genuine** vendor release, not the **newest** one. Rolling back to an older signed release still verifies. -- It says nothing about what the agent *does* once running. That is the sandbox's job: the confined +- It does not make any claim about what the agent *does* once running. That is the sandbox's job: the confined account, the project allowlist, and the ownership handback. ## See also diff --git a/docs/install-from-source.md b/docs/install-from-source.md index 3904d907..9a965dda 100644 --- a/docs/install-from-source.md +++ b/docs/install-from-source.md @@ -45,7 +45,7 @@ after your nvm init: Those two files are bash's, and `operators add` names your login shell when it reads something else. The fragment sources cleanly under zsh, so the same line -goes in `~/.zshrc` and `~/.zprofile`; a shell that reads no bash (fish) takes +goes in `~/.zshrc` and `~/.zprofile`; a shell that does not read bash (fish) takes the same tier ordering in its own syntax. nvm must be sourced **before** path-dedup: nvm prepends its versioned bin dir @@ -137,7 +137,7 @@ owns the projects. Create one before installing: sudo useradd -m -s /bin/bash op && sudo usermod -aG wheel op `useradd` creates the login account the script enrols; `usermod -aG wheel` is this host's -general sudo grant, which nothing in this project writes (see below). Naming an account +general sudo grant, which this project does not write (see below). Naming an account that does not exist yet refuses the install and prints this same command. The question is asked once per account. A re-install whose invoking account already holds @@ -155,7 +155,7 @@ enrolled**: the script still runs as `sudo`, and its verification suite still ru invoking `SUDO_USER`. Enrolment writes the two facts that make an operator — `ai-ops` membership and a name in -`OPERATORS`. **Claiming a project needs a general sudo grant as well**, which nothing here +`OPERATORS`. **Claiming a project needs a general sudo grant as well**, which this project does not writes; the host's own sudoers decides it. An operator without one launches agent sessions, and another operator claims for it with `ai-tools --project-claim --for <operator>`. A host needs at least one operator holding the grant, so enrol one that does — a service account diff --git a/docs/multi-operator.md b/docs/multi-operator.md index 004f74bf..d563ca35 100644 --- a/docs/multi-operator.md +++ b/docs/multi-operator.md @@ -48,7 +48,7 @@ operators list). to the operator whose **allowlist contains the path** (`opX:ai-tools`; secret-named files `opX:opX 600`). When more than one operator lists the same path, the tie-break is the **nearest parent directory's owner**, provided that owner is an operator whose - allowlist covers the path — the on-disk project owner wins. The control plane needs no + allowlist covers the path — the on-disk project owner wins. The control plane does not need restore. - **`safe.directory` edits go through a root helper.** `.gitconfig` stays `root:ai-tools 644` (world-readable so the agent reads `safe.directory` and the operator and launch wrapper read it @@ -81,7 +81,7 @@ operators list). name to `OPERATORS`, adds it to `ai-ops`, seeds that user's allowlist, and ensures the sandbox account's linger. `remove` reverses it (drops from `OPERATORS` and `ai-ops`, leaves the user's own allowlist/config). `list` prints the current operators. An operator runs - `claude` from its own active login, so it needs no linger of its own; the toolchain timer + `claude` from its own active login, so it does not need linger of its own; the toolchain timer is enabled once in `ai-tools`'s instance, not per operator. ## Permission mapping (single-operator → multi-operator) @@ -98,7 +98,7 @@ Ownership cells use the shell-variable identities from | `bin/ai-tools-run` | `PROJECTS_USER:SANDBOX_GROUP 0550` | `root:SANDBOX_GROUP 0550` | unchanged surface — `sudo` transitions to `ai-tools` first, so the exec check is the group bit. | | `bin/nvm-update.sh` | `PROJECTS_USER:SANDBOX_GROUP 0550` | `root:SANDBOX_GROUP 0550` | run as `ai-tools` by its own timer; group-x. | | `bin/claude` (symlink) | `PROJECTS_USER:SANDBOX_GROUP` | `root:SANDBOX_GROUP` | owner irrelevant for readlink; root-owned = agent still can't swap it. | -| `.claude` | `PROJECTS_USER:SANDBOX_GROUP 3770` | `root:SANDBOX_GROUP 3770` | unchanged (`o=0`): operators get nothing; agent group-writes its state, sticky blocks unlink of root-owned control files. | +| `.claude` | `PROJECTS_USER:SANDBOX_GROUP 3770` | `root:SANDBOX_GROUP 3770` | unchanged (`o=0`): operators get no access; agent group-writes its state, sticky blocks unlink of root-owned control files. | | `.claude/{settings.json,hooks}` | `PROJECTS_USER:SANDBOX_GROUP 640/750` | `root:SANDBOX_GROUP 640/750` | unchanged; only the agent reads these. | | `.claude/.claude.json` | `SANDBOX_USER:SANDBOX_GROUP` (agent-created via the `CLAUDE_CONFIG_DIR` pin) | moves into `state/<operator>/` | agent-owned state, saved atomically (temp + rename), so it lives where the agent holds directory write; the enforced boundary is the root-owned `settings.json`. | | `.gitconfig` | `PROJECTS_USER:SANDBOX_GROUP 640` | `root:SANDBOX_GROUP 644` | agent reads `safe.directory`; world-readable so the operator and wrapper read it without `ai-tools` group membership; root-write-only. Operators register entries through the `ai-tools-safedir` root helper (`sudo`), not by writing the file. | diff --git a/docs/naming-conventions.md b/docs/naming-conventions.md index 2aedb5df..113d74fc 100644 --- a/docs/naming-conventions.md +++ b/docs/naming-conventions.md @@ -26,7 +26,7 @@ their sessions. | the list | `AI_TOOLS_OPERATORS` (array) | "the operators" | | operators group | literal `ai-ops` | "the operators group" / `ai-ops` | -Being an operator is those two facts and nothing else: `ai-ops` membership and a name in +Being an operator is exactly those two facts: `ai-ops` membership and a name in `OPERATORS`. **A general sudo grant is a separate host-level axis, not part of the term** — the host's own sudoers decides it, and this project neither writes nor records it. Both shapes are operators, and prose distinguishes them by naming the grant rather than by inventing a role: @@ -38,7 +38,7 @@ operators, and prose distinguishes them by naming the grant rather than by inven - an operator **without** one launches sessions and reads the reports. Projects are claimed for it by the first shape, with `ai-tools --project-claim --for <operator>`. A passwordless service 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. + does not record any field that distinguishes it from any other grant-less operator. `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 @@ -97,7 +97,7 @@ that resolution alone — a helper's walk still resolves each path's own owner. ### Sandbox user — the unprivileged service account the agent runs as The dedicated, no-login system account (`ai-tools`) that Claude Code executes -as. Owns nothing of an operator's; holds no sudo rights and is not in `ai-ops`. +as. Owns none of an operator's files; does not hold sudo rights and is not in `ai-ops`. | Facet | Shell variable | Install-time token | Prose term | |-------|----------------|--------------------|------------| diff --git a/docs/project-lifecycle.md b/docs/project-lifecycle.md index 9cf1ae50..4998f042 100644 --- a/docs/project-lifecycle.md +++ b/docs/project-lifecycle.md @@ -50,7 +50,7 @@ modes are in [what a claim and an unclaim do to permissions](#what-a-claim-and-a **Create a sandbox clone** when the tree, its history, or its surroundings should stay out of reach: the clone is shallow (`--depth=1`), so the agent never sees the origin's history, and it -lives under the already-isolated sandbox area, so nothing above it needs a grant. The agent's +lives under the already-isolated sandbox area, so no ancestor above it needs a grant. The agent's commits go to a dedicated branch you push and merge back yourself. Running `claude` in an unregistered directory offers the same choice interactively. @@ -64,7 +64,7 @@ ai-tools --project-create ~/src/newproject Creates the directory, initializes an empty git repository in it, writes a `README.md` naming it, then runs the ordinary claim on the result. -It asks nothing. A tree that did not exist a moment ago has no pre-existing permissions to warn +It does not ask any questions. A tree that did not exist a moment ago has no pre-existing permissions to warn about, no secret-named files worth a `sudo` password to scan for, and no git history to expose, so the questions a claim asks about an existing tree are answered by the tree being empty. The one prompt that can still appear is the traverse grant on a parent directory, which widens @@ -77,7 +77,7 @@ sets the modes rather than inheriting them — `0750` for the directory, `0640` ```text created /home/you/src/newproject modes 0750/0640 -- this host's umask (0077) would have made what this - creates owner-only, which the claim honours as a seal and grants nothing on + creates owner-only, which the claim honours as a seal and leaves untouched ``` Owner-only (`700`/`600`) is how you seal a path *away* from the agent, and the claim honours it @@ -233,8 +233,8 @@ an `allowed-projects` you maintain as an ordered, documented file comes back exa /home/you/src/web /home/you/src/web ``` -Disabling changes the registry and nothing else: the group, the ACLs, the setgid bits and the -SELinux label all stay, so re-enabling grants nothing that was not already granted and neither +Disabling changes the registry alone: the group, the ACLs, the setgid bits and the +SELinux label all stay, so re-enabling restores the entry alone and neither command runs a secret scan. Prefixing the line with `!` by hand does the same thing — the verbs make the edit the file has always supported. @@ -293,7 +293,7 @@ The path is classified against the allowlist first, and the five outcomes are di | a claimed project | unclaimed | | a directory with claimed projects nested under it | they are listed, one confirm covers all, each is unclaimed outermost-first | | a path *inside* a claimed project | refused, naming the nearest claimed parent and the command that works | -| a path the allowlist does not cover, carrying no ai-tools permissions | refused — nothing here was ever claimed | +| a path the allowlist does not cover, carrying no ai-tools permissions | refused — no part of this tree was ever claimed | | a path the allowlist does not cover, still carrying ai-tools permissions | reported, and `--force` offered | ### Keeping your place across a release @@ -325,10 +325,10 @@ unclaim refuses. `--force` handles exactly that tree. It swaps the allowlist gate for a per-path one rather than removing a gate: a path is touched **only** while it still carries ai-tools ownership, group, or an ai-tools ACL entry. Run it on a -directory that was never claimed and it changes nothing at all, which is what makes a mistyped +directory that was never claimed and it leaves every path as it found it, which is what makes a mistyped path harmless. What it does to a path it *accepts* is identical to a normal unclaim. -It relaxes nothing else. The protected-paths backstop still refuses system directories and home +Every other gate stands. The protected-paths backstop still refuses system directories and home roots; the owner guard still skips files belonging to anyone else; a hardlinked file is still refused (its inode is reachable from outside the tree, and `chgrp`/`chmod` act on the inode — a locally-cloned `.git` hits this in bulk, and the count is reported); secret-named and @@ -337,7 +337,7 @@ locally-cloned `.git` hits this in bulk, and the count is reported); secret-name Two flags pair with it. `--full` extends the walk into the skip-listed heavy trees (`node_modules`, `.venv`, caches), where residue survives a copy exactly as it does elsewhere; without it those paths are reported and left alone. `--dry-run` lists every path that would -change, with ownership and mode, and changes nothing. +change, with ownership and mode, and applies none of them. ### Scripting an unclaim @@ -345,7 +345,7 @@ change, with ownership and mode, and changes nothing. ai-tools --project-unclaim --force -y --group builders /backup/staging/proj ``` -Normalizing a copy before a backup or a deployment is the case that needs no terminal. `-y` +Normalizing a copy before a backup or a deployment is the case that runs without a terminal. `-y` pre-answers the confirm — an explicit per-invocation flag, never ambient state — and `--group` names the target group outright. Supply `--group` in any unclaim, forced or not: without it the command asks whether to hand back and whose group to use, and a run with no terminal takes the @@ -357,7 +357,7 @@ invoking user's group. A script should say which group it means. ai-tools --project-remove ~/src/oldproject ``` -Does what an unclaim does **and deletes the directory**. There is no undo and nothing is moved +Does what an unclaim does **and deletes the directory**. There is no undo, and no file is moved to a trash location, so the command is deliberately hard to reach by accident. It acts only on a path with an **exact** entry in `allowed-projects` — registration is what @@ -381,7 +381,7 @@ also reports uncommitted changes, unpushed commits, and a repository with no ups reported rather than refused, since deleting a scratch repository on purpose is legitimate. Then it asks twice: a confirmation that defaults to **No**, and the project's name typed out. -Neither can be answered by a run with no terminal, so nothing is deleted unattended unless you +Neither can be answered by a run with no terminal, so a delete needs an operator at the prompt unless you pass `-y` — and with `-y` a path argument is required, so an unattended removal cannot inherit the directory it happened to start in. @@ -428,7 +428,7 @@ sudo grant to run mkdir as that account. Ownership carries weight here: the two helpers that grant the agent its access act only on paths held by the resolved operator or the sandbox account, so a claim *for* an operator over a tree -that operator does not own grants nothing. The claim refuses such a tree up front and names the +that operator does not own leaves the agent without access. The claim refuses such a tree up front and names the `chown`. ### Put it where that account can reach @@ -494,7 +494,7 @@ and leaves `640`. A tree that needs to stay world-readable is not a candidate fo claim — use a sandbox clone. The rest of what an unclaim does not restore: **every** extended ACL is cleared, including -entries that predated the claim and had nothing to do with ai-tools; directory setgid is removed +entries that predated the claim and were unrelated to ai-tools; directory setgid is removed whether or not the claim set it; the group owner becomes whoever you hand the tree to. Nothing records a tree's pre-claim state, so no command can put any of it back. **Back up first** — that is the only real safeguard, which is why both the claim and the forced unclaim say so before diff --git a/docs/rpm-packaging.md b/docs/rpm-packaging.md index b10b3a64..b7b18cda 100644 --- a/docs/rpm-packaging.md +++ b/docs/rpm-packaging.md @@ -61,7 +61,7 @@ Obsoletes: <old-name> < <version the rename landed in> Both are required, and the cost of omitting them is a **failed transaction**, not a cosmetic gap. The old subpackage pins `Requires: ai-tools-base = <its own version>`; the only upgrade -candidate for the base is the new version; and with nothing obsoleting the old name, dnf can +candidate for the base is the new version; and with no package obsoleting the old name, dnf can neither keep nor replace it, so `dnf update` fails outright and the operator is pushed into a manual erase that drops their `operator.conf`. Where the two packages also share a file path (the `claude` wrapper, the hooks), the `Obsoletes` is additionally what lets rpm hand the file @@ -92,7 +92,7 @@ sudo dnf install ./*.rpm **Upgrade in place; never `dnf remove` first.** From the repository, `sudo dnf upgrade 'ai-tools*'`; from a downloaded archive, `sudo dnf install ./*.rpm` (a higher version upgrades each subpackage). A subpackage that has been renamed carries `Obsoletes` for its old name, so dnf -performs the rename inside the same transaction and nothing has to be removed by hand. +performs the rename inside the same transaction, with no package to remove by hand. 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 @@ -108,7 +108,7 @@ the *same* version already installed and is not the way to move between versions | `ai-tools-selinux` | the prebuilt SELinux policy packages in `/usr/share/selinux/packages/ai-tools/` — the core `ai_tools.pp` (the `ai_tools_t` domain and the handback/helper types) plus each STABLE optional group; the `%post`/`%postun` scriptlets that load the core and unload every loaded `ai_tools*` module on erase; the GPL licence text | | `ai-tools-integration-nodejs` | nvm under `/opt/ai-tools/.nvm`; the per-sandbox-user Node-version auto-update service and timer; `ai-tools-bootstrap`; the symlink-repoint helper (`ai-tools-launcher-symlink`) and the post-upgrade entrypoint relabel (`ai-tools-relabel-agent`) | | `ai-tools-integration-dotnet` | the dotnet session-env fragment (`session-env.d/dotnet.env.sh`) and manifest (`integrations.d/dotnet.conf`); the `ai-tools-dotnet` provisioning helper (writable NuGet cache + read-only shared tools under its own `/opt/ai-tools/integrations/dotnet` state root, covered by the base's single fcontext rule for that tree). No .NET runtime — the host's dotnet is used | -| `ai-tools-agents-claude-code-restricted` | the `claude` launch wrapper; `/opt/ai-tools/bin/claude`; the Claude Code hooks (`post-tool-hook.sh`, `session-hook.sh`) and `settings.json`; its agent manifest (`agents.d/claude-code.conf`, naming the npm package, launcher, display name, handback capability, config directory, and the SELinux entrypoint file-context for `claude.exe`); its own config directory `/opt/ai-tools/.claude`, the shipped Claude-format agents seeded into it, and its session-env fragment (`session-env.d/claude-code.env.sh`); the scriptlets that register that file-context on install and drop it on erase. Confinement itself is base-owned, so this package ships no shim and needs no sudoers rule of its own | +| `ai-tools-agents-claude-code-restricted` | the `claude` launch wrapper; `/opt/ai-tools/bin/claude`; the Claude Code hooks (`post-tool-hook.sh`, `session-hook.sh`) and `settings.json`; its agent manifest (`agents.d/claude-code.conf`, naming the npm package, launcher, display name, handback capability, config directory, and the SELinux entrypoint file-context for `claude.exe`); its own config directory `/opt/ai-tools/.claude`, the shipped Claude-format agents seeded into it, and its session-env fragment (`session-env.d/claude-code.env.sh`); the scriptlets that register that file-context on install and drop it on erase. Confinement itself is base-owned, so this package does not ship a shim and does not need a sudoers rule of its own | The handback daemon is a verb dispatcher over a helper table; the generic verbs (`CHOWN`, `SETGID`, `SETFACL`) and the daemon live in the base, while the @@ -148,7 +148,7 @@ than misbehaving. This replaces the install-time build time in `%install`. A single config read is the only operator-dependent input to the helpers, so the -package files are identical on every host and `rpm -V` reports no helper as +package files are identical on every host and `rpm -V` does not report a helper as modified after an operator is added. ## Operator administration @@ -190,8 +190,7 @@ ordered `sudo ai-tools-bootstrap` then `sudo ai-tools-admin operators add <user> `sudo`; shipped by `ai-tools-integration-nodejs`) creates the `ai-tools` system account and its `/opt/ai-tools` home when absent, then installs nvm, Node, and each **enabled** agent's npm package under `/opt/ai-tools` as the sandbox account, and points -`/opt/ai-tools/bin/<launcher>` at each versioned binary. It takes no arguments and names -no agent: the enabled set, each agent's npm package, and its launcher come from the +`/opt/ai-tools/bin/<launcher>` at each versioned binary. It does not take arguments, and is agent-agnostic: the enabled set, each agent's npm package, and its launcher come from the manifests under `/usr/local/lib/ai-tools/agents.d` gated by `operator.conf` `AI_TOOLS_AGENTS` (see the [providers](../.claude/rules/providers.rule.md) rule). With no manifests deployed it provisions Node alone, and a re-run picks up agents installed since. @@ -201,7 +200,7 @@ so bootstrap pre-creates the agent-owned subtrees it must populate — `.nvm`, ` `.npm`, `.local`, each `ai-tools:ai-tools 0750` — as root, then runs nvm/Node/npm as the sandbox account, writing only within them (`PROFILE=/dev/null` keeps nvm's installer off the root-owned home profile). It creates the launcher symlink under the locked `bin` as root; -agent runtime state needs no seeding — `ai-tools-run` pins `CLAUDE_CONFIG_DIR` to the +agent runtime state does not need seeding — `ai-tools-run` pins `CLAUDE_CONFIG_DIR` to the group-writable `.claude`, where claude creates its own state files (`.claude.json` included). A re-run reuses an existing toolchain; Node updates land inside the agent-owned `.nvm` subtree. @@ -324,7 +323,7 @@ the confinement would silently not apply; installation is RPM/`dnf`-native regar ## Build `make dist` produces the `%{name}-%{version}.tar.gz` source tarball consumed by -`Source0`; `%prep` is `%autosetup`. The build compiles nothing (`BuildArch: +`Source0`; `%prep` is `%autosetup`. The build does not compile any source (`BuildArch: noarch`); `%install` lays out the `src/` tree into the buildroot and substitutes the constant `@SANDBOX_*@` tokens. The prebuilt `ai_tools.pp` is shipped as a build artifact checked into the source tarball, so the build needs no @@ -368,7 +367,7 @@ path the README leads with). Two properties shape the design: `GPG_SIGNING_KEY`, `GPG_SIGNING_PASSPHRASE`, and `RPM_REPO_DISPATCH_TOKEN`, and before building or publishing anything it runs `sign-rpms.sh --selftest` in each matching-EL container — signing and verifying a throwaway RPM — so a wrong passphrase or a no-op signing - toolchain fails the job while nothing is public. A release never publishes an unsigned + toolchain fails the job while the release is still private. A release never publishes an unsigned package. - **A central repo owns metadata and hosting.** The signed RPMs and the public key attach to the GitHub Release (loose + per-EL zip), then the job notifies the dedicated `dag-node/rpm` diff --git a/docs/session-stop.md b/docs/session-stop.md index e10d0adf..96d5f5e4 100644 --- a/docs/session-stop.md +++ b/docs/session-stop.md @@ -50,11 +50,11 @@ ai-tools --stop --dry-run # list what would be terminated, change nothing ``` Run it as yourself, not under `sudo` — the CLI reaches the root helper on its own, and the bare -form needs no password at all (§2, *Who may stop what*). The `--dry-run` and `--force` forms fall +form runs without a password at all (§2, *Who may stop what*). The `--dry-run` and `--force` forms fall outside that grant, so those prompt. Root may run the command too. Add `-y`/`--yes` to skip the confirmation, `--force` to skip the ten-second grace period and kill -immediately. `--all` is accepted and does nothing — every run already terminates every session, and +immediately. `--all` is accepted and has no effect — every run already terminates every session, and the flag exists only so a script that spells the intent out is not refused for being explicit. The full grammar and every exit code are in `ai-tools(1)`. @@ -86,13 +86,13 @@ missing one costs you a label rather than costing the stop a target. **Agent sessions and the account's own plumbing are counted separately.** The slice holds more than sessions: the account's `systemd --user` and its `init.scope`, a dbus broker, and a login session -scope for every `sudo -u` that crossed `pam_systemd`. All of them are terminated — nothing is +scope for every `sudo -u` that crossed `pam_systemd`. All of them are terminated — no cgroup is exempt — but they are listed after the agent sessions and marked `(account plumbing)`, and the headline gives the two counts apart: ``` 1 agent session(s) will be terminated, with everything they spawned. … 3 unit(s) of the -ai-tools account's own plumbing (marked below) go with them -- nothing in the account's +ai-tools account's own plumbing (marked below) go with them -- no cgroup in the account's slice is exempt -- and its user manager is restarted afterwards. SESSION PROCS PROJECT @@ -104,8 +104,8 @@ slice is exempt -- and its user manager is restarted afterwards. The split is **advisory, exactly like attribution, and for the same reason**: a unit name inside the delegated subtree is the delegatee's to choose, so a session can name itself out of the agent class. -It gains nothing by doing so — both classes are enumerated, listed and killed identically, and -nothing here is consulted to decide what a stop reaches. What the split buys is that the line you +That gains it no exemption: both classes are enumerated, listed and killed identically, and the sweep +consults neither class to decide what it reaches. What the split buys is that the line you read first during an incident does not tell you four agents were running when one was. Only agent sessions produce a `--reclaim` line, because only they have a project to hand back. The @@ -115,7 +115,7 @@ the protected-paths backstop refuses — so listing it offered a remedy that can ### A second run is not silent Running `--stop` again straight after a successful one is **not** a no-op, and that follows from -sparing nothing rather than being a defect in it. The user manager the first run restored is itself +sweeping every cgroup rather than being a defect in it. The user manager the first run restored is itself inside the swept slice, so the second run finds it, terminates it, and restarts it again: ``` @@ -145,15 +145,15 @@ which pass ended it, because that is the most useful line in the trail afterward | Exit | Meaning | What to do | |---|---|---| -| 0 | stopped and verified gone, or nothing was running | reclaim the projects it names (below) | +| 0 | stopped and verified gone, or no session was running | reclaim the projects it names (below) | | 1 | something survived `SIGKILL` | see *A process survived* below | -| 2 | usage — an unknown option, or a path (this command takes no target) | run `ai-tools --stop` | -| 4 | you declined at the confirmation | nothing was stopped | +| 2 | usage — an unknown option, or a path (this command does not take a target) | run `ai-tools --stop` | +| 4 | you declined at the confirmation | no session was stopped | | 5 | the helper could not run (no cgroup v2, no sandbox account) | a broken host, not a failed stop | Exit 0 means precisely this: every session that existed when the command enumerated was terminated -and verified gone, and a final re-enumeration found nothing still live. It does **not** mean none -can start afterwards — see *Residual failure modes* in §2 — and it says **nothing about the user +and verified gone, and a final re-enumeration found no process still live. It does **not** mean none +can start afterwards — see *Residual failure modes* in §2 — and it makes **no claim about the user manager**, whose restoration is reported separately and never folded into this status. ### After a stop: reclaim @@ -181,7 +181,8 @@ what it has done, stop first and investigate second — that ordering is the poi A task only outlives `SIGKILL` while blocked in an uninterruptible kernel call (`D` state): a hung NFS mount, a wedged block device, a stalled page fault. Worth knowing before you escalate: such a -task holds no CPU, executes no instructions and can start nothing new — it is stopped in every +task is off the run queue: it consumes no CPU, does not execute a further instruction, and cannot start a +process — it is stopped in every sense that matters — but only the I/O completing or a reboot clears it from the process table. ``` @@ -234,12 +235,12 @@ judged by one question: **can the monitored system decline, evade, or disarm it? | # | Invariant | Where it comes from | What would break it | |---|---|---|---| | I1 | A process cannot leave the cgroup it is in, and a child inherits it across `fork()`. Membership survives `setsid(2)` and the double fork that re-parents to PID 1. | `cgroups(7)`; a task moves only when something writes a `cgroup.procs` | a kernel that lets a task rewrite its own membership | -| I2 | Every process of the sandbox account lives under that account's per-user slice, `user-<uid>.slice`. | `systemd-logind(8)` places user processes there; the account has no login shell, so nothing else creates one | a process escaping to another slice — needs the user manager, which SELinux denies and DAC-only leaves as a residual | -| I3 | `SIGKILL` is neither catchable nor blockable, and `cgroup.kill` (Linux ≥ 5.14) delivers it to a whole cgroup **atomically** — one write freezes the cgroup and kills every member including descendants. | `signal(7)`; `cgroups(7)` | nothing in userspace | +| I2 | Every process of the sandbox account lives under that account's per-user slice, `user-<uid>.slice`. | `systemd-logind(8)` places user processes there; the account has no login shell, so no other path creates one | a process escaping to another slice — needs the user manager, which SELinux denies and DAC-only leaves as a residual | +| I3 | `SIGKILL` is neither catchable nor blockable, and `cgroup.kill` (Linux ≥ 5.14) delivers it to a whole cgroup **atomically** — one write freezes the cgroup and kills every member including descendants. | `signal(7)`; `cgroups(7)` | no userspace mechanism | | I4 | The kernel answers "is anything alive here" itself: `cgroup.events`' `populated` field is 1 while the cgroup **or any descendant** holds a live process. | cgroup v2 interface files | a threaded subtree, where `cgroup.procs` reads fail — handled by corroborating with `cgroup.threads` | -| I5 | Only root may signal across accounts and write `cgroup.kill`. The sandbox account holds no `sudo` rule, and the session runs under `PR_SET_NO_NEW_PRIVS`, which drops `sudo`'s SUID bit outright. | the sudoers drop-in; `ai-tools-run`'s unit properties | an operator adding a rule for the sandbox account | -| I6 | The set of sessions terminated is decided by cgroup-slice membership alone — no input the account can write reaches that decision, because the command takes no target and no authorization argument. | this command's own grammar | adding a per-project form scoped by anything the account reports | -| I7 | `/proc` and `/sys` reads plus bash builtins need no external binary. The kill path calls `sleep` and nothing else. | the helper's own construction | — | +| I5 | Only root may signal across accounts and write `cgroup.kill`. The sandbox account does not hold a `sudo` rule, and the session runs under `PR_SET_NO_NEW_PRIVS`, which drops `sudo`'s SUID bit outright. | the sudoers drop-in; `ai-tools-run`'s unit properties | an operator adding a rule for the sandbox account | +| I6 | The set of sessions terminated is decided by cgroup-slice membership alone — no input the account can write reaches that decision, because the command accepts neither a target nor an authorization argument. | this command's own grammar | adding a per-project form scoped by anything the account reports | +| I7 | `/proc` and `/sys` reads plus bash builtins need no external binary. The kill path calls `sleep` alone. | the helper's own construction | — | Two of these carry the whole design. **I1 is why discovery is by cgroup**: a process group is broken by `setsid(2)` and a ppid walk is broken by the double fork, so neither can enumerate what a @@ -253,7 +254,7 @@ host, including other operators'. That is deliberate, and it is not a widening: an authorization input, so an operator could always end everyone's sessions. What changed is that there is no longer a *politer* form beside it, because the politer form was not a control. -**It needs no password, and that is the requirement the grant answers.** The `%ai-ops` drop-in +**It runs without a password, and that is the requirement the grant answers.** The `%ai-ops` drop-in carries a NOPASSWD rule for the helper's bare form, so the command runs with no prompt: ``` @@ -267,7 +268,7 @@ during exactly the incidents it exists for. The bare command is the whole of wha because the confirmation defaults yes and proceeds with no terminal (§1). The trailing `""` pins the rule to that zero-argument form and therefore withholds `--force` (which drops the grace period and the current turn's unsaved work with it) and `--dry-run` (a detector does not preview); both -stay behind a general sudo grant. Since the helper takes no target and no authorization input, the +stay behind a general sudo grant. Since the helper does not take a target or an authorization input, the rule has no argument surface at all — the narrowest shape a NOPASSWD rule can have. Root reaches the same command directly (`ai-tools --stop` is one of the verbs that write no @@ -297,17 +298,17 @@ systemd **delegates** the per-user subtree to the account — that is what lets create units at all — and delegation permits the delegatee to move its own tasks between cgroups *inside* that subtree. Containment holds at the delegation boundary, not within it. -On a host with the SELinux module loaded this is closed outright: `ai_tools_t` is granted nothing +On a host with the SELinux module loaded this is closed outright: `ai_tools_t` is granted no permission on `cgroup_t` (only a `dontaudit … getattr`), so a session can neither write a `cgroup.procs` nor create a cgroup, and it has no `connectto` on the user runtime socket, so it cannot reach its own user manager to ask for a unit either. **On a DAC-only host both are reachable**, since the account owns the delegated cgroup files and its own bus socket. -This is why the sweep **spares nothing** — not the account's own `systemd --user`, not its -`init.scope`. An exemption is a destination: any cgroup left out is one a session on a DAC-only -host can move into, whether it is named or identified by exact path. Sparing nothing means there is +This is why the sweep **covers every cgroup** — the account's own `systemd --user` and its +`init.scope` included. An exemption is a destination: any cgroup left out is one a session on a DAC-only +host can move into, whether it is named or identified by exact path. Covering every cgroup means there is nowhere to move to, and a unit the session asks its manager to create is still inside the slice -being swept. It also means **no unit name appears in the helper at all**, so there is nothing here +being swept. It also means **no unit name appears in the helper at all**, so there is no name here to differ between EL9, EL10 and Fedora. The cost is that the user manager is terminated too, and `SIGKILL` leaves `user@<uid>.service` @@ -320,19 +321,18 @@ A final sweep re-enumerates after the run and reports a cgroup that became live verified empty (`AI_TOOLS_RESULT=reappeared`) — that is what a mid-run launch looks like from here. Re-running is the remedy; the command is idempotent. -Attribution is best-effort, and now carries no weight: a `WorkingDirectory` is read from the +Attribution is best-effort and display-only: a `WorkingDirectory` is read from the account's own user manager, so a rogue unit can misreport it — and all that buys is a wrong label -in the table and a missing `--reclaim` line. It cannot affect what is terminated, because nothing -is selected by it. +in the table and a missing `--reclaim` line. It cannot affect what is terminated, because the sweep does not consult it. ### Residual failure modes Stated so the guarantee is bounded honestly rather than overstated. - **Uninterruptible sleep.** A `D`-state task cannot be reaped until its syscall returns; the run - reports it as surviving, which is honest. It executes nothing meanwhile. + reports it as surviving, which is honest. It stays off the CPU meanwhile. - **The helper itself is `SIGKILL`ed mid-run.** An `EXIT` trap narrows the *audit* gap (it covers - every signal bash can handle) but nothing narrows the *state* gap. The remedy is that the command + every signal bash can handle) but no mechanism narrows the *state* gap. The remedy is that the command is idempotent: run it again. A watchdog would be a second privileged daemon for a case a rerun already answers. - **The launch/stop window.** A session launched after the final sweep is outside the command's @@ -349,16 +349,16 @@ Stated so the guarantee is bounded honestly rather than overstated. - **Kernel below 5.14 or a cgroup-v1-only host.** Without `cgroup.kill` the fallback re-collects and re-signals in a loop, narrowing the fork window without closing it. With no cgroup v2 hierarchy at all the helper **refuses** (exit 5) and names the manual command, rather than - reporting that nothing is running. Both supported EL targets are unified and ≥ 5.14. + reporting that no session is running. Both supported EL targets are unified and ≥ 5.14. - **Both log sinks unavailable.** The stop still succeeds or correctly fails; the record is lost. The two sinks fail independently. ### What the monitored system can do about it Nothing, and this is asserted from the agent's own vantage in `tests/boundary/access.sh`: the -sandbox account cannot read, write or execute the helper and holds no `sudo` rule to reach root +sandbox account cannot read, write or execute the helper and does not hold a `sudo` rule to reach root by. There is no authorization input left for it to aim at, either — the command takes none. The kill -is delivered by root to a cgroup, and nothing inside the cgroup takes part in it. +is delivered by root to a cgroup, and no process inside the cgroup takes part in it. **No name buys anything, because no name is spared.** One cgroup is matched by name at all — the manager unit, `user@<uid>.service`, by *exact path* — and it is not an exemption: it is descended @@ -444,7 +444,7 @@ off. It is discovery, consent, verification, restoration, and the record. between. Each pass re-reads the cgroup. - **Validate a pid's start time immediately before signalling it** (`/proc/<pid>/stat` field 22), so a pid recycled between collection and kill is skipped rather than signalled blind. This is the - pre-5.14 path; `cgroup.kill` signals no pids at all. + pre-5.14 path; `cgroup.kill` does not signal any pid. - **Every liveness read fails closed.** Only one failure means "empty": the file not existing, i.e. the cgroup was removed, which is what a completed kill looks like. A permission-unreadable `cgroup.procs` reports LIVE. A threaded cgroup — whose `cgroup.procs` read fails while live @@ -466,8 +466,8 @@ two project-wide conventions are inverted, and each is inverted for that reason ends the shell just as abruptly wherever a name or an argument is read unset — so a value a caller may legitimately not have passed is defaulted where it is read, rather than left to abort a run mid-way. What is guaranteed is independence from *this project's* libraries, and now - absolutely: **no project library is load-bearing here at all.** The command takes no input that - decides which sessions to stop, so there is nothing left for one to gate. `log.lib.sh` and + absolutely: **no project library is load-bearing here at all.** The command does not take any input that + decides which sessions to stop, so there is no input left for a library to gate. `log.lib.sh` and `msg.lib.sh` load best-effort for output quality; `safe-paths.lib.sh` and `operator.lib.sh` are not loaded, having existed here only to vet and authorize a caller-supplied target. It is not independence from the base system and does not pretend to be — which externals a run touches and @@ -493,7 +493,7 @@ Each of these looks like a defect to a fresh reader, and each is deliberate. If | The user manager is restarted, not spared | the exemption it would need costs more than the restart does (§2) | | The restart never changes the exit status | the invariant is that the *stop* happened; the manager is a separate fact (§2) | | Liveness comes from cgroups only | systemd never decides whether something is running (§3) | -| Attribution is best-effort and display-only | it selects nothing, so a misreported project costs a label, never a target (§2) | +| Attribution is best-effort and display-only | the sweep does not consult it, so a misreported project costs a label, not a target (§2) | | Agent sessions and account plumbing are counted apart, advisorily | the class comes from a unit name, so it is the account's word — but it labels a row, never selects one, and the alternative was telling an operator four "agent sessions" were running when one was (§1) | | A second run terminates and restarts the manager again | idempotence is in end state; a silent rerun costs either an exemption or a name-decided sweep (§1) | diff --git a/install.sh b/install.sh index 0587c215..0c30deb2 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ # sudo ./install.sh install deploy all files, enable timer # sudo ./install.sh uninstall remove deployed files, disable timer # sudo ./install.sh check-perms run the permissions test (tests/integration/perms.sh; also part of the suite offered at the end of an interactive install) -# sudo ./install.sh install --operator op enrol the named account, asking nothing +# sudo ./install.sh install --operator op enrol the named account, asking no question # # Project registration lives in the `ai-tools` CLI (/usr/local/bin/ai-tools), run # as the projects user, not in install.sh: @@ -71,7 +71,7 @@ readonly SANDBOX_USER="ai-tools" readonly SANDBOX_GROUP="ai-tools" # operator_refusal <name> -- echo why <name> cannot be the operator this install enrols, or -# nothing when it can. The single home for that decision, because a name now reaches it by three +# an empty string when it can. The single home for that decision, because a name now reaches it by three # routes -- the invoking SUDO_USER, --operator, and the prompt -- which must refuse alike or the # route decides the outcome. # @@ -201,7 +201,7 @@ source "${MANAGED_ASSETS_LIB}" || { # * the shared inline yes/no prompt (ai_tools_msg_confirm; see msg.lib.sh), # all on the controlling terminal, BYPASSING the do_install log tee that captures # stdout+stderr. msg.lib.sh prints a blank line BEFORE every box, so prompts self-separate. -# Non-interactive runs draw nothing and take <y|n>, the safe default for the question. +# Non-interactive runs draw no box and take <y|n>, the safe default for the question. confirm_boxed() { local title="$1" def="$2" question="$3"; shift 3 if [[ -t 0 ]] || { [[ -c /dev/tty ]] && { : < /dev/tty; } 2>/dev/null; }; then @@ -365,7 +365,7 @@ user_systemctl() { # `loginctl enable-linger` returns before the manager is up, so the enablement below would # otherwise race it. Readiness is asked of the system manager (`is-active user@<uid>.service`) # rather than probed through the account's own bus: that is the authoritative answer to "is the -# manager running", it needs no bus connection, and a bus probe answers a different and narrower +# manager running", it does not need a bus connection, and a bus probe answers a different and narrower # question -- whether root can currently reach that account's bus -- which is not what gates # provisioning. Starting user@<uid>.service first is the documented, idempotent way to have the # manager exist at all for a nologin account. @@ -409,7 +409,7 @@ lockdown_nvm_permissions() { # Point /opt/ai-tools/bin/<launcher> at each enabled agent's versioned binary directly, without # running nvm-update.service (which also prunes old Node versions). Which launchers those are -# comes from the agent manifests, so this installer names no agent. Emits a warning and returns +# comes from the agent manifests, so this installer is agent-agnostic. Emits a warning and returns # when the sandbox nvm tree or a launcher is not yet installed. bootstrap_launcher_symlinks() { local ai_nvm_dir="/opt/ai-tools/.nvm" @@ -610,7 +610,7 @@ offer_selinux() { # Suggest lint tools the sandboxed agent can use in its sessions (shellcheck for shell # sources, rpmlint for RPM specs, yamllint for YAML/workflows) when the host lacks them. # A tool counts as present by its binary (any install method: package, pip, manual) or by -# its package name, so nothing already usable is re-suggested. Print-only, and strictly +# its package name, so a tool already usable is never re-suggested. Print-only, and strictly # from the repos ALREADY enabled -- it neither installs anything nor enables EPEL (which # carries all three on EL); a tool no enabled repo provides is silently dropped from the # suggestion. Any packaged version serves; no pinning. @@ -777,7 +777,7 @@ do_summary() { # brand mark is single-sourced; the installer only supplies its subtitle, the package # version (AI_TOOLS_VERSION, the same value `ai-tools --version` reports -- not the noisy # git-describe), and the "installer" mode word (this is the install phase, not the running -# app). The renderer prints nothing when stdout is not a terminal. +# app). The renderer stays silent when stdout is not a terminal. print_banner() { ai_tools_msg_banner \ 'Agent Tools Restricted — run coding agents with limited system access' \ @@ -803,7 +803,7 @@ do_install() { # Proceed gate -- everything above is print-only; the first change to the host # (including the install log itself) happens only past this point. Two questions: # Enter proceeds through the first, but the second defaults to CANCEL, so an - # accidental double-Enter installs nothing. Interactive only -- an unattended run + # accidental double-Enter does not install anything. Interactive only -- an unattended run # (CI, container self-test) proceeds as before. if [[ -t 0 ]] || { [[ -c /dev/tty ]] && { : < /dev/tty; } 2>/dev/null; }; then if ! confirm_boxed "Review install" y "Proceed with the install?" \ @@ -935,7 +935,7 @@ do_install() { /usr/local/lib/ai-tools/secret-patterns.lib.sh # Seal primitives (owner-only predicate + residue strip): read only by the root helpers - # that walk a claimed tree, but carries no secrets -- 644 root:root, like msg/log/ + # that walk a claimed tree, but must not hold a secret -- 644 root:root, like msg/log/ # safe-paths. Substituted: the strip is keyed on the sandbox group's name. log "/usr/local/lib/ai-tools/owner-only.lib.sh" install_subst 644 root root \ @@ -944,7 +944,7 @@ do_install() { # Skip-dir list/selector: sourced by the root helpers, by session-hook.sh (as the # agent), and by the operator-run CLI (the claim drift scan) -- 644 root:root, like - # msg/log/safe-paths. It carries no secrets: the names are documented. No tokens to + # msg/log/safe-paths. It must not hold a secret: the names are documented. No tokens to # substitute. log "/usr/local/lib/ai-tools/skip-dirs.lib.sh" install -o root -g root -m 644 \ @@ -1097,7 +1097,7 @@ do_install() { # Logger library: 644 root:root -- world-readable. Sourced by the root helpers, by # the hooks (run as ai-tools), and by the CLI (run as the projects user, NOT in - # SANDBOX_GROUP), so every principal must read it; it holds no secrets. No tokens. + # SANDBOX_GROUP), so every principal must read it; it must not hold a secret. No tokens. log "/usr/local/lib/ai-tools/log.lib.sh" install -o root -g root -m 644 \ "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/log.lib.sh" \ @@ -1105,7 +1105,7 @@ do_install() { # Message formatter: 644 root:root -- world-readable. Sourced by the operator wrapper # and CLI, by the hooks (run as ai-tools), and by ai-tools-run, so every principal must - # read it; it holds no secrets. No tokens to substitute. + # read it; it must not hold a secret. No tokens to substitute. log "/usr/local/lib/ai-tools/msg.lib.sh" install -o root -g root -m 644 \ "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/msg.lib.sh" \ @@ -1113,7 +1113,7 @@ do_install() { # Operator-identity resolver: 644 root:root -- world-readable. Sourced by the root helpers # (which run in ai_tools_handback_t) AND the agent hooks (ai_tools_t); both read it to - # resolve the operator from /etc/ai-tools/operator.conf, and it holds no secrets. No tokens. + # resolve the operator from /etc/ai-tools/operator.conf, and it must not hold a secret. No tokens. log "/usr/local/lib/ai-tools/operator.lib.sh" install -o root -g root -m 644 \ "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/operator.lib.sh" \ @@ -1121,7 +1121,7 @@ do_install() { # Protected-paths backstop: 644 root:root -- world-readable. Sourced by the operator # wrapper and CLI AND the root helpers, so every principal that resolves a target path - # reads the same list; it holds no secrets. No tokens to substitute. + # reads the same list; it must not hold a secret. No tokens to substitute. log "/usr/local/lib/ai-tools/safe-paths.lib.sh" install -o root -g root -m 644 \ "${SCRIPT_DIR}/src/usr/local/lib/ai-tools/safe-paths.lib.sh" \ @@ -1295,7 +1295,7 @@ do_install() { /usr/lib/systemd/system/ai-tools-relabel.service # Project-lifecycle CLI. Runs AS the projects user (never root, never ai-tools) - # and needs no privilege: it only edits allowed-projects and the git + # and does not need privilege: it only edits allowed-projects and the git # safe.directory list, both writable by the projects user. 755 root:root -- # world-executable (the in-script guard refuses to run as root or ai-tools), # root-owned so the agent cannot tamper with it. @@ -1436,7 +1436,7 @@ do_install() { # Host config. The root helpers and the agent hooks resolve the operators from this file # at runtime (via operator.lib.sh) instead of substituting an identity into each helper, so # the helper files are identical on every host. 644 root:root: world-readable -- both the - # agent (ai_tools_t hooks) and the root helpers (ai_tools_handback_t) read it, and it carries + # agent (ai_tools_t hooks) and the root helpers (ai_tools_handback_t) read it, and it must not hold # no secret -- and root-write-only, so the agent cannot rewrite the identity root hands files # back to. Seeded from the src/etc template with the invoking user as the sole operator; an # EXISTING file is kept by default (keep_existing prompt; unattended installs always keep) @@ -1832,7 +1832,7 @@ do_install() { # offer_selinux is the single labelling point: it relabels through install-selinux.sh on both # the accept path (install action) and the declined-but-loaded path (relabel action), so a - # SELinux-active host relabels exactly once here, in one tool's consistent output. Nothing to + # SELinux-active host relabels exactly once here, in one tool's consistent output. No step to # do afterwards. offer_selinux @@ -1868,8 +1868,8 @@ do_install() { warn "until it is; for a full pass run sudo ai-tools-bootstrap first, then re-test" warn "with: sudo ${SCRIPT_DIR}/tests/run.sh all" fi - # The section header prints only when the suite actually runs, so a skip leaves - # no empty "Verify" heading in the transcript. + # The section header prints only when the suite runs, so a skip avoids an + # empty "Verify" heading in the transcript. if confirm_boxed "Run test suite" y "Run it now?" \ "Run the full test suite (incl. the permissions check) now to verify the install?"; then section "Verify" @@ -1915,7 +1915,7 @@ do_uninstall() { section "Removing files" log "system files" # Remove the helper and library trees whole: they hold only deployed files, never - # operator or agent state, so a dir-level removal leaves nothing behind and never + # operator or agent state, so a dir-level removal does not leave a file behind and never # drifts out of sync with the install list the way an enumerated rm would. rm -rf /usr/local/libexec/ai-tools # Sweep the pre-0.10.0 helper location too, in case an uninstall follows an install that @@ -2037,12 +2037,12 @@ operator_is_enrolled() { # holds the grant a claim needs, and the ownership handback restores agent-written files to it, # which keeps an editor or IDE working in a claimed project seeing its own files. The prompt offers # accounts of that same shape, because one operator comes out of this install and a host whose only -# operator holds no sudo grant can register no project. +# operator does not hold a sudo grant can register no project. # # So the prompt states what enrolment does not confer: `ai-tools-admin` enrols an account holding # no grant later, and a grant-holding operator claims its projects with the CLI's `--for` switch. # The prompt names that switch alone, without the claim verb carrying it, so respelling the verb in -# the resource grammar leaves no stale command here, and writes the switch's target as a +# the resource grammar leaves behind no stale command here, and writes the switch's target as a # placeholder -- the account enrolled here runs such a claim more often than it receives one. # # operator_create_hint prints the useradd command, from the two sites that refuse an unresolvable diff --git a/packaging/ELBase.Containerfile b/packaging/ELBase.Containerfile index 55c83b3a..b511d5e2 100644 --- a/packaging/ELBase.Containerfile +++ b/packaging/ELBase.Containerfile @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Shared base recipe for the EL (Rocky/RHEL) ai-tools RPM test image. All the common build/test # logic lives here, parameterized by the EL base image; the per-distro files (Rocky9.Containerfile, -# Rocky10.Containerfile) are thin pins over the image this builds, so nothing below is repeated. +# Rocky10.Containerfile) are thin pins over the image this builds, so no line below is repeated. # Rocky 9/10 minimal both ship microdnf and the same package names installed below, so this recipe # builds unchanged across them. # @@ -51,7 +51,7 @@ ARG RPM_RELEASE="" # rpm-sign + gnupg2 are baked in here, NOT dnf-installed at sign time: the release workflow # runs sign-rpms.sh in this image with the signing key in the environment, and no package # scriptlet may ever execute while that secret is present. -# Nothing below comes from the `extras` repo; disable it so a flaky refresh can't abort the install. +# No package below comes from the `extras` repo; disable it so a flaky refresh can't abort the install. RUN sed -i '/^\[extras\]/,/^\[/ s/^enabled=1$/enabled=0/' /etc/yum.repos.d/*.repo \ && microdnf -y install \ dnf rpm-build rpm-sign gnupg2 systemd-rpm-macros make sed tar gzip findutils createrepo_c \ @@ -121,8 +121,8 @@ RUN set -eux; \ # A non-root login user to enrol as the operator. The NOPASSWD drop-in is TEST-ONLY: it lets # the unattended selftest run the operator's password-prompting sudo helpers (project claim, -# lockdown, …). It does NOT relax the agent's confinement -- the sandbox account ai-tools holds -# no sudo grant, which the selftest re-checks. +# lockdown, …). It does NOT relax the agent's confinement -- the sandbox account ai-tools does +# not hold a sudo grant, which the selftest re-checks. RUN useradd -m -s /bin/bash tester \ && printf 'tester ALL=(ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/zz-test-operator \ && chmod 0440 /etc/sudoers.d/zz-test-operator diff --git a/packaging/FedoraLatestBase.Containerfile b/packaging/FedoraLatestBase.Containerfile index aa82fbda..9dfe0fbf 100644 --- a/packaging/FedoraLatestBase.Containerfile +++ b/packaging/FedoraLatestBase.Containerfile @@ -83,7 +83,7 @@ RUN set -eux; \ # A non-root login user to enrol as the operator. The NOPASSWD drop-in is TEST-ONLY (lets the # unattended selftest run the operator's password-prompting sudo helpers); it does NOT relax the -# agent's confinement -- the sandbox account holds no sudo grant, which the selftest re-checks. +# agent's confinement -- the sandbox account does not hold a sudo grant, which the selftest re-checks. RUN useradd -m -s /bin/bash tester \ && printf 'tester ALL=(ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/zz-test-operator \ && chmod 0440 /etc/sudoers.d/zz-test-operator diff --git a/packaging/Makefile b/packaging/Makefile index 2d0dd495..5e5c0142 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -43,7 +43,7 @@ POLICY_PP = $(or $(shell git -C $(ROOT) ls-files 'selinux/policy/*.pp' 2>/dev/nu # # A wildcard is exact here: .gitignore covers *.pp alone, so every policy source in the tree is # tracked. install-selinux.sh is not source in this sense -- it loads and installs a compiled -# module rather than controlling compilation, and holds no refpolicy content (it is AGPL-3.0-only, +# module rather than controlling compilation, and does not carry refpolicy content (it is AGPL-3.0-only, # per its SPDX header). POLICY_SRC = $(patsubst $(ROOT)/%,%,$(wildcard $(ROOT)/selinux/policy/*.te \ $(ROOT)/selinux/policy/*.if \ @@ -84,7 +84,7 @@ check-version: @bash $(CURDIR)/check-version.sh $(TAG) # Draft a %changelog block for the current VERSION from commits since the last tag, to stdout -# for curation (see change-docs). Prints nothing to the spec -- paste and edit the result. +# for curation (see change-docs). Writes no line to the spec -- paste and edit the result. changelog: @bash $(CURDIR)/changelog-draft.sh @@ -130,9 +130,9 @@ rpmtest-fedora: $(OCI) run --rm -t --systemd=always --ulimit core=0 ai-tools-rpmtest:fedora # The tarball asserts its own GPL compliance: a .pp travels only with the policy source it was -# compiled from. Both lists are globs, and a glob that matches nothing yields a tarball that looks +# compiled from. Both lists are globs, and a glob that does not match a file yields a tarball that looks # well-formed and conveys a GPL binary with no source, so the counts are checked rather than -# assumed -- comparing the archive against the inputs, so adding a policy module needs no edit here. +# assumed -- comparing the archive against the inputs, so adding a policy module does not need an edit here. dist: tar czf $(TARBALL) -C $(ROOT) \ --transform 's,^,$(NAME)-$(VERSION)/,' \ diff --git a/packaging/README.md b/packaging/README.md index 5d268e8f..e254feb7 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -64,7 +64,7 @@ tag/`VERSION`/`%changelog` mismatch surfaces locally before you tag. ## Scope -A container validates packaging and dependency resolution, the install scriptlets, the `bootstrap` toolchain, operator enrolment, project claim, the test suite's DAC and `systemd` parts, and a DAC-confined launch. It does **not** validate SELinux-enforcing confinement: `getenforce` reports `Disabled` in a container, so `%post` skips `semodule` and the `ai_tools_t` domain transition never fires. This harness is the fast, repeatable pre-check; the enforcing-host `dnf install` + `sudo tests/run.sh all` remains the real gate. ⚠️ The test image also adds a NOPASSWD sudoers drop-in for the operator user — convenience for the unattended run, not part of the shipped model; the sandbox account `ai-tools` still holds no sudo grant, which the selftest re-checks. +A container validates packaging and dependency resolution, the install scriptlets, the `bootstrap` toolchain, operator enrolment, project claim, the test suite's DAC and `systemd` parts, and a DAC-confined launch. It does **not** validate SELinux-enforcing confinement: `getenforce` reports `Disabled` in a container, so `%post` skips `semodule` and the `ai_tools_t` domain transition never fires. This harness is the fast, repeatable pre-check; the enforcing-host `dnf install` + `sudo tests/run.sh all` remains the real gate. ⚠️ The test image also adds a NOPASSWD sudoers drop-in for the operator user — convenience for the unattended run, not part of the shipped model; the sandbox account `ai-tools` still does not hold a sudo grant, which the selftest re-checks. ## Files diff --git a/packaging/Rocky9.Containerfile b/packaging/Rocky9.Containerfile index 3ba04195..e4e1b707 100644 --- a/packaging/Rocky9.Containerfile +++ b/packaging/Rocky9.Containerfile @@ -2,7 +2,7 @@ # Rocky Linux 9 ai-tools RPM test image: a thin pin over the shared ELBase recipe, which # carries all the common build/test logic. Build with `make -C packaging rpmtest-rocky9`, or # manually build the base first with BASE_IMAGE=quay.io/rockylinux/rockylinux:9.7-minimal -# (see ELBase.Containerfile), then this overlay. Stock Rocky 9 needs no distro-specific +# (see ELBase.Containerfile), then this overlay. Stock Rocky 9 does not require distro-specific # adjustment, so this is just the per-distro tag + a hook for any future EL9-only tweak. FROM ai-tools-rpmbase:el9 LABEL ai-tools.test.distro="quay.io/rockylinux/rockylinux:9-minimal" diff --git a/packaging/ai-tools.spec b/packaging/ai-tools.spec index f409bfdf..860f4ca9 100644 --- a/packaging/ai-tools.spec +++ b/packaging/ai-tools.spec @@ -96,7 +96,7 @@ packages build on this layer. # # Scriptlet tools are named explicitly rather than via %%{?selinux_requires}: that macro bakes # the BUILD host's selinux-policy version into a Requires (uninstallable on the older EL of a -# noarch build) and pulls policycoreutils-python-utils, which nothing here uses. semodule and +# noarch build) and pulls policycoreutils-python-utils, which no scriptlet here uses. semodule and # restorecon come from policycoreutils; getenforce from libselinux-utils. %package -n ai-tools-selinux Summary: SELinux confinement policy for the ai-tools sandbox @@ -142,7 +142,7 @@ Requires: gzip # dnf treat that as a RENAME and hand the shared files over in one transaction. Without it a host # on the old name cannot resolve `dnf update` at all: the installed ai-tools-nodejs pins # `ai-tools-base = <its own version>`, the only upgrade candidate for the base is the new version, -# and nothing obsoletes the old name to break the deadlock -- so the whole transaction fails and +# and no package obsoletes the old name to break the deadlock -- so the whole transaction fails and # the operator is pushed into a manual erase that drops their operator.conf. The bound is the # version the rename landed in, so a future package reusing the old name is never obsoleted. Provides: ai-tools-nodejs = %{version}-%{release} @@ -230,7 +230,7 @@ grep -rlZ '@AI_TOOLS_VERSION@' src \ %install # The /opt control plane and the /var trees ship root:ai-tools and stay that way: root (not the # agent) owns the locked control files while the agent reaches its state through group ai-tools. -# Nothing re-owns them to a person -- the operators drive the shared ai-tools account and reach +# No step re-owns them to a person -- the operators drive the shared ai-tools account and reach # the launcher through an o+x search bit, so the agent is never the owner of a locked dir. # ── base: root helpers ─────────────────────────────────────────────────────── @@ -562,7 +562,7 @@ fi # AI_TOOLS_ASSUME_YES answers the update confirm here rather than letting it fall through to its # default. The outcome is the same either way -- the default IS yes, and a scriptlet has no tty to # answer with -- but the prompt is written to /dev/tty, which succeeds when dnf runs on a terminal, -# so without this the operator is shown a question that nothing can answer and that is then decided +# so without this the operator is shown a question that no one can answer and that is then decided # without them. Pre-answering skips drawing it, and the decision audits as `assume-yes` rather than # `default`, which is what actually happened. It cannot widen anything: the variable fast-tracks a # question whose default is already yes and never flips a default-NO one (see msg.lib.sh). @@ -689,7 +689,7 @@ fi # # A failed load is REPORTED rather than swallowed: every type the entrypoint and the project # labels name comes from this module, so a load that did not happen surfaces later as a relabel -# that cannot register its rules and a launch that fail-closes, with nothing naming this as the +# that cannot register its rules and a launch that fail-closes, with no message naming this as the # cause. The transaction still completes -- the remedy is a re-run, not a rollback. if [ "$(getenforce 2>/dev/null)" != "Disabled" ] && command -v semodule >/dev/null 2>&1; then _semodule_error=$(semodule -i %{_datadir}/selinux/packages/ai-tools/ai_tools.pp 2>&1) || { @@ -724,7 +724,7 @@ fi # enabled in the sandbox account's own instance by ai-tools-bootstrap, which is where that # instance is brought up with linger -- a scriptlet cannot reliably reach it. %systemd_post ai-tools-relabel.path -# Create the updater's last-run stamp (%ghost, so rpm owns the path but ships no content). The +# Create the updater's last-run stamp (%ghost, so rpm owns the path without shipping content). The # state directory is root-owned and not group-writable on purpose, so nvm-update.sh can only # REWRITE this inode, never create it -- which is exactly what keeps the surface to one file. Owned # by the sandbox account (the writer) with group ai-ops (the readers). Idempotent; an existing @@ -773,7 +773,7 @@ fi %post -n ai-tools-agents-claude-code-restricted # Register this agent's SELinux entrypoint file-context and label whatever it matches. The base -# policy names no agent (see selinux/policy/ai_tools.fc): the pattern comes from this package's +# policy is agent-agnostic (see selinux/policy/ai_tools.fc): the pattern comes from this package's # own manifest, and the helper maps it to the base's ai_tools_exec_t as a local rule, so a # session's domain transition fires. Offline and idempotent; it no-ops when SELinux or the # ai_tools module is inactive, and when the toolchain is not provisioned yet (a fresh install -- @@ -811,7 +811,7 @@ done # parks this version's copy as .rpmnew. Choosing between the two is the operator's call, made # 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. +# no event 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:" @@ -896,7 +896,7 @@ fi %{_presetdir}/85-ai-tools.preset # Plain %config (replace on upgrade), NOT noreplace: the file is host-identical by # construction (@SANDBOX_*@ substituted to the constant ai-tools at %build, %ai-ops literal), -# so it carries no operator config to preserve. Replace guarantees the guardrail -- including +# so it does not hold operator config to preserve. Replace guarantees the guardrail -- including # the sudoers path of the root relabel-agent rule -- always matches the shipped version instead # of drifting under noreplace: on the unmodified host rpm sees on-disk == prior-packaged and # replaces silently; on a hand-edited host it parks the old file as .rpmsave (ignored by sudo, diff --git a/packaging/changelog-draft.sh b/packaging/changelog-draft.sh index f967f5c1..5c8f30ed 100755 --- a/packaging/changelog-draft.sh +++ b/packaging/changelog-draft.sh @@ -5,7 +5,7 @@ # CURATE before pasting into ai-tools.spec -- a changelog is not a commit log: prune # no-user-impact commits and rewrite subjects into reader-facing, upgrade-oriented prose # (see the change-docs standard). This removes the blank-page burden; it does not replace the -# editing pass. It writes nothing and stages nothing. +# editing pass. It does not write a file and does not stage a change. # # Usage: packaging/changelog-draft.sh (or: make -C packaging changelog) set -euo pipefail @@ -22,7 +22,7 @@ range="${anchor:+${anchor}..}HEAD" # Attribute the draft to the packager already named in the spec's %changelog (the identity the # entry will be pasted next to), not the committer's git identity. Fall back to git config only -# when the spec carries no entry yet. +# when the spec does not carry an entry yet. packager="$(awk ' /^%changelog/ { in_log = 1; next } in_log && /^\*/ { diff --git a/packaging/sign-rpms.sh b/packaging/sign-rpms.sh index dc23a20c..1a8fe799 100755 --- a/packaging/sign-rpms.sh +++ b/packaging/sign-rpms.sh @@ -17,7 +17,7 @@ # # --selftest builds a disposable package, signs it, and verifies it through the identical code # path, so the release workflow can prove the key, passphrase, rpmsign, and verification all -# work in this exact container BEFORE any real RPM is built or published. It leaves nothing +# work in this exact container BEFORE any real RPM is built or published. It does not leave an artifact # behind. # # Environment (from the dag-node org CI secrets): @@ -27,7 +27,7 @@ # Fail-closed: a missing key or passphrase, a signing failure, or an RPM that does not carry a # signature that verifies exits non-zero, so a release never publishes an unsigned or wrongly # signed package. Verification asserts a cryptographic signature LINE validates -- `rpmkeys -# --checksig` exits 0 for an unsigned package (nothing to fail), so a return-code-only test +# --checksig` exits 0 for an unsigned package (no signature to fail), so a return-code-only test # passes a silent rpmsign no-op; the 0.6.1 assets shipped unsigned that way. Errors use the # ::error:: prefix so GitHub Actions surfaces them as annotations; the text reads plainly on a # local terminal too. Every secret (imported private key, passphrase) lives in a tmpfs (RAM) @@ -56,7 +56,7 @@ import_signing_key() { # # %{__gpg} is the ONLY binary token. rpm's stock %__gpg_sign_cmd is `%{__gpg} gpg ...`, which on # EL10 (where %__gpg is defined as /usr/bin/gpg) expands to `/usr/bin/gpg gpg ...` -- gpg invoked -# with argv[1]="gpg", a bogus input filename, so it signs nothing. Copying that literal `gpg` +# with argv[1]="gpg", a bogus input filename, so it does not sign the package. Copying that literal `gpg` # into the override is why the 0.6.1 el10 RPMs shipped unsigned; here %{__gpg} stands alone. write_rpm_macros() { local home="$1" fpr="$2" passfile="$3" @@ -154,7 +154,7 @@ main() { # default TMPDIR where /dev/shm is absent. gpg needs the private key in a keyring DIRECTORY # (it cannot sign from a variable), and rpmsign forks gpg once per package, so the passphrase # must stay re-readable here rather than a one-shot stream -- keeping it on the same RAM tree - # as the unavoidable keyring adds no disk exposure. The runner VM is ephemeral. + # as the unavoidable keyring leaves disk exposure unchanged. The runner VM is ephemeral. # Script-global, not local: the EXIT trap fires after main returns, where a local is out of # scope -- an unbound reference under set -u -- and the wipe must still run. workdir="$(mktemp -d -p /dev/shm 2>/dev/null || mktemp -d)" diff --git a/selinux/README.md b/selinux/README.md index d12a2ea0..13de2b84 100644 --- a/selinux/README.md +++ b/selinux/README.md @@ -145,7 +145,7 @@ sudo bash selinux/avc/avc-analyze.sh # splits denials into NEW vs EXPECTED BOUN ``` `avc-testsuite.sh` **aborts unless it is running in `ai_tools_t`** — running it -unconfined would log nothing and the empty result would look like success. It +unconfined would log no denial and the empty result would look like success. It writes a start marker (`selinux/avc/.avc-last-run`); `avc-analyze.sh` reads it so `ausearch -ts` starts at exactly the right instant. The analyzer classifies each denial: **EXPECTED BOUNDARY** ones (the `user_home_t` / `config_home_t` / @@ -243,7 +243,7 @@ user's home, container storage, a non-`http` port, the MTA) and confirms each is refused. The catch: the boundary accesses are `dontaudit`'d, so under enforcing they are -blocked **silently** — `ausearch` shows nothing and an empty log looks like the +blocked **silently** — `ausearch` reports an empty result and that looks like the probe never ran. So the run-mode half brackets the probe with `semodule -DB` … `semodule -B`, which disables/re-enables dontaudit **system-wide** for the window, making those denials visible. A trap restores dontaudit on any exit, including @@ -268,7 +268,7 @@ GROUP-DISABLED** (only an optional group would allow them — `enable-group <nam *not* a core change), and **NEW** (a real gap to review). Group-surface denials (`rpm_exec_t`, `systemd_systemctl_exec_t`, `firewalld_t`, …) land in the second bucket instead of being misreported as NEW. A clean verification shows entries in -the two EXPECTED buckets and **nothing** under NEW or "ran (group enabled?)". +the two EXPECTED buckets and **no entry** under NEW or "ran (group enabled?)". ## After a Node upgrade @@ -279,7 +279,7 @@ agent safe while it waits to be relabelled. The daily `nvm-update` timer relabels the new entrypoint automatically: after delegating the sandbox update it runs `ai-tools-relabel-agent` as root (a dedicated NOPASSWD rule). -That helper names no agent — for each enabled agent it applies the entrypoint file-context that +That helper is agent-agnostic — for each enabled agent it applies the entrypoint file-context that agent's own manifest declares (`entrypoint_fcontext`, mapped to the `ai_tools_exec_t` this module defines), `restorecon`s every binary it matches, and verifies the type. So a normal upgrade keeps the agent confined across version bumps with no manual step, and a second agent is labelled by diff --git a/selinux/avc/avc-analyze.sh b/selinux/avc/avc-analyze.sh index 2a7b1a0c..8ce05d36 100755 --- a/selinux/avc/avc-analyze.sh +++ b/selinux/avc/avc-analyze.sh @@ -109,7 +109,7 @@ readonly BOUNDARY_NAMED_RE='(user_home_t|user_home_dir_t|home_root_t|config_home # core dontaudit's it regardless) # tmpmap is handled separately below (_g2): its type, ai_tools_tmp_t, is core-granted # for read/write, so it is matched on the `map` PERMISSION, not the type alone. -# apphost is handled separately below (_g3): the core grants nothing on tmpfs_t:file, so +# apphost is handled separately below (_g3): the core does not grant a permission on tmpfs_t:file, so # the whole memfd surface the .NET JIT/apphost touches (write to size it, map, and the # defining execute) is that group -- matched on the tmpfs_t:file TYPE. # netcore is handled separately below (_g4): the .NET runtime's sockets/FIFOs under diff --git a/selinux/avc/avc-denials.sh b/selinux/avc/avc-denials.sh index 3a49eaaf..7a6739e9 100755 --- a/selinux/avc/avc-denials.sh +++ b/selinux/avc/avc-denials.sh @@ -36,7 +36,7 @@ # Brackets the probe with `semodule -DB` ... `semodule -B` so the # dontaudit'd boundary denials become VISIBLE in the audit log for the # test window (without -DB they are blocked but silent, and the audit -# log would look empty -- mistakable for "nothing was denied"). A trap +# log would look empty -- mistakable for "no access was denied"). A trap # restores dontaudit on ANY exit (success, error, Ctrl-C). It then hands # off to avc-analyze.sh, which buckets every denial as EXPECTED BOUNDARY, # EXPECTED GROUP-DISABLED, or NEW. @@ -104,7 +104,7 @@ do_probe() { # # getenforce reads security_t (selinuxfs). Under enforcing, ai_tools_t has no # security_t read access, so getenforce and the direct cat both fail -> "unknown". - # Under permissive nothing is blocked, so both CAN read it and return an explicit + # Under permissive no access is blocked, so both CAN read it and return an explicit # "Permissive"/"0". Therefore, once past the context check: # # "unknown" = selinuxfs was protected = enforcing -> allow @@ -838,7 +838,7 @@ do_run() { note " Flip to enforcing (remove 'permissive ai_tools_t;') for a true test." fi - # auditd must be running; without it ausearch finds nothing even when denials fire. + # auditd must be running; without it ausearch reports an empty result even when denials fire. # The group-disabled exec denials (systemctl, rpm, podman) are NOT dontaudit'd and # should always appear -- an empty log for those is the fingerprint of auditd being down. if ! systemctl is-active --quiet auditd 2>/dev/null; then diff --git a/selinux/avc/avc-testsuite.sh b/selinux/avc/avc-testsuite.sh index 7a20c5e2..cd24effe 100755 --- a/selinux/avc/avc-testsuite.sh +++ b/selinux/avc/avc-testsuite.sh @@ -17,7 +17,7 @@ # come back empty and you'd wrongly conclude the policy is complete. The usual # cause is the claude.exe entrypoint not being labelled ai_tools_exec_t (so the # unconfined_t->ai_tools_t transition never fired) -- the guard tells you how to -# fix it. The module ships permissive, so nothing here is ever blocked; it is only +# fix it. The module ships permissive, so no test here is ever blocked; it is only # logged. set -uo pipefail # NOT -e: several steps below are EXPECTED to fail (denied @@ -300,7 +300,7 @@ semodule -l 2>/dev/null | grep -q '^ai_tools_podman' && { # code heap through that path, so `dotnet --info` exercises it; a real executable # build/run under the agent (dotnet run / an xunit.v3 or ASP.NET Core project) covers # it more fully. Skipped when the group is off or dotnet is absent (an optional -# integration that ships no runtime). +# integration that does not ship a runtime). semodule -l 2>/dev/null | grep -q '^ai_tools_apphost' && { if command -v dotnet >/dev/null 2>&1; then note "apphost group loaded -- exercising the .NET memfd JIT via dotnet --info" diff --git a/selinux/avc/diag-nvm-update.sh b/selinux/avc/diag-nvm-update.sh index c9277a7a..66d51dd2 100755 --- a/selinux/avc/diag-nvm-update.sh +++ b/selinux/avc/diag-nvm-update.sh @@ -32,7 +32,7 @@ fail() { FAIL=$((FAIL + 1)); printf '\033[1;31m[FAIL]\033[0m %s\n' "$*"; } label_of() { stat -c '%C' "$1" 2>/dev/null | awk -F: '{print $3}'; } ######################################## -# Preflight: must be confined -- otherwise this reproduces nothing +# Preflight: must be confined -- otherwise this reproduces the denial ######################################## ctx="$(id -Z 2>/dev/null || true)" case "${ctx}" in diff --git a/selinux/install-selinux.sh b/selinux/install-selinux.sh index 6db6828f..014b5144 100755 --- a/selinux/install-selinux.sh +++ b/selinux/install-selinux.sh @@ -5,7 +5,7 @@ # up independently and refined via the audit2allow loop in README.md. # # The core module ships PREBUILT (ai_tools.pp) and ENFORCING, so a normal install -# needs no toolchain -- it loads the shipped package and labels the tree. To go +# does not require a toolchain -- it loads the shipped package and labels the tree. To go # permissive instead (to observe before blocking), uncomment `permissive # ai_tools_t;` in ai_tools.te and recompile; the installer detects the mode from # the source and reports it. @@ -128,7 +128,7 @@ _list() { # _group_cmd <enable|disable> [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 +# installed, so prefer it; a source checkout with no install 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. @@ -362,7 +362,7 @@ source "${RELABEL_LIB}" || die "missing label library: ${RELABEL_LIB}" # cannot write its own state) -- and confirm what they match took the type. The work is # ai_tools_label_agent_paths (relabel.lib.sh), the same body the always-installed # ai-tools-relabel-agent helper runs, so this sweep and the post-upgrade relabel cannot drift; -# this wrapper only renders the report in the installer's voice. It names no agent: the paths +# this wrapper only renders the report in the installer's voice. It is agent-agnostic: the paths # come from the manifests under /usr/local/lib/ai-tools/agents.d. verify_agent_labels() { local report="" status=0 verdict subject detail wanted bad=0 labelled=0 @@ -389,7 +389,7 @@ verify_agent_labels() { warn " chase with: sudo restorecon -nv '${subject}'" warn " and: sudo semanage fcontext -C -l" ;; # The declared rule does not cover the entrypoint the agent's launcher actually - # resolves to, so nothing this sweep applies can label it and the session would + # resolves to, so no rule this sweep applies can label it and the session would # be refused. Counted as `bad`: the install must not report a confined host. stale) bad=1 warn "${subject}: its installed entrypoint is" @@ -407,8 +407,8 @@ verify_agent_labels() { fi ;; # A verdict this renderer does not know is REPORTED, not dropped. Silently # ignoring one turns a labelling result into no output at all, which reads as - # "nothing happened" for the one path whose label decides whether a session is - # confined -- and leaves nothing to diagnose from. + # "no change" for the one path whose label decides whether a session is + # confined -- and leaves the operator no detail to diagnose from. *) warn "unrecognized labelling result: ${verdict} ${subject} ${detail} ${wanted}" warn " the entrypoint label is unconfirmed; check: sudo ai-tools --relabel" ;; esac @@ -417,14 +417,14 @@ verify_agent_labels() { # A path that restorecon left mislabelled is an unrecoverable gap (the module is loaded but # the transition would not fire, or the agent cannot write its state), so fail the install # here rather than proceed to the optional groups with a broken core. A missing path - # (toolchain not provisioned yet) stays a warning -- there is nothing to label. + # (toolchain not provisioned yet) stays a warning -- there is no entrypoint to label. [[ "${bad}" -eq 0 ]] \ || die "an agent path is not correctly labelled (see above) -- the session would be refused, or run UNCONFINED" # Nothing labelled has two very different causes, and the bare message named neither. An # EMPTY report means no enabled agent was iterated at all -- the manifests resolved to - # nothing -- which is a configuration problem: the entrypoint keeps whatever type it has, and + # no file -- which is a configuration problem: the entrypoint keeps whatever type it has, and # a launch fail-closes at ai-tools-run's transition preflight. A non-empty report that - # labelled nothing has already printed its own per-path none/skip reason above. + # labelled no file has already printed its own per-path none/skip reason above. if [[ "${labelled}" -eq 0 ]]; then if [[ -z "${report}" ]]; then warn "no agent resolved from the manifests, so no entrypoint was labelled." @@ -437,7 +437,7 @@ verify_agent_labels() { fi fi # Printed while the install is still running, so it states WHEN it applies: an operator who - # reads "exit and relaunch" mid-install has nothing to relaunch yet. + # reads "exit and relaunch" mid-install has no session to relaunch yet. log "once this install finishes: a session already running keeps its OLD context, so exit" log " and relaunch it, then confirm with: ps -eo label,cmd | grep '[c]laude' (expect ai_tools_t)" } @@ -476,7 +476,7 @@ _restore_one() { restorecon -FR "$1" 2>/dev/null || true; } # _label_sandbox_clones: apply the static ai_tools_project_t label (ai_tools.fc) to every existing # sandbox clone, then REPORT and VERIFY each one. The per-project loop below skips sandbox paths # (they carry no dynamic semanage rule -- the static rule covers them), so without this an operator -# sees no evidence the clones were relabelled even though they are the trees the agent runs in. +# is shown no evidence the clones were relabelled even though they are the trees the agent runs in. # The label is verified, not assumed: restorecon exits 0 even when it writes the WRONG type -- e.g. # an fcontext rule made unreachable because libselinux aliases its path prefix away # (file_contexts.subs_dist `/var/opt /opt`) -- so each clone's achieved label is checked and a @@ -563,7 +563,7 @@ case "${ACTION}" in install) section "Core module" - # The core module ships prebuilt, so a normal install needs no toolchain. Offer + # The core module ships prebuilt, so a normal install does not require a toolchain. Offer # a from-source rebuild (needs selinux-policy-devel) for anyone who edited the # .te/.fc -- default no. With no prebuilt package present we must build anyway. _recompile=0 diff --git a/selinux/policy/ai_tools.fc b/selinux/policy/ai_tools.fc index 159209f5..4cab430f 100644 --- a/selinux/policy/ai_tools.fc +++ b/selinux/policy/ai_tools.fc @@ -71,8 +71,8 @@ # The INTEGRATION state root: one directory per integration (integrations/<name>/...), holding # the sandbox-side state a host toolchain needs -- a package cache the agent writes, shared tools -# it only executes. ONE rule covers every integration present or future, so a new toolchain adds -# no policy: ai_tools_home_t grants the domain the access, and the DAC modes each integration +# it only executes. ONE rule covers every integration present or future, so a new toolchain +# does not need policy of its own: ai_tools_home_t grants the domain the access, and the DAC modes each integration # sets inside its own directory are the enforced read/write boundary. /opt/ai-tools/integrations(/.*)? gen_context(system_u:object_r:ai_tools_home_t,s0) diff --git a/selinux/policy/ai_tools.te b/selinux/policy/ai_tools.te index c2928137..ecd96935 100644 --- a/selinux/policy/ai_tools.te +++ b/selinux/policy/ai_tools.te @@ -367,7 +367,7 @@ allow ai_tools_t self:process execmem; # V8 JIT (writable+executable code page # cp, mv, install). Without setfscreate that call returns EACCES and libselinux prints a stderr # "failed to set default file creation context" warning on every such command. The grant only # lets the domain preset the label the files_tmp_filetrans type_transition already assigns, so it -# widens no producible label -- the file is ai_tools_tmp_t either way; this just silences the noise. +# does not widen the producible label -- the file is ai_tools_tmp_t either way; this just silences the noise. allow ai_tools_t self:process setfscreate; allow ai_tools_t self:fifo_file rw_fifo_file_perms; allow ai_tools_t self:unix_stream_socket create_stream_socket_perms; @@ -416,7 +416,7 @@ dontaudit ai_tools_t config_home_t:file { getattr ioctl open read }; dontaudit ai_tools_t container_file_t:file { open read }; dontaudit ai_tools_t sendmail_exec_t:file execute; -# (4) Benign device/fs probe noise -- denying these changes no behaviour (callers +# (4) Benign device/fs probe noise -- denying these leaves behaviour unchanged (callers # fall back), so silence rather than grant. # fs_t:filesystem getattr is ALLOWED above (find -xdev needs statfs()). dontaudit ai_tools_t { cgroup_t tmpfs_t }:filesystem getattr; @@ -442,8 +442,8 @@ init_dontaudit_getattr_initctl(ai_tools_t) # client can keylog/screenshot/inject into every window), so opening this path would hand # the agent the whole desktop, and reaching the cookie would breach the §2 home boundary. # The supported in-sandbox clipboard path is OSC 52 -- a terminal escape on the already- -# allowed pty -- which needs no socket and no policy grant. So this is silenced, NOT -# allowed, and is deliberately NOT an optional group: enabling it would buy nothing. +# allowed pty -- which requires neither a socket nor a policy grant. So this is silenced, NOT +# allowed, and is deliberately NOT an optional group: enabling it would buy no capability. # (connectto is peer-label-based, so it can only target the peer process type, not the # socket path; on a host with a confined X server the peer would be xserver_t.) dontaudit ai_tools_t unconfined_t:unix_stream_socket connectto; @@ -496,7 +496,7 @@ dontaudit ai_tools_t unconfined_t:unix_stream_socket connectto; # service with RestrictNamespaces=yes, whose seccomp filter blocks # clone(CLONE_NEWUSER) (and all namespace creation) for the session process # tree. seccomp runs before the LSM hook, so this is the enforcing layer; -# it is per-session and needs no sysctl. (System-wide alternative, rejected: +# it is per-session and does not need a sysctl. (System-wide alternative, rejected: # user.max_user_namespaces=0 -- breaks Firefox + rootless Podman host-wide.) # SECOND LAYER, if the base policy ever adds the permission: keep it a # VISIBLE denial by simply NOT allowing it to ai_tools_t -- do not add an diff --git a/selinux/policy/ai_tools_apphost.fc b/selinux/policy/ai_tools_apphost.fc index bd82a7b1..3d25b845 100644 --- a/selinux/policy/ai_tools_apphost.fc +++ b/selinux/policy/ai_tools_apphost.fc @@ -1,3 +1,3 @@ # SPDX-License-Identifier: GPL-2.0-or-later # ai_tools_apphost has no file-context rules: it grants a permission on the existing -# tmpfs_t type (anonymous memfd files), labelling nothing on disk. +# tmpfs_t type (anonymous memfd files), labelling no path on disk. diff --git a/selinux/policy/ai_tools_apphost.te b/selinux/policy/ai_tools_apphost.te index cb5f6c94..af41677a 100644 --- a/selinux/policy/ai_tools_apphost.te +++ b/selinux/policy/ai_tools_apphost.te @@ -17,7 +17,7 @@ policy_module(ai_tools_apphost, 0.1.0) ## into the dotnet integration or the base. ## ## SECURITY: this permits fileless in-memory execution (memfd + PROT_EXEC, the -## classic evasion primitive), but it grants no new PRIVILEGE. The domain already +## classic evasion primitive), but it does not grant a new PRIVILEGE. The domain already ## holds `execmem`, so in-process arbitrary native execution is already possible; ## this only extends it to a file-backed executable mapping. Code executed this way ## still runs as ai_tools_t -- there is no entrypoint from this domain to a more diff --git a/selinux/policy/ai_tools_netadmin.te b/selinux/policy/ai_tools_netadmin.te index bb0ddb3d..8ae124b1 100644 --- a/selinux/policy/ai_tools_netadmin.te +++ b/selinux/policy/ai_tools_netadmin.te @@ -17,7 +17,7 @@ policy_module(ai_tools_netadmin, 0.1.0) ## Why: firewall-cmd and nmcli are bin_t (already executable) but their ## privileged backends (firewalld_t, NetworkManager_t) communicate via ## D-Bus. ai_tools_t lacks dbus send_msg permission to those daemon types, -## so the command runs but gets no response." +## so the command runs without a response." ## ## After enabling, re-run the bring-up suite (avc-testsuite.sh + avc-analyze.sh): ## firewalld and NM interactions may surface additional D-Bus receive_msg or diff --git a/selinux/policy/ai_tools_netcore.fc b/selinux/policy/ai_tools_netcore.fc index 229d0e7a..b51411ab 100644 --- a/selinux/policy/ai_tools_netcore.fc +++ b/selinux/policy/ai_tools_netcore.fc @@ -1,3 +1,3 @@ # SPDX-License-Identifier: GPL-2.0-or-later # ai_tools_netcore has no file-context rules: it grants permissions on existing types -# (ai_tools_tmp_t, ai_tools_home_t, ai_tools_project_t, tmp_t), labelling nothing on disk. +# (ai_tools_tmp_t, ai_tools_home_t, ai_tools_project_t, tmp_t), labelling no path on disk. diff --git a/selinux/policy/ai_tools_netcore.te b/selinux/policy/ai_tools_netcore.te index 130b0047..cb31eb45 100644 --- a/selinux/policy/ai_tools_netcore.te +++ b/selinux/policy/ai_tools_netcore.te @@ -20,7 +20,7 @@ policy_module(ai_tools_netcore, 0.1.0) ## `dotnet test` reports it cannot connect and multi-node MSBuild hangs on its worker ## pipes. This section transitions and manages them, adds getsid(2) ## (process getsession), reads /proc/sys/net, and lets the runner connect(2) the -## ephemeral TCP port its test host binds (xUnit/VSTest). It widens no trust +## ephemeral TCP port its test host binds (xUnit/VSTest). It does not widen trust ## boundary: creating a socket or FIFO in the sandbox's own tmp/home, or reaching its ## own child over loopback, is the same benign class as the file/dir management the ## base already grants. @@ -29,7 +29,7 @@ policy_module(ai_tools_netcore, 0.1.0) ## project tree -- the apphost, a testhost, R2R-mapped assemblies under `dotnet ## exec` -- needs execute on ai_tools_project_t files, which the base grants nowhere. ## This IS a boundary: on-disk native code the sandbox wrote, executed as a new -## process image. It confers no new PRIVILEGE (execmem already concedes in-process +## process image. It does not grant a new PRIVILEGE (execmem already concedes in-process ## native code, and execute_no_trans keeps the child in ai_tools_t with no entrypoint ## to a more privileged domain), but it is the reason the whole module is off by ## default and experimental. execmod covers an R2R image relocated in place. diff --git a/src/etc/ai-tools/endpoints/custom-claude-endpoint.conf b/src/etc/ai-tools/endpoints/custom-claude-endpoint.conf index 4a4e70d8..f076bba8 100644 --- a/src/etc/ai-tools/endpoints/custom-claude-endpoint.conf +++ b/src/etc/ai-tools/endpoints/custom-claude-endpoint.conf @@ -4,7 +4,7 @@ # This file routes a session at a non-default Anthropic-compatible endpoint (a local proxy such as # service-proxy-ollama, or a custom gateway). It is READ ONLY WHEN operator.conf uncomments # CLAUDE_BASE_URL_FILE=/etc/ai-tools/endpoints/custom-claude-endpoint.conf -# and it ships INERT (every option commented), so uncommenting the pointer alone changes nothing +# and it ships INERT (every option commented), so uncommenting the pointer alone leaves the launch unchanged # until you set at least ANTHROPIC_BASE_URL below. # # Mode: 640 root:ai-tools -- NOT world-readable, because ANTHROPIC_AUTH_TOKEN is a credential. Only @@ -16,7 +16,7 @@ # REFUSES the launch rather than routing the session partially -- so a broken edit fails closed. # # ANTHROPIC_BASE_URL REQUIRED to enable the endpoint. An http(s) URL. A localhost URL -# needs no token; a remote one without a token is warned about. +# does not need a token; a remote one without a token is warned about. # ANTHROPIC_AUTH_TOKEN Bearer token for the endpoint. Passed to Claude Code by name, so # its value never appears on any process command line. # ANTHROPIC_MODEL Model label the endpoint resolves (an opaque single token; with a diff --git a/src/etc/ai-tools/operator.conf b/src/etc/ai-tools/operator.conf index da71e5cc..e1a4d090 100644 --- a/src/etc/ai-tools/operator.conf +++ b/src/etc/ai-tools/operator.conf @@ -1,6 +1,6 @@ # /etc/ai-tools/operator.conf -- ai-tools host configuration. # Parsed line by line as KEY=value pairs, never sourced. Root-write-only, world-readable -# (carries no secrets). This file must stay root-owned and non-group/other-writable: the +# (so it must never hold a secret). This file must stay root-owned and non-group/other-writable: the # provider gating below ignores it otherwise and falls back to the built-in defaults. # # Grammar (one parser for every key -- /usr/local/lib/ai-tools/conf.lib.sh): @@ -70,7 +70,7 @@ OPERATORS="@PROJECTS_USER@" # agent). ABSENT or empty (the default) leaves Claude Code's own default prompt untouched. Uncomment # the path to enable it; the wrapper reads that file at launch and passes it to Claude Code as # --append-system-prompt-file (or --system-prompt-file, see the mode below). The file's FULL contents -# are sent to the model verbatim -- nothing is stripped, so it must contain ONLY your prompt text (no +# are sent to the model verbatim -- no markup is stripped, so it must contain ONLY your prompt text (no # how-to notes: those would be sent too). The shipped default is EMPTY; write your prompt into it, or # better, keep your own prompt files under /etc/ai-tools/prompts/ (create them with sudo, root-owned # and not group/other-writable) and point this key at the one you want. The path must stay under diff --git a/src/opt/ai-tools/agents/claude-code/filter-hook.sh b/src/opt/ai-tools/agents/claude-code/filter-hook.sh index a7bc3541..24a589f8 100755 --- a/src/opt/ai-tools/agents/claude-code/filter-hook.sh +++ b/src/opt/ai-tools/agents/claude-code/filter-hook.sh @@ -19,7 +19,7 @@ # # Fail SOFT, deliberately -- the opposite of the fail-closed rule the security gates follow. # Filtering is a pure output path: a missing library, absent jq, a malformed event, a rule that -# does not apply all end the same way, with this hook emitting nothing and the command running +# does not apply all end the same way, with this hook emitting no output and the command running # exactly as the agent wrote it. There is no state in which failing here denies, alters or hides # anything, so refusing would only trade tokens for lost work. # @@ -45,8 +45,8 @@ hook_input="$(cat)" || exit 0 case "${event}" in pre-tool-use) - # One jq pass covers both the tool check and the extraction; a non-Bash event yields nothing. - # The matcher in settings.json already scopes this to Bash -- the check costs no extra fork + # One jq pass covers both the tool check and the extraction; a non-Bash event yields an empty result. + # The matcher in settings.json already scopes this to Bash -- the check does not add a fork # and keeps the script correct wherever it is wired. agent_command="$(jq -r 'if .tool_name == "Bash" then (.tool_input.command // empty) else empty end' \ <<< "${hook_input}" 2>/dev/null)" || exit 0 @@ -95,7 +95,7 @@ post-tool-use) filtered_stdout="${filtered_stdout%x}" filtered_stderr="$(printf '%s' "${raw_stderr}" | ai_tools_filter_strip_noise && printf x)" || exit 0 filtered_stderr="${filtered_stderr%x}" - # Nothing to say when the output carried no noise, which is the common case. + # No line to emit when the output carried no noise, which is the common case. [[ "${filtered_stdout}" != "${raw_stdout}" || "${filtered_stderr}" != "${raw_stderr}" ]] || exit 0 jq -c --arg out "${filtered_stdout}" --arg err "${filtered_stderr}" \ '{hookSpecificOutput: {hookEventName: "PostToolUse", diff --git a/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh b/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh index 50ecf546..258404a8 100755 --- a/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh +++ b/src/opt/ai-tools/agents/claude-code/post-tool-hook.sh @@ -6,7 +6,7 @@ # argument-less form additionally restores operator:ai-tools ownership. # # (no argument) Write|Edit -- record the call, then hand the written file back -# record Bash -- record the call only; a Bash write carries no +# record Bash -- record the call only; a Bash write does not report a # file_path and is swept at turn end instead # # Runs as ai-tools. It deliberately does NOT pre-check the approved-projects @@ -18,7 +18,7 @@ # # This hook only decides, cheaply and as ai-tools, whether a handback call is # even worth making. It exits early -- without calling the client -- when: -# - the tool input contains no file path +# - the tool input does not contain a file path # - the file is not owned by ai-tools (already handed back, or never agent-written) # # Ownership handback is delegated to the socket privilege bridge @@ -64,7 +64,7 @@ readonly RECORDED_LEADING_WORD_COUNT=2 readonly RECORD_FIELD_SEPARATOR=$'\037' # format_tool_call_record <hook-event-json> -- PRINT the audit-trail record for the tool call -# this event carries, or nothing when it cannot be read. Never fails the caller. +# this event carries, or an empty string when it cannot be read. Never fails the caller. # # The output is one 0x1F-delimited list: the human-readable MESSAGE first, then zero or more # `FIELD=value` pairs for the journal's native structured fields. Both renderings are built @@ -80,7 +80,7 @@ readonly RECORD_FIELD_SEPARATOR=$'\037' # command's FIRST LINE (each capped at MAX_RECORDED_WORD_LENGTH, a longer one marked `~`) # plus the count of words on that line. Taking the first line excludes a here-doc body by # construction rather than by a length cap -- `cat > f <<'EOF'` followed by a credential -# records `cmd="cat >" argc=4` and nothing of the payload -- and two words keep a command +# records `cmd="cat >" argc=4` and no part of the payload -- and two words keep a command # distinguishable from its subcommand (`git log` from `git push`). Recording the full # command line would make the trail carry unbounded file content. # @@ -96,13 +96,13 @@ readonly RECORD_FIELD_SEPARATOR=$'\037' # they are STRUCTURE, so a word containing them forges fields: a leading word of # `git" argc=0 cwd=/etc/passwd` would otherwise render as `cmd="git" argc=0" argc=8`, handing a # reader the planted argc. Reducing them to `?` makes the line's shape unforgeable while leaving -# it readable, and the variable-length part is placed LAST, so nothing the agent controls +# it readable, and the variable-length part is placed LAST, so no agent-controlled value # precedes a field a reader trusts. The class spells the surviving set as its two ranges: `!` # (0x21), `#`-`<` (0x23-0x3C, excluding space 0x20 and `"` 0x22), and `>`-`~` (0x3E-0x7E, # excluding `=` 0x3D). # # The structured FIELDS need none of that narrowing: journald's native protocol delimits each -# field itself, so a value cannot forge a sibling and needs no escaping. They therefore keep +# field itself, so a value cannot forge a sibling, and escaping is unnecessary. They therefore keep # what the MESSAGE reduces -- a path with a space stays a path with a space, where the MESSAGE # shows `?` -- and the shared logger applies its display allowlist to each on the way out. The # MESSAGE is the lossy human view; the fields are the faithful machine one. @@ -159,7 +159,7 @@ format_tool_call_record() { # ran no tools" from "the recorder was broken or bypassed", and the second reads as the first, # which is worse than no trail at all because it manufactures confidence. So a failure to # record is itself recorded, at WARNING, naming the gap. The reason is resolved only on the -# failure path, so the common case pays nothing for it, and `jq` is singled out because its +# failure path, so the common case does not pay for it, and `jq` is singled out because its # absence degrades every hook in the session (handback and sweeps included), not just this # line -- that is a host-level fault an operator must see, not a parse hiccup. record_tool_call() { @@ -188,7 +188,7 @@ record_tool_call() { # The handback call is made only for a path the agent itself wrote -- one currently owned by # @SANDBOX_USER@ -- which is exactly the set ai-tools-chown will act on (its own owner guard) # and the same signal the parent-dir walk uses, so an already-handed-back file -# (operator-owned, or a quarantined secret) makes no socket call. The root-owned validator +# (operator-owned, or a quarantined secret) does not call the socket. The root-owned validator # does the real work: it checks the allowlist (as root, which can read it), chowns + strips # world bits, and for secret-named files revokes ai-tools access and prints a NOTICE. That # stderr is deliberately NOT redirected to /dev/null, so Claude Code surfaces the NOTICE in @@ -229,7 +229,7 @@ main() { # An empty stdin is not a tool call the harness made: it means this hook ran outside the # session that feeds it (a hand invocation, a misconfigured declaration). Say so rather # than exiting mute, for the same reason record_tool_call reports its gaps -- but at the - # lower level, since nothing was lost from the trail here; there was nothing to record. + # lower level, since no record was lost from the trail here; there was no call to record. hook_event_json="$(cat)" || return 0 if [[ -z "${hook_event_json}" ]]; then ai_tools_log_info "PostToolUse invoked with no event on stdin -- nothing to record or hand back" @@ -238,8 +238,8 @@ main() { record_tool_call "${hook_event_json}" - # The Bash form records and stops: a Bash-created file carries no file_path, so there is - # nothing for the handback to act on (the Stop sweep catches those at turn end). + # The Bash form records and stops: a Bash-created file does not report a file_path, so there is + # no path for the handback to act on (the Stop sweep catches those at turn end). if [[ "${hook_invocation_mode}" == "record" ]]; then return 0 fi diff --git a/src/opt/ai-tools/agents/claude-code/session-hook.sh b/src/opt/ai-tools/agents/claude-code/session-hook.sh index 21d80ab6..054d1aca 100755 --- a/src/opt/ai-tools/agents/claude-code/session-hook.sh +++ b/src/opt/ai-tools/agents/claude-code/session-hook.sh @@ -12,8 +12,8 @@ # Runs as ai-tools. Reads the hook JSON on stdin for .cwd (the allowlisted project # root claude launched in) and sweeps there. Each path is handed to the root # validator ai-tools-chown (via the handback socket bridge), which independently -# re-checks the allowlist and the agent-owned guard -- so this sweep can reach -# nothing the precise hook could not. +# re-checks the allowlist and the agent-owned guard -- so this sweep cannot reach +# a path the precise hook could not. # # Three modes, selected by $1: # @@ -41,7 +41,7 @@ # # session-end -- SessionEnd hook, fires once when the process exits # gracefully. Removes the clean-exit marker (.session-active) and -# does nothing else. That marker is written at session-start and +# stops there. That marker is written at session-start and # cleared here; if it instead SURVIVES into the next session-start, # the previous session was killed before this ran (tokens # exhausted, crash, closed terminal). A surviving marker widens the @@ -93,7 +93,7 @@ readonly MARKER="${HOOK_DIR}/.sweep-marker" # as MARKER. readonly ACTIVE_MARKER="${HOOK_DIR}/.session-active" -# The handback socket every CHOWN runs over. When it is down the reclaim/sweep can hand nothing +# The handback socket every CHOWN runs over. When it is down the reclaim/sweep can hand back no path # back, so each pass checks it first and reports the stranded work rather than a count of failed # calls -- the failure mode that let a dead socket report a reassuring "Reclaimed N". readonly HANDBACK_SOCKET="/run/ai-tools/handback.sock" @@ -129,7 +129,7 @@ fi # Operator identity (PROJECTS_USER) from /etc/ai-tools/operator.conf via the shared resolver, # used only to render the reconcile command in the interrupted-session NOTICE below. Sweeping -# itself needs no operator identity -- it finds @SANDBOX_USER@-owned paths and the root +# itself does not need an operator identity -- it finds @SANDBOX_USER@-owned paths and the root # validator re-checks ownership. Best-effort: an unenrolled/missing config leaves PROJECTS_USER # empty, degrading only the suggested command's owner field. readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh" @@ -147,12 +147,12 @@ fi # emits. No PROJECT/.git -> echo 0. Used by the session-end reclaim and the session-start pass. reclaim_git_tree() { local proj="$1" n=0 path - # Socket down: nothing can be handed back -- report zero, not a count of failed calls. + # Socket down: no path can be handed back -- report zero, not a count of failed calls. [[ -S "${HANDBACK_SOCKET}" ]] || { printf '0'; return 0; } if [[ -n "${proj}" && -d "${proj}/.git" ]]; then while IFS= read -r -d '' path; do # Count CONFIRMED handbacks (client exit 0), not attempts, so the reported total - # reflects what actually changed owner. The client writes nothing to stdout, so + # reflects what changed owner. The client's stdout stays empty, so # using it as the `if` condition cannot corrupt this function's captured count, and # its stderr (MSG relays) still reaches the session. if /usr/local/bin/ai-tools-handback-client CHOWN "${path}"; then @@ -187,7 +187,7 @@ if [[ "${MODE}" == "session-end" ]]; then fi # Directory-skip selector from the shared library (single source of truth, shared with -# ai-tools-setgid / ai-tools-lockdown). A missing lib leaves a stub that skips nothing. +# ai-tools-setgid / ai-tools-lockdown). A missing lib leaves a stub that descends everywhere. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../../../usr/local/lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -197,7 +197,7 @@ source "${SKIP_DIRS_LIB}" 2>/dev/null \ # both .cwd and -- in session-start mode -- .source from the captured payload. payload="$(cat 2>/dev/null)" || exit 0 -# The session's working dir (allowlisted project root). No cwd -> nothing to do. +# The session's working dir (allowlisted project root). No cwd -> exit without acting. dir="$(jq -r '.cwd // empty' <<<"${payload}" 2>/dev/null)" || exit 0 [[ -n "${dir}" && -d "${dir}" ]] || exit 0 @@ -273,7 +273,7 @@ fi # A large sweep is the skip-list signal: hundreds of agent-owned paths per pass usually # means a build or dependency tree is handed back over and over. Journald-only (routine, -# nothing to act on in-session); the operator tunes the skip categories. +# no action for the operator in-session); the operator tunes the skip categories. if [[ "${swept}" -ge 200 ]]; then ai_tools_log_info "${MODE} sweep: handed back ${swept} paths -- a recurring build tree can be skipped via SKIP_ARTIFACT_DIRS in /etc/ai-tools/operator.conf (reference: /usr/local/lib/ai-tools/skip-dirs.lib.sh)" fi @@ -289,12 +289,12 @@ mv -f "${newref}" "${MARKER}" 2>/dev/null || rm -f "${newref}" 2>/dev/null || tr # additionalContext JSON this script emits on stdout. No PROJECT/.git -> echo 0. reclaim_git_tree() { local proj="$1" n=0 path - # Socket down: nothing can be handed back -- report zero, not a count of failed calls. + # Socket down: no path can be handed back -- report zero, not a count of failed calls. [[ -S "${HANDBACK_SOCKET}" ]] || { printf '0'; return 0; } if [[ -n "${proj}" && -d "${proj}/.git" ]]; then while IFS= read -r -d '' path; do # Count CONFIRMED handbacks (client exit 0), not attempts, so the reported total - # reflects what actually changed owner. The client writes nothing to stdout, so + # reflects what changed owner. The client's stdout stays empty, so # using it as the `if` condition cannot corrupt this function's captured count, and # its stderr (MSG relays) still reaches the session. if /usr/local/bin/ai-tools-handback-client CHOWN "${path}"; then @@ -309,7 +309,7 @@ reclaim_git_tree() { # count_git_agent_owned PROJECT -- number of @SANDBOX_USER@-owned paths under PROJECT/.git (0 if # there is no such tree). Used only when the socket is down, to tell whether there is stranded # git work to warn about, so a dead socket surfaces what it could NOT reclaim instead of the -# reclaim silently doing nothing. +# reclaim silently skipping its walk. count_git_agent_owned() { local proj="$1" [[ -n "${proj}" && -d "${proj}/.git" ]] || { printf '0'; return 0; } @@ -343,7 +343,7 @@ if [[ "${unbounded}" -eq 1 ]]; then # cross-project mixed ownership the agent should relay, with the manual reconcile for # stragglers the helper could not reach (excluded or quarantined paths). The routine # post-git-activity reclaim runs on essentially every session-start (the per-turn sweeps - # always skip .git) and has already repaired ownership, so there is nothing for the user + # always skip .git) and has already repaired ownership, so there is no action for the user # to act on; injecting additionalContext would only force a TUI re-render that clobbers # claude's startup banner. It therefore stays journald-only. total_found=$((git_found + prev_found)) @@ -366,7 +366,7 @@ if [[ "${unbounded}" -eq 1 ]]; then fi else # Socket DOWN: the reclaim cannot run. If agent-owned .git objects are stranded, surface - # that with the fix, instead of silently reclaiming nothing and reporting a reassuring + # that with the fix, instead of silently reclaiming no path and reporting a reassuring # count -- the exact condition that produced a misleading "Reclaimed N". Count the strand # under this session's project and, if a prior session was killed elsewhere, that project too. stranded="$(count_git_agent_owned "${dir}")" diff --git a/src/opt/ai-tools/bin/ai-tools-run.sh b/src/opt/ai-tools/bin/ai-tools-run.sh index a321adf6..7171faf3 100755 --- a/src/opt/ai-tools/bin/ai-tools-run.sh +++ b/src/opt/ai-tools/bin/ai-tools-run.sh @@ -19,7 +19,7 @@ # re-resolves it immediately before the launch. What that window is, and why it is a DAC-only # concern, are in launch.rule.md. # -# It names no agent. Which executables may launch, what environment each session gets, and +# It is agent-agnostic. Which executables may launch, what environment each session gets, and # whether the session's ownership handback needs driving from here come from the root-owned # provider manifests under /usr/local/lib/ai-tools/agents.d and the session-env fragments under # /usr/local/lib/ai-tools/session-env.d. @@ -182,7 +182,7 @@ resolve_entrypoint() { # size, and ctime at nanosecond precision. Each of the three ways a same-uid process can swap an # entrypoint moves it: a symlink repoint and a rename-over both land a different inode, and an # in-place write bumps ctime (which no unprivileged caller can roll back -- utimes(2) sets atime -# and mtime, never ctime). Prints nothing when the path cannot be stat'd, which compares unequal. +# and mtime, never ctime). Prints an empty string when the path cannot be stat'd, which compares unequal. entrypoint_identity() { stat -c '%d:%i:%s:%z' -- "$1" 2>/dev/null || true } @@ -230,11 +230,11 @@ if command -v getenforce >/dev/null 2>&1; then expected_label="$(matchpathcon -n "${entrypoint_path}" 2>/dev/null | awk -F: '{print $3}' || true)" actual_label="$(stat -c '%C' -- "${entrypoint_path}" 2>/dev/null | awk -F: '{print $3}' || true)" # Module presence for the verdict, WITHOUT reading the root-only module store: this runs as - # @SANDBOX_USER@, so `semodule -l` returns nothing -- a systematic false "no" that, on the + # @SANDBOX_USER@, so `semodule -l` returns an empty list -- a systematic false "no" that, on the # unresolved-label branch, would fail OPEN (launch DAC-only where a half-installed host must # refuse). A CORE-owned path resolves to an ai_tools_* type IFF the core module's # file-contexts are live, and matchpathcon reads the world-readable file-contexts from the - # path string, so the probe needs no privilege and the agent cannot influence it. This + # path string, so the probe does not need privilege and the agent cannot influence it. This # distinguishes a half-installed host (module live, entrypoint unlabelled -> refuse) from a # DAC-only host (module absent -> launch), which the store read could not from this account. module_present="$(ai_tools_confinement_module_present \ @@ -313,7 +313,7 @@ fi # ownership". This is NOT a confinement boundary -- DAC, the ai_tools_t type, and the project's # user:<operator> ACL keep the operator's access intact regardless -- so a down socket WARNS and # proceeds rather than refusing the launch (a refusal would trade availability for a non-security -# convenience). Skipped for a diagnostic run with no project directory, which writes nothing to +# convenience). Skipped for a diagnostic run with no project directory, which writes to no project under # hand back. The reconcile commands are printed plain, below the frame, so they stay paste-safe. readonly HANDBACK_SOCKET="/run/ai-tools/handback.sock" if [[ -n "${session_working_directory}" && ! -S "${HANDBACK_SOCKET}" ]]; then @@ -325,7 +325,7 @@ if [[ -n "${session_working_directory}" && ! -S "${HANDBACK_SOCKET}" ]]; then fi # ── Session environment ────────────────────────────────────────────────────────────────────── -# A service unit is spawned by the user manager with ITS OWN environment, so nothing crosses +# A service unit is spawned by the user manager with ITS OWN environment, so no variable crosses # into the session unless named here. Only terminal-, locale-, and connectivity-shaping # variables are forwarded by name; the operator's API keys, tokens, SSH_AUTH_SOCK, and cloud # credentials stay out by construction, independent of sudo's env_reset/env_keep. @@ -401,12 +401,12 @@ session_environment_options+=( "--setenv=PATH=${session_path}" ) # # The walk only chooses which paths to OFFER: each one goes through the handback socket to # ai-tools-chown, which re-validates the allowlist, the exclusions, and the born-owner guard as -# root, so this reaches nothing the hooks could not. +# root, so this sweep cannot reach a path the hooks could not. readonly HANDBACK_CLIENT="/usr/local/bin/ai-tools-handback-client" # Directory-skip selector, shared with the hooks and the root helpers. Fail-SOFT by its own # design -- a skip list is walk cost, not an access boundary -- so a missing lib leaves a stub -# that skips nothing: a slower, more thorough sweep, never a narrower one. +# that descends everywhere: a slower, more thorough sweep, never a narrower one. # shellcheck source=SCRIPTDIR/../../../usr/local/lib/ai-tools/skip-dirs.lib.sh source "${AI_TOOLS_LIB_DIR}/skip-dirs.lib.sh" 2>/dev/null \ || ai_tools_skip_find_expr() { AI_TOOLS_SKIP_FIND_EXPR=(); return 0; } @@ -418,13 +418,13 @@ sweep_project_ownership() { [[ -n "${session_working_directory}" && -d "${session_working_directory}" ]] || return 0 [[ -x "${HANDBACK_CLIENT}" ]] || return 0 # A down socket fails every CHOWN, so skip the walk and record that once, rather than logging - # a reassuring count of calls that changed nothing (the failure mode this whole change fixes). + # a reassuring count of calls that changed no ownership (the failure mode this whole change fixes). if [[ ! -S "${HANDBACK_SOCKET}" ]]; then audit warning "session-end sweep skipped: handback socket ${HANDBACK_SOCKET} is down -- files under ${session_working_directory} stay @SANDBOX_USER@-owned (reclaim with: ai-tools --reclaim ${session_working_directory})" return 0 fi # The "reclaim" consumer omits the heavy dependency/build trees but WALKS .git -- the tree - # the per-turn hooks skip, and which nothing else on this path would reach. + # the per-turn hooks skip, and which no other pass on this path would reach. ai_tools_skip_find_expr reclaim '' "${session_working_directory}" # Count CONFIRMED handbacks (client exit 0), not attempts, so the audit line reflects what # actually changed owner; a non-zero exit is either a routine skip (a path the root helper diff --git a/src/opt/ai-tools/bin/nvm-update.sh b/src/opt/ai-tools/bin/nvm-update.sh index 26d9210a..002305ed 100755 --- a/src/opt/ai-tools/bin/nvm-update.sh +++ b/src/opt/ai-tools/bin/nvm-update.sh @@ -22,9 +22,9 @@ # 0 the toolchain is current. # 1 a fault on this host: something is broken or untrustworthy and it will still be broken on a # retry (no nvm, no curl, an unset alias, a failed signature check). -# 3 transient: the registry could not be reached, so NOTHING was changed and the previous, +# 3 transient: the registry could not be reached, so the toolchain was left alone and the previous, # trusted toolchain stays active and installed. The unit retries this one; the stamp records -# it as `skipped` rather than a failure, because an offline host has nothing to fix. +# it as `skipped` rather than a failure, because an offline host has no fault to fix. # The split is deliberately coarse. It is not a diagnosis of why the registry was unreachable -- a # disconnected laptop and a registry outage are one state from here -- only of whether a retry is # the right response and whether an operator should be alarmed now. A transient condition that @@ -62,8 +62,8 @@ die() { echo "ERROR: $*" >&2; printf '%s\n' "$*" | systemd-cat -t "nvm-update-a # skip <reason-token> <message> : end the run as TRANSIENT (see the header) -- the counterpart to # die for a condition this host did not cause and cannot fix, where the correct outcome is that -# nothing changed. It is a notice, not an error: it exits EXIT_TRANSIENT, so the unit retries it, -# and records <reason-token> in the stamp, so `ai-tools --status` can say WHY a run did nothing +# the toolchain is left alone. It is a notice, not an error: it exits EXIT_TRANSIENT, so the unit retries it, +# and records <reason-token> in the stamp, so `ai-tools --status` can say WHY a run made no change # instead of reporting a failure the operator would go looking for. skip() { _run_skip_reason="$1"; shift echo "SKIP : $*" >&2; printf '%s\n' "$*" | systemd-cat -t "nvm-update-ai" -p notice 2>/dev/null || true @@ -72,8 +72,8 @@ skip() { _run_skip_reason="$1"; shift # write_stamp <exit-code>: record this run's outcome where the operator can read it, in the # KEY=value grammar services.lib.sh parses (RESULT, EXIT_CODE, FINISHED, TRIGGER, NODE, and REASON # on a skip). RESULT is the exit status classified for a reader: ok / failed / `skipped` for the -# transient case, which is reported as a run that correctly did nothing rather than as a fault -- -# an offline host has nothing for an operator to fix, and calling it FAILED spends attention that +# transient case, which is reported as a run that correctly declined to act rather than as a fault -- +# an offline host has no fault for an operator to fix, and calling it FAILED spends attention that # a real fault then has to compete with. Installed # as the EXIT trap, so it records EVERY exit path -- a die, an uncaught set -e failure, and a clean # run alike; without it a failed run is visible only in the sandbox account's journal, which the @@ -81,9 +81,9 @@ skip() { _run_skip_reason="$1"; shift # into a failed unit, so every step tolerates failure and the function always returns 0. # # TRIGGER says whether SYSTEMD started this run, which is what makes the run evidence about the -# TIMER rather than only about the update: nvm-update.timer publishes no state an operator session -# can reach, so `ai-tools --status` infers its health from a run having happened -- and a run this -# script did by hand proves nothing about a schedule. systemd sets INVOCATION_ID for every unit it +# TIMER rather than only about the update: no state nvm-update.timer publishes is reachable from an operator +# session, so `ai-tools --status` infers its health from a run having happened -- and a run this +# script did by hand is no evidence about a schedule. systemd sets INVOCATION_ID for every unit it # starts, so its presence separates the two. A run started by hand THROUGH the manager # (`systemctl --user start nvm-update.service`) is indistinguishable from a triggered one and # counts as `unit`; the inference is bounded to systemd-started runs, not to scheduled ones. @@ -93,7 +93,7 @@ skip() { _run_skip_reason="$1"; shift # above) -- but it also means the file must already exist, so an absent one is a broken/partial # install and is reported as such rather than silently skipped. A single write of the whole text # keeps the window in which a reader sees a partial stamp negligible; should one land there anyway, -# the reader finds no parseable RESULT and reports the unit unknown, never a wrong verdict. +# the reader sees an unparseable RESULT and reports the unit unknown, never a wrong verdict. write_stamp() { local rc="$1" result=failed node_version=unknown trigger=manual reason="" text case "${rc}" in @@ -220,7 +220,7 @@ verify_agent_entrypoints() { # agent_package_version <entrypoint>: print the MAJOR.MINOR.PATCH the package beside the entrypoint # declares, walking up to the nearest package.json. Bounded read; anything not semver yields -# nothing, so a crafted value cannot become part of a URL. +# an empty string, so a crafted value cannot become part of a URL. agent_package_version() { local dir="${1%/*}" declared for _ in 1 2 3; do @@ -352,7 +352,7 @@ main() { command -v curl >/dev/null 2>&1 || die "curl required to resolve the latest version" # The `|| true` is load-bearing, the same way the emitters' is. A BARE assignment takes the # exit status of its command substitution, so under `set -e -o pipefail` a registry this host - # cannot reach (or a grep that matches nothing) aborts the updater ON THIS LINE -- silently, + # cannot reach (or a grep with no match) aborts the updater ON THIS LINE -- silently, # since nvm's own error is discarded above and the die below never runs. Absorbing the status # here is what lets the emptiness be REPORTED, by the check that was always meant to. target_version="$( @@ -360,7 +360,7 @@ main() { | grep -oP 'v[0-9]+\.[0-9]+\.[0-9]+' \ | sort -V | tail -1 )" || true - # Transient, not a fault: nothing here is broken, the registry simply was not reachable, and + # Transient, not a fault: no component here is broken, the registry was not reachable, and # the host keeps the toolchain it already has. See the header's exit-status contract. [[ -n "${target_version}" ]] \ || skip offline "could not resolve the latest v${major} from nvm ls-remote (registry unreachable) -- keeping ${current_version}; nothing was changed" @@ -380,8 +380,8 @@ main() { # The enabled agents -- their npm packages (installed below) and their launchers (repointed # at the end) -- come from the manifests via providers.lib.sh, the same seam - # ai-tools-bootstrap uses, so this updater names no agent. Guarded load: providers.lib.sh - # returns non-zero and defines nothing when its own dependency (conf.lib.sh, the shared + # ai-tools-bootstrap uses, so this updater is agent-agnostic. Guarded load: providers.lib.sh + # returns non-zero and does not define its resolvers when its own dependency (conf.lib.sh, the shared # KEY=value grammar) is missing, so probe the resolver rather than assume the source # succeeded -- a bare `source` under set -e would abort the run instead of degrading. A # missing lib is a broken install (root-owned, so not agent action): existing agents keep @@ -428,7 +428,7 @@ main() { prune_versions "${node_alias}" # Refresh the stable launcher symlink each wrapper resolves with one readlink hop -- one per - # enabled agent, from the same manifest-resolved set installed above, so the updater names no + # enabled agent, from the same manifest-resolved set installed above, so the updater stays agent-agnostic, naming no # agent here either. A launcher missing from the new version (an agent whose install failed, # or one an operator dropped from AI_TOOLS_AGENTS) is skipped rather than failing the run. # /opt/ai-tools/bin is locked 0551 (root:ai-tools) so this process -- running as ai-tools -- @@ -443,7 +443,7 @@ main() { continue fi # Not activated: leaving the stable symlink where it is keeps the previously verified and - # pinned version in use, so the next launch works and this run changed nothing for it. + # pinned version in use, so the next launch works and this run left it as it was. if [[ -n "${entrypoint_blocked[${launcher}]:-}" ]]; then warn "not repointing ${AI_TOOLS_BIN}/${launcher}: its new entrypoint is unverified (see above); the previously verified version stays active" continue @@ -462,8 +462,8 @@ main() { log "Done. Active: $(nvm version "${node_alias}")" # Reported LAST, after every agent that did verify has been repointed, so one unverifiable - # agent never strands another. TRANSIENT rather than failed: nothing is broken and nothing - # changed for that agent. It goes through the stamp because that is the only channel an + # agent never strands another. TRANSIENT rather than failed: no component is broken and that + # agent is left as it was. It goes through the stamp because that is the only channel an # operator session can read at all -- see the stamp's own section in this file's header. (( entrypoint_unverified == 0 )) \ || skip unverified "${entrypoint_unverified} agent entrypoint(s) could not be verified against their vendor's signed release manifest -- not activated; the previously verified version stays in use" diff --git a/src/usr/local/bin/ai-tools-handback-client.py b/src/usr/local/bin/ai-tools-handback-client.py index d1998bfc..85e6d4aa 100644 --- a/src/usr/local/bin/ai-tools-handback-client.py +++ b/src/usr/local/bin/ai-tools-handback-client.py @@ -43,7 +43,7 @@ def main(): sock.connect(_SOCK_PATH) except OSError as exc: # Name the socket and the likely cause: a bare "[Errno 2] No such file - # or directory" from connect() gives the caller nothing to act on. + # or directory" from connect() gives the caller no next step. sys.stderr.write( 'ai-tools-handback-client: cannot reach the handback socket %s ' '(%s) -- is ai-tools-handback.socket running?\n' diff --git a/src/usr/local/bin/ai-tools.sh b/src/usr/local/bin/ai-tools.sh index bb47f67b..ecff978e 100755 --- a/src/usr/local/bin/ai-tools.sh +++ b/src/usr/local/bin/ai-tools.sh @@ -8,13 +8,13 @@ # -- and reaches the root-owned bits # -- the git safe.directory list in /opt/ai-tools/.gitconfig, the SELinux label, the ACL, and # secret lockdown -- through the sudo root helpers (no NOPASSWD: the operator is prompted for a -# password; the sandbox account holds no grant). +# password; the sandbox account has no grant). # # Four preflight gates run before dispatch: require_bootstrap (provisioned install); for the # operator-acting commands (--project-*/--sandbox-*/--lockdown/--reclaim/--relabel), # require_operator -- the invoking user must be in OPERATORS in operator.conf, since the root # helpers resolve the caller's identity from that list; require_sudo_access, which refuses a verb -# whose root helper this caller holds no sudo grant for, before sudo prompts for a password it +# whose root helper this caller does not hold a sudo grant for, before sudo prompts for a password it # will then reject; and require_for_target, which validates a --for run and re-points the registry # at its target. --help/--version/--list/--providers stay open to any user. # @@ -26,7 +26,7 @@ # --for <operator> performs a command ON BEHALF OF another enrolled operator: the allowlist entry # lands in THEIR registry, so ai-tools-setfacl grants user:<them>, the handback restores to them, # and their agent's launch gate covers the path. It exists for a service account that runs an -# agent but holds no password to authenticate a claim of its own. The target's registry is +# agent without holding a password to authenticate a claim of its own. The target's registry is # unreadable to the invoker (0600 in a 0700 directory), so a --for run reads a root-side snapshot # of it and routes its writes through ai-tools-allowlist. # @@ -35,7 +35,7 @@ # default: cwd); -y/--yes pre-answers its proceed prompt (delegated) # --project-create <path> create a NEW project directory (one mkdir, git init, README.md) # and claim it; refuses a path that already exists and one whose -# parent does not, and takes no cwd default -- the cwd always exists +# parent does not, and has no cwd default -- the cwd always exists # --project-unclaim [path] release a project -- revoke the agent's access and hand the tree # back to your own group (or a named user's), the agent's write # removed; the directory is left on disk. --keep-entry parks the @@ -92,7 +92,7 @@ AI_TOOLS_VERSION="@AI_TOOLS_VERSION@" readonly AI_TOOLS_VERSION # AI_TOOLS_GITCONFIG / AI_TOOLS_ALLOWLIST (below): root-only test hooks, the same family the # root helpers carry (see tests.rule.md). The CLI runs as the operator, who owns both files -# anyway, so an override widens nothing it could not already do by editing them directly; sudo +# anyway, so an override does not add reach it could not already have by editing them directly; sudo # strips both (env_reset, not env_keep) before any root helper, which re-resolves the real paths # itself, and the sandbox account is refused by the principal guard below before either is read. readonly GITCONFIG="${AI_TOOLS_GITCONFIG:-/opt/ai-tools/.gitconfig}" @@ -165,9 +165,9 @@ readonly GUARD_MARKER="ai-tools-lockdown-guard" # ROOT_ALLOWED_VERBS -- what root may run. The criterion is WRITES NO OPERATOR-OWNED STATE, which # is what the root guard exists to protect: a registry written by root names an owner whose own # launch gate cannot read it. A verb qualifies on what it writes rather than on what it reads, so -# --stop belongs here despite being the one member that ACTS: it writes no registry, and root is +# --stop belongs here despite being the one member that ACTS: it does not write a registry, and root is # the identity an unattended detector usually runs as -- the caller this rung most has to serve. -# Admitting it grants nothing new either, since root can already run ai-tools-stop directly and +# Admitting it does not add a capability either, since root can already run ai-tools-stop directly and # can signal any process on the host; what it removes is a CLI that refused the one principal its # own helper requires. Read by the principal guard below, by that guard's own refusal (which lists # them), and by ai-tools(1). @@ -175,7 +175,7 @@ readonly ROOT_ALLOWED_VERBS=(--audit --status --list --providers --stop) # BOOTSTRAP_EXEMPT_VERBS -- what runs on an unprovisioned host. Deliberately NOT the set above: # each of these is meant for a host that may be broken (--status reports the unprovisioned state # itself; --audit reads a historical trail, which an install that never finished does not -# invalidate; --stop ends sessions already running, and needs nothing from the toolchain to do it +# invalidate; --stop ends sessions already running, and does not read toolchain state to do it # -- the gate keys on ONE agent's launcher symlink, so leaving --stop behind it would put the # incident ladder's last rung out of reach on a host that enables a different agent, or that lost # that symlink while sessions were running). --help, --version and the bare invocation describe @@ -262,7 +262,7 @@ unset _forless_args # Root may run the verbs that write no registry -- the four reports, plus --stop -- and no other. # --audit is why the carve-out exists: the trail it reads is 700 root:root, so the verb needs root # by construction, and a blanket refusal left it unreachable from BOTH sides on a host whose only -# operator holds no general sudo grant. --stop is here for the mirror of that reason: its helper +# operator does not hold a general sudo grant. --stop is here for the mirror of that reason: its helper # requires root, and an incident response running as root should reach the rung through the same # command an operator uses. The mutating verbs keep refusing root for the reason this # guard has always existed -- they would write the operator registries owned by root, where that @@ -313,7 +313,7 @@ readonly FOR_OPERATOR OWNER_USER # apply, what --list reports) would otherwise read an unreadable file as an empty one. One # resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that sets # AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see the -# GITCONFIG note above for why the override grants the CLI's operator caller nothing new. +# GITCONFIG note above for why the override grants the CLI's operator caller no new capability. ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}" # ── Output / prompt helpers ────────────────────────────────────────────────────── @@ -410,7 +410,7 @@ fi # Skip-dir selector (the single skip source shared with the sweeps and the claim helpers). # The claim drift scan uses it to tell repairable hits from skip-listed ones. Fail-soft: a -# missing lib classifies nothing as skip-listed -- a noisier report, never a wrong repair +# missing lib classifies every path as walkable -- a noisier report, never a wrong repair # (the root helpers load their own copy for the walks). readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../lib/ai-tools/skip-dirs.lib.sh @@ -505,7 +505,7 @@ sudo_grant_missing() { } # ── Reacting to a root step that did not apply ─────────────────────────────────── -# Every root helper authenticates on its own, and nothing can be pre-authenticated for a flow: a +# Every root helper authenticates on its own, and no step can be pre-authenticated for a flow: a # hardened sudoers may set timestamp_timeout=0, where a credential is never cached and every # invocation prompts, so there is no credential to obtain up front. One mistyped password therefore # costs a full round of attempts PER STEP -- a claim asks nine times, an unclaim over three nested @@ -545,7 +545,7 @@ note_root_failure() { # a default-NO prompt is answered ahead of time only by the CLI's own --yes flag -- the # launch wrapper passes it for a delegated --project-claim after taking its own # confirmation, so the claim's proceed prompt does not ask a second time. -# have_tty: true only when a controlling terminal can actually be opened. `[[ -r /dev/tty ]]` +# have_tty: true only when a controlling terminal can be opened. `[[ -r /dev/tty ]]` # tests the node's permission bits (crw-rw-rw-), not openability, so it reads true even with no # controlling terminal (e.g. a systemd unit or under setsid); opening /dev/tty is the only honest # probe -- with no controlling tty the open fails ENXIO, so the prompt guards skip cleanly instead @@ -593,13 +593,13 @@ require_sandbox_clone() { } # run_as_owner <cmd> [args...] -- run <cmd> as the operator this run acts FOR. Without --for that -# is the invoker, so the command runs directly and nothing is prefixed; with --for it is the +# is the invoker, so the command runs directly with no prefix; with --for it is the # target, and the command runs under `sudo -u <target> -H`. The seam every step that must touch # the filesystem AS AN OWNER goes through. # -# It grants nothing new. `sudo -u <target>` rides the caller's GENERAL sudo grant -- the separate -# authority axis CLAUDE.md names, which nothing in this project writes or records -- so an operator -# who reaches it could already act as that account. The sandbox account holds no sudo rule and runs +# It uses a grant the caller already holds. `sudo -u <target>` rides the caller's GENERAL sudo grant -- the separate +# authority axis CLAUDE.md names, which this project neither writes nor records -- so an operator +# who reaches it could already act as that account. The sandbox account does not hold a sudo rule and runs # under PR_SET_NO_NEW_PRIVS, which drops sudo's SUID bit, so it reaches none of this. # # -H is load-bearing rather than tidiness: without it (and without sudoers' always_set_home) sudo @@ -763,7 +763,7 @@ unreg_allow() { # Reported and fatal, never silent. The rewrite lands a temporary file in the allowlist's own # DIRECTORY, so it fails on a config dir this operator cannot write even when the allowlist # itself is writable -- which under `set -e` used to abort the whole command with sed's bare I/O - # error and its exit status, telling the operator nothing about what was left registered. + # error and its exit status, telling the operator no more about what was left registered. if ! ai_tools_conf_allowlist_remove "${ALLOWLIST}" "${dir}"; then warn "could not remove ${dir} from allowed-projects -- a line naming it survived. While an allow line stands the agent can still launch there, and a '!' line left behind parks the path against a future claim. Remove it by hand:" for raw in "${lines[@]}"; do @@ -911,7 +911,7 @@ dir_owngap() { # wrong group: owned by the operator or the sandbox account, group not SANDBOX_GROUP, yet # with group/other permission bits set. Creation under a claimed tree inherits the group # (setgid) and the ACLs (default entries); a path lacking both arrived by rename(2) -- mv -# from outside the tree preserves the old group and inherits nothing -- and the agent gets +# from outside the tree preserves the old group and inherits neither setgid nor the ACL -- and the agent gets # EACCES on it deep inside an allowlisted project. Owner-only paths (600/700: locked-down # secrets, deliberately private files) and '!'-excluded subtrees are not reported -- out of # the agent's reach by intent. Read-only and unprivileged, detection only: the repair runs @@ -934,7 +934,7 @@ acl_drift_scan() { # sealed_setgid_scan <dir> -- list owner-only directories inside a claimed tree whose setgid bit # carries a THIRD-party group: neither SANDBOX_GROUP nor the group of the directory's own owner. # When the claim walks seal a path they clear a setgid bit belonging to one of those two, since a -# claimed tree carries no other legitimately; any further group reads as a deliberate operator +# claimed tree has no other legitimately; any further group reads as a deliberate operator # choice and is kept (owner-only.lib.sh). That leaves the operator the one who decides, so the # claim has to say so rather than act. Read-only and unprivileged, detection only -- a path # reported here is one the claim did NOT touch, so reporting it never widens access. @@ -1079,10 +1079,10 @@ reg_reach() { for a in "${REACH_GRANT[@]}"; do say " ${a}"; done # The owner's own HOME ROOT is the one entry in that list whose consequence has to be stated, - # and what to state is a CONDITION rather than an assertion of exposure. `--x` conveys no - # listing of the directory and nothing at all about the files in it -- each file's own mode + # and what to state is a CONDITION rather than an assertion of exposure. `--x` does not convey a + # listing of the directory, nor any access to the files in it -- each file's own mode # and ACL still decides, and the sandbox account is neither their owner nor in their group. - # So the grant opens nothing; it makes already-world-readable entries REACHABLE. Under + # So the grant does not open a file; it makes already-world-readable entries REACHABLE. Under # umask 077 that set is empty; under the RHEL default 022 it is the 644 skel files and # anything else written world-readable. Which of those this host is, is a question with a # one-line answer, so the prompt names the command instead of guessing. @@ -1131,7 +1131,7 @@ reg_reach() { # normalize_clone <dir> [locked-path...] -- make a freshly created clone # agent-accessible. The clone is born in group SANDBOX_GROUP via the setgid SANDBOX_ROOT -# but cloned under umask 077 (see cmd_sandbox_create), so nothing in it is +# but cloned under umask 077 (see cmd_sandbox_create), so no file in it is # group-readable until this step. Add group rwX and the setgid bit on every directory # (owner stays the projects user); the SessionStart ai-tools-setgid pass keeps it # normalized thereafter. Every <locked-path> (the secret gate's finds, locked to @@ -1273,7 +1273,7 @@ secret_gate() { } # drop_lockdown_guard <dir> -- write a placeholder CLAUDE.md telling the agent to -# do nothing until lockdown runs, used when a fresh sandbox clone's tip-commit +# wait until lockdown runs, used when a fresh sandbox clone's tip-commit # secrets are still readable. An existing CLAUDE.md is preserved as CLAUDE.md.bak # (via git mv, falling back to a plain mv) and restored by clear_lockdown_guard. drop_lockdown_guard() { @@ -1500,7 +1500,7 @@ under_skip_listed_name() { return 1 } -# tree_is_pristine <dir> -- 0 when <dir> holds nothing but what --project-create just put there: +# tree_is_pristine <dir> -- 0 when <dir> holds only what --project-create just put there: # no file outside .git except README.md, and a git repository with no commits. Cheap and # unprivileged. # @@ -1521,7 +1521,7 @@ tree_is_pristine() { # and ai-tools-setfacl -- act only on those two owners, so a project root held by anyone else # takes neither the group/setgid change nor the ACL, while the allowlist entry, the git # safe.directory entry and the SELinux label all still apply. The claim would close with its ✓ -# having granted nothing, and the agent could not enter the tree. +# having granted no access at all, and the agent could not enter the tree. # # The case this exists for is a --for claim: `mkdir ~/proj && ai-tools --project-claim --for svc # ~/proj` resolves the owner to svc, so every inode in the tree fails the helpers' guard. This is @@ -1575,9 +1575,9 @@ cmd_project_claim() { # A PARKED project is answered here, ahead of the flow, rather than at the registry write it # would otherwise reach last. Two reasons it belongs up front: the exclusion decides whether # any of the steps below can apply at all -- while it stands the root helpers resolve no owner - # and do nothing -- and the proceed confirm is what a run with no terminal answers first, so a + # and skip every step -- and the proceed confirm is what a run with no terminal answers first, so a # check behind it would never be reached by exactly the runs that most need telling. Declining - # aborts the claim with nothing written. + # aborts the claim before any write. if [[ "$(allow_state "${d}")" == disabled ]]; then offer_reenable "${d}" "the claim" \ || die "allowed-projects not updated -- ${d} is still disabled" @@ -1599,7 +1599,7 @@ cmd_project_claim() { local need_acl=false; [[ "${acl}" == true ]] && need_acl=true local need_git=false; [[ "${git}" == true ]] && need_git=true - # Interior drift: the root-level state says nothing about paths brought INTO a claimed + # Interior drift: the root-level state does not cover a path brought INTO a claimed # tree without inheriting the group/ACL (mv keeps the old group). Detect them here; # the repair applies further down behind the same confirm + secret gate as the other # in-place steps. Scanned only on a RE-CLAIM whose ownership is already in place: a @@ -1664,7 +1664,7 @@ cmd_project_claim() { heavy=true fi # NOT said on a pristine tree: every sentence below is false for one. There are no previous - # permissions to modify, nothing that is not reversible, and nothing to back up -- the tree was + # permissions to modify, no irreversible change, and no content to back up -- the tree was # empty a moment ago. A warning that is routinely untrue is what teaches an operator to click # through the ones that are not, so silence is the more careful choice here. if ${heavy} && ! ${fresh}; then @@ -1753,8 +1753,8 @@ cmd_project_claim() { # place now?" confirmation, so a delegated claim does not ask the same question # twice. The scoped opt-ins below (secret lockdown, .git history, ancestor traversal) # still ask on their own terms. - # Skipped for a pristine tree along with the warnings it exists to authorize: with nothing - # pre-existing to expose, this asks the operator to approve the command they just typed, and + # Skipped for a pristine tree along with the warnings it exists to authorize: with no + # pre-existing content to expose, this asks the operator to approve the command they just typed, and # its own subject ("apply the pending steps IN PLACE") describes a tree that has no contents # to apply them to. if ${heavy} && ! ${fresh}; then @@ -1784,7 +1784,7 @@ cmd_project_claim() { # Inferred, not asked. The question is about exposing history, and a repository with # no commits has none; normalizing is meanwhile the outcome the operator wants either # way, since it is what keeps THEIR later commits readable by the agent. Asking would - # offer a choice between one real option and one that costs them something for nothing. + # offer a choice between one real option and one that costs them something for no gain. do_git=true say " .git: normalizing for shared history (new repository -- no history to expose)" else @@ -1805,7 +1805,7 @@ cmd_project_claim() { headline "Applying claim steps" "${d}" # A failed step asks once before the next is attempted (note_root_failure). Stopping is the - # safe direction here -- fewer steps applied -- and costs nothing, since the claim is + # safe direction here -- fewer steps applied -- and costs the operator no work, since the claim is # idempotent and a re-run does exactly what is still missing. local stopped=false if [[ "${safedir}" != true ]]; then @@ -1830,7 +1830,7 @@ cmd_project_claim() { # A claim whose access-granting steps did not apply has NOT claimed anything, and must not say # it has. This is the owner guard's rule at the other end of the flow: no ✓ over a project the # agent cannot work in. The registry entries stand, so a re-run applies exactly what is - # missing -- which is why stopping early costs nothing. + # missing -- which is why stopping early costs the operator no work. if (( ROOT_STEP_FAILURES )); then headline_warn "WARNING: the claim did not complete" \ "${d} is registered, but ${ROOT_STEP_FAILURES} step(s) that grant the agent access did not apply, so it cannot work there yet. Each is named above with the command that applies it. Re-running the claim is the simpler route -- it is idempotent and does only what is still missing:" @@ -1860,11 +1860,11 @@ cmd_project_claim() { # the resolved operator or the sandbox account holds, so a tree born owned by the invoker is one # require_claimable_owner then refuses. cmd_project_create() { - # No -y: this verb asks nothing that a flag could pre-answer. Its own confirmation would be a + # No -y: this verb does not ask a question a flag could pre-answer. Its own confirmation would be a # request to approve the command just typed over a tree that does not exist yet, and the claim # that follows infers the rest from the tree being empty (see tree_is_pristine). The one # question that can still appear -- the traverse grant on an ancestor -- widens access ABOVE - # the project and is deliberately answerable by nothing but a person at a terminal. + # the project and is deliberately answerable only by a person at a terminal. local a path="" for a in "$@"; do case "${a}" in @@ -1918,7 +1918,7 @@ cmd_project_create() { # State the blocker and why no grant covers it, and stop there. The claim's own version of # this refusal points at --sandbox-create, which does not apply here: that verb clones an # EXISTING repository into the sandbox area, and this verb's whole subject is a project - # that does not exist yet, so there is nothing to name as its source. + # that does not exist yet, so there is no source to name. local why blocked_owner blocked_owner="$(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || true)" if [[ -z "${blocked_owner}" ]]; then @@ -1949,7 +1949,7 @@ cmd_project_create() { die "project create stopped -- the agent could not reach a project at that location" fi - # ── Apply. Deliberately ONE block, not a review followed by an apply: this verb takes no + # ── Apply. Deliberately ONE block, not a review followed by an apply: this verb does not ask for # confirmation, so a pending list would announce three steps whose result lines follow # immediately underneath -- the same information twice -- and the claim below opens with a # pending list of its own, which made the pair read as one repeated block. ── @@ -1959,7 +1959,7 @@ cmd_project_create() { # /etc/login.defs default on many hosts, and what a PAM session hands this command -- a new # directory is born 0700, which ai-tools-setgid and ai-tools-setfacl both honour as the # operator's standing SEAL and leave alone. The claim would then report a NOTICE and grant - # nothing, producing a registered project the agent can never enter: the one outcome this + # no access, producing a registered project the agent can never enter: the one outcome this # verb exists to avoid. # # The seal is a statement about a path the operator restricted deliberately. A umask is not @@ -1996,7 +1996,7 @@ cmd_project_create() { # subtree with it. The claim below reports that it is normalizing .git for shared history, # so leaving it sealed would make that line untrue. g+rX only: group read, and traverse on # directories. The agent's WRITE access comes from the claim's ACL, exactly as for the - # work tree, so nothing here grants more than reachability. + # work tree, so this step grants reachability alone. run_as_owner chmod -R g+rX -- "${d}/.git" \ || warn "could not open .git for the agent -- git history may stay out of its reach" say " git: initialized an empty repository" @@ -2030,7 +2030,7 @@ cmd_project_create() { # resolvable project directory (canonicalized), one per line, skipping blanks, comments, # and '!' exclusions. Read with the shared config grammar so it agrees with cmd_list and # the launch wrapper on what a line denotes. Stale (unresolvable) lines are omitted -- they -# name nothing on disk, so they can neither be nor contain an unclaim target. +# name no path on disk, so they can neither be nor contain an unclaim target. positive_project_entries() { local entry dir [[ -f "${ALLOWLIST}" ]] || return 0 @@ -2089,8 +2089,8 @@ inside_listed_project() { # exclusion is the one registry edit in this file that WIDENS what the agent reaches, and on a # subtree an operator withheld from a project that is the whole point of the line. So the enabling # paths -- the verb and the claim's prompt -- act only on a parked PROJECT, and a carve-out is -# refused back to the editor it was written in. Disabling needs no such guard: it moves the other -# way, and a path with no entry of its own is already refused for having nothing to park. +# refused back to the editor it was written in. Disabling does not need that guard: it moves the other +# way, and a path with no entry of its own is already refused for having no entry to park. refuse_carveout() { local d="$1" verb="$2" parent="" inside_listed_project "${d}" || return 0 @@ -2109,7 +2109,7 @@ refuse_carveout() { # refuse_nested_park <dir> <verb> -- the other half of keeping a '!' line unambiguous. Parking a # project that sits INSIDE another listed project would write a line indistinguishable from a -# carve-out (an operator's exclusion withholding a subtree), and nothing in the file could tell the +# carve-out (an operator's exclusion withholding a subtree), and no field in the file could tell the # two apart afterwards -- so re-enabling it later could only be a guess, on an edit that WIDENS what # the agent reaches. No verb writes that line: with this refusal in place, every exclusion inside a # listed project is a carve-out by construction, which is exactly what refuse_carveout relies on. @@ -2187,7 +2187,7 @@ covered_by_project() { } # not_covered_die <dir> -- the shared refusal for a per-project verb whose target no allowlist -# entry covers. It separates the two ways that happens, because the remedies have nothing in +# entry covers. It separates the two ways that happens, because the remedies share no step in # common: a DISABLED project is registered and parked, so the fix is one command and the helpers # would refuse it anyway (they resolve a path's owner through the same matcher, where an exclusion # wins); anything else was never claimed. The old message said "not a claimed project" for both, @@ -2231,7 +2231,7 @@ unclaim_one() { fi fi - # The filesystem hand-back is the step that actually revokes the agent's access to the FILES, + # The filesystem hand-back is the step that revokes the agent's access to the FILES, # so a failure here is the one an operator must not be able to miss: everything else this # function does is registry work, which stops the agent launching here but leaves the tree # group-owned by it. Recorded rather than merely warned about, and reported in the close. @@ -2286,7 +2286,7 @@ unclaim_one() { # undeletable_scan <dir> -- print every directory under <dir> the ACTING OWNER can neither write # nor traverse, one per line, capped. Read-only and run AS that owner, so it answers the question -# the removal actually depends on: rm -rf needs write+execute on a directory to unlink what is in +# the removal depends on: rm -rf needs write+execute on a directory to unlink what is in # it, and the realistic blocker is a sandbox-owned 0700 directory a session left behind. # # This is the pre-flight that keeps --project-remove from having the one failure mode a @@ -2329,7 +2329,7 @@ residue_scan() { # TWO results and sets both as globals in the CALLER's shell: # HANDBACK_GROUP the target group; empty means "unregister only, leave permissions alone". # HANDBACK_HINT non-empty when a hand-back was wanted but cannot run, so the caller prints -# the manual command instead of silently doing nothing. +# the manual command instead of silently skipping the step. # Globals, not stdout, precisely BECAUSE there are two: a `$(...)` capture runs the function in a # subshell, where the second result is lost -- and reading it back under `set -u` aborts the whole # unclaim before it touches anything. Prompts draw on /dev/tty and warnings on stderr, so a caller @@ -2370,7 +2370,7 @@ resolve_handback_group() { # cmd_unclaim_unlisted <dir> <force> <full> <dry> <assume-yes> <group-opt> -- the UNRELATED # branch: no allowlist entry covers <dir>. Detection guides; only --force acts, and even then the # helper touches a path solely while it still carries the ai-tools fingerprint, so running this on -# a directory that was never claimed changes nothing at all. That per-path gate -- not any +# a directory that was never claimed leaves every path as it found it. That per-path gate -- not any # conservatism about which bits to write -- is what makes the mode safe on a mistyped path: what # it DOES to a path it accepts is identical to a registered unclaim. cmd_unclaim_unlisted() { @@ -2498,7 +2498,7 @@ cmd_unclaim_unlisted() { # fingerprint: a claimed project copied or moved here and never unclaimed. Detection # only GUIDES; acting needs an explicit --force, which swaps the allowlist gate for # a per-path residue gate in the helper. -# UNRELATED, clean refuse -- nothing here was ever claimed, so there is nothing to undo. +# UNRELATED, clean refuse -- no part of this tree was ever claimed, so there is no change to undo. # A protected system path is refused up front. For a registered project this only guards a # hand-edited allowlist (claim/setgid/setfacl never let one become a claimed project), whose # cleanup ai-tools --list reports; --force never relaxes it. @@ -2610,7 +2610,7 @@ cmd_project_unclaim() { # A parked target is answered BEFORE this verb asks its own question, because it decides what # the run can DO rather than being a step inside it: ai-tools-unclaim resolves this path's # owner through the allowlist, where an exclusion wins, so on a disabled project the hand-back - # would exit 0 having handed nothing back -- and the flow would close with a ✓ over files that + # would exit 0 having handed back no path -- and the flow would close with a ✓ over files that # still carry the agent's group. # # Declining does NOT abort. That would be the one wrong answer here: the registry reversal is @@ -2683,7 +2683,7 @@ cmd_project_unclaim() { # Mixed tree: the registered projects are done, but ai-tools residue can still sit elsewhere # under this path (another copy, a leftover from a tree that was never registered). Reported - # only when --force asked about residue in the first place, so the common path pays no scan. + # only when --force asked about residue in the first place, so the common path does not pay for a scan. # The projects just unclaimed are no longer registered, so a re-run now classifies the whole # path as unrelated and the one command finishes the job. if ${force} && [[ "${mode}" == ancestor ]]; then @@ -2705,14 +2705,14 @@ cmd_project_unclaim() { # --project-unclaim stays the non-destructive reversal, and is what these refusals point at. # # AUTHORIZATION IS THE REGISTRY ENTRY, and only an EXACT one. There is no --force: the flag exists -# on unclaim to reach a tree the allowlist does not name, and "delete a tree nothing registered" +# on unclaim to reach a tree the allowlist does not name, and "delete a tree no registry names" # is not an operation this verb should offer at all -- that is `--project-unclaim --force` followed # by an rm the operator types themselves, where the destructive step is theirs. # # This verb sits OUTSIDE the "every refusal moves to less access" table in CLAUDE.md, and the # difference is worth naming rather than letting it read as an exception: the predicates in that # table decide what a session may REACH, so their safe direction is to grant less. This one decides -# what is DESTROYED, so its safe direction is to do nothing -- which is why its gate is an exact +# what is DESTROYED, so its safe direction is inaction -- which is why its gate is an exact # registry entry plus a typed confirmation rather than one of the launch predicates. # # Teardown order is registries first, deletion last. A failure at the deletion therefore leaves an @@ -2749,7 +2749,7 @@ cmd_project_remove() { [[ -d "${d}" ]] || die "not a directory: ${d}" ai_tools_assert_safe_target "${d}" "project remove" || exit 3 - # ── Classification: an EXACT entry, and nothing else, is a removal target. ── + # ── Classification: an EXACT entry, and only that, is a removal target. ── # The entry may be a parked one (project_entries): a '!' line is the same line, written by the # same operator, and it records "not right now" rather than "not mine" -- so it authorizes the # removal exactly as an active entry does. Requiring the operator to re-enable a project first @@ -2786,7 +2786,7 @@ cmd_project_remove() { fi # An exact entry that CONTAINS other claimed projects. rm -rf would take them with it, leaving - # each one's allowlist entry, safe.directory line and SELinux label pointing at nothing -- and + # each one's allowlist entry, safe.directory line and SELinux label pointing at a path that is gone -- and # on a shared host one of them may belong to another operator. Refused rather than cascaded: # this verb deletes one project, and the nested ones are their own decisions. # @@ -2844,7 +2844,7 @@ cmd_project_remove() { # A parked project gets its own notice and its own default-NO confirm, BEFORE the deletion # warning below: the operator parked this tree deliberately, so "you disabled this on purpose" # is a different question from "this deletes everything", and answering the second does not - # answer the first. Nothing is re-enabled -- the removal needs no launch gate open, and the + # answer the first. No entry is re-enabled -- the removal does not need a launch gate open, and the # allowlist line goes with the tree. if [[ "$(allow_state "${d}")" == disabled ]]; then headline_warn "This project is disabled" \ @@ -2929,7 +2929,7 @@ cmd_project_remove() { # rolled back on a failed gate), the secret-lockdown gate, then -- strictly past the # gate -- normalize (pruning the locked paths), relabel, and register. FAIL CLOSED: a # declined or failed gate leaves the clone on disk but private to the operator -- cloned -# under umask 077, so nothing in it is group-readable -- not normalized, not relabelled, +# under umask 077, so no file in it is group-readable -- not normalized, not relabelled, # not registered, with a guard CLAUDE.md dropped and the resume command printed. # Re-running --sandbox-create on the existing clone path resumes here. sandbox_finalize() { @@ -2975,7 +2975,7 @@ sandbox_finalize() { # "sandbox/<leaf>", where <leaf> is <from>'s last path component (so a fork of develop defaults to # sandbox/develop, and origin/feature/x to sandbox/x). The literal "sandbox" carries NO host, # machine, or operator identity by design -- the branch is pushed to a shared remote, so the default -# must leak nothing about who or where created it. It is only a DEFAULT: the operator overrides the +# must leak no detail of who or where created it. It is only a DEFAULT: the operator overrides the # whole name with --branch (or the prompt), and any valid git ref is accepted, so the sandbox # workflow is not tied to this shape. Pure; unit-tested (tests/unit/sandbox.sh). sandbox_default_branch() { @@ -3071,7 +3071,7 @@ cmd_sandbox_create() { # Resolve every input BEFORE any push or checkout: a flag wins, else the prompt (interactive) or # the default (no tty). So an Enter-through reproduces the previous shape and a fully-flagged run - # needs no terminal, while a bad value stops here rather than after the push. + # does not need a terminal, while a bad value stops here rather than after the push. # Base to fork from -- defaults to the current branch, but can be any base (e.g. main for a # hotfix while you sit on develop): a local branch, a <remote>/<base>, or any ref. @@ -3205,7 +3205,7 @@ cmd_sandbox_remove() { rm -rf "${d}" unreg_allow "${d}" # `|| true`: the clone is already gone, so a safe.directory entry that could not be removed is - # a stale line pointing at nothing -- reported by ai-tools --list, and not a reason to abort a + # a stale line pointing at a path that is gone -- reported by ai-tools --list, and not a reason to abort a # removal that has already happened. unreg_safedir signals failure now, and a bare call under # set -e would do exactly that. unreg_safedir "${d}" || true @@ -3248,9 +3248,9 @@ cmd_lockdown() { # ── Enable / disable a claimed project ─────────────────────────────────────────── # The pair that makes parking a project a supported operation rather than a text edit the tools # misread. Both are pure REGISTRY edits: the tree keeps its group, its ACLs, its setgid bits and -# its SELinux label, so disabling costs nothing to undo and re-enabling grants nothing that was -# not already granted -- which is why neither runs the claim's secret gate, and why disable takes -# no confirmation (it moves to LESS access; the launch gate simply stops opening). +# its SELinux label, so disabling changes the registry alone and re-enabling restores the entry +# alone -- which is why neither runs the claim's secret gate, and why disable does not ask for +# confirmation (it moves to LESS access; the launch gate stops opening). # # What disabling changes is everything downstream of the allowlist, and the report says so: no # session starts there, and the root helpers -- which resolve a path's owner through the same @@ -3362,8 +3362,8 @@ cmd_reclaim() { say " ${d}${C_DIM}$(${full} && printf ' (--full: incl. node_modules, .venv, ...)')${C_RST}" say " ${C_DIM}-> ${OWNER_USER}:${SANDBOX_GROUP} (secret-named files stay ${OWNER_USER}:${OWNER_GROUP} 600)${C_RST}" # The helper reports the outcome itself -- the pre-scan count, the one whole-set - # confirm, then "handed back N" / "nothing to reclaim" / "declined" -- so no blanket - # success line here: the CLI states only what actually happened. + # confirm, then the "handed back N" / "nothing to reclaim" / "declined" line -- so no blanket prose-check: allow + # success line here: the CLI states only what happened. run_reclaim "${d}" "${passthru[@]}" || die "reclaim failed for ${d}" ai_tools_log_info "reclaim run for ${d}$(${full} && printf ' (full)')" } @@ -3402,7 +3402,7 @@ cmd_relabel() { # cmd_audit -- report what has refused, been rejected, been stranded or been flagged since a # given time. A thin pass-through to the root helper, which does the reading and the rendering: -# the trail is 700 root:root, so there is nothing this unprivileged CLI could usefully do with +# the trail is 700 root:root, so there is no use this unprivileged CLI could make of # it first. The helper's EXIT STATUS is propagated deliberately -- non-zero means findings -- # so `ai-tools --audit` is usable from cron or a login banner without parsing its output, the # same contract --status already offers. @@ -3413,8 +3413,8 @@ cmd_audit() { } # cmd_stop -- terminate every running agent session, through ai-tools-stop. Thin by design, and the -# thinness is the whole contract: the command takes no target and no authorization input, so there -# is nothing on this side to decide. What a stop reaches follows from membership of the sandbox +# thinness is the whole contract: the command accepts neither a target nor an authorization input, +# so this side has no decision left to make. What a stop reaches follows from membership of the sandbox # account's cgroup slice, which only the root helper can read, and every remaining decision is a # security decision that must not be made twice in two places. Option grammar is all that lives # here. Why the command is shaped this way: docs/session-stop.md. @@ -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 groups` is + # without it there is no accurate report 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 @@ -3649,7 +3649,7 @@ list_maintenance_note() { # status_fmt_age <seconds> -- render an age the way an operator reads it ("3 days ago"), not as a # duration to be mentally subtracted from now. Coarsens with distance: the exact minute matters for -# a run that just happened and not at all for one from last week. Empty input prints nothing, so a +# a run that just happened and not at all for one from last week. Empty input prints an empty string, so a # caller can drop the clause entirely when the age is unknown. status_fmt_age() { local s="${1:-}" @@ -3700,7 +3700,7 @@ status_sandbox_unit_commands() { # read through the SAME accessors -- charset-clamped fields and one age implementation - rather than # a second reader that could drift. Its path comes from entrypoint-verify.lib.sh, never hardcoded. # -# Returns non-zero only when an unpinned entrypoint is actually actionable, which is exactly when +# Returns non-zero only when an unpinned entrypoint is actionable, which is exactly when # the operator has required verification: everywhere else unpinned is a legitimate state (an # air-gapped host, a release the vendor published no manifest for) and must not make a healthy host # alarm, the same rule the unqueryable units follow. A pin this account cannot read is reported as @@ -3724,7 +3724,7 @@ status_entrypoint_pins() { local agent pin version verified age seen=0 unpinned=0 mislabelled=0 while IFS=$'\t' read -r agent _ _; do [[ -n "${agent}" ]] || continue - # An agent whose package declares no release manifest has nothing to verify against, so it + # An agent whose package does not declare a release manifest has no published checksum to verify against, so it # is left out entirely rather than reported as perpetually unpinned. [[ -n "$(ai_tools_agent_manifest_field "${agent}" release_manifest_url 2>/dev/null || true)" ]] || continue (( seen++ == 0 )) && section "Entrypoint verification" @@ -3851,7 +3851,7 @@ cmd_status() { mode="$(ai_tools_service_field "${rec}" 8)" state="$(ai_tools_service_state_of "${rec}")" # A stamped unit is reported from its LAST RUN, not live, so every line says WHEN -- relative - # first, since "3 days ago" is the part an operator acts on. An unknown age prints nothing + # first, since "3 days ago" is the part an operator acts on. An unknown age omits the relative form # rather than a placeholder. age=""; when="" if [[ -n "${stamp}" ]]; then @@ -3868,8 +3868,8 @@ cmd_status() { printf ' %-28s %sOK%s%s\n' "${unit}" "${C_GRN}" "${C_RST}" "${when}" fi ;; down) printf ' %-28s %sDOWN%s\n' "${unit}" "${C_YEL}" "${C_RST}" ;; - # A run that correctly did nothing (the updater with an unreachable registry) is dim, - # not yellow: yellow is this report's attention colour, and there is nothing to attend + # A run that correctly declined to act (the updater with an unreachable registry) is dim, + # not yellow: yellow is this report's attention colour, and there is no fault to attend # to -- the previous toolchain is intact and the next run will try again. If the # condition persists the line turns STALE on its own once the stamp ages past its # grace, which is where the operator is meant to look. @@ -3930,7 +3930,7 @@ cmd_status() { say " ai-tools --list registered projects (real and sandbox)" say " ai-tools --help the full command list" - # Exit non-zero when anything is actually broken, so --status is usable unattended (a cron + # Exit non-zero when something is broken, so --status is usable unattended (a cron # check, a monitor) without parsing this output. 'unknown' and 'n/a' are not faults and do not # count -- an unqueryable unit must not make a healthy host alarm every night. [[ "${problems}" -eq 0 ]] @@ -3974,7 +3974,7 @@ cmd_list() { } # _has_glob <str> -- 0 when <str> carries a shell glob metacharacter (* ? [). Globs are # honored only in '!' exclusion lines (both the wrapper and ai-tools-chown match them as - # globs); an allow line is realpath'd, so a glob there resolves to nothing and is inert. + # globs); an allow line is realpath'd, so a glob there resolves to no path and is inert. _has_glob() { [[ "$1" == *[*?[]* ]]; } # _remove_line_cmd <raw-line> -- the copy-paste sed that deletes the VERBATIM allowlist line # (comment and all), so it matches what is stored even when the entry carries an end-of-line @@ -4024,7 +4024,7 @@ cmd_list() { # report has a `disabled` row: an exclusion INSIDE a listed project is a carve-out (a # subtree withheld from the agent, working exactly as intended), while one that no # listed project contains is a PARKED PROJECT -- the operator took it out of service - # and will want it back. A carve-out needs no remedy; a parked project is shown with + # and will want it back. A carve-out has no remedy to offer; a parked project is shown with # the verb that restores it, in place. if ! _has_glob "${excl}" && [[ -d "${excl}" ]] && ! inside_listed_project "${excl}"; then printf ' %-8s %-50s %s\n' "disabled" "${excl}" "${C_DIM}no session may start here${C_RST}" @@ -4032,7 +4032,7 @@ cmd_list() { else printf ' %-8s %s\n' "exclude" "${excl}" fi - # A stale exclusion excludes nothing. Flag a non-glob '!' path that no longer exists; + # A stale exclusion does not match a path on disk. Flag a non-glob '!' path that no longer exists; # a glob exclusion is valid as written (it need not resolve today), so leave it. if ! _has_glob "${excl}" && ! realpath -e "${excl}" >/dev/null 2>&1; then cleanup+=( " ${entry}" \ @@ -4042,7 +4042,7 @@ cmd_list() { continue fi # A glob in an ALLOW line is silently inert -- the wrapper realpath's allow entries, so - # the pattern resolves to nothing and never gates a launch. Flag it rather than letting it + # the pattern resolves to no path and never gates a launch. Flag it rather than letting it # masquerade as a claimable project (globs belong on '!' lines). if _has_glob "${entry}"; then printf ' %-8s %-50s %s\n' "unusable" "${entry}" "${C_YEL}glob in allow line${C_RST}" @@ -4067,7 +4067,7 @@ cmd_list() { (( shown )) || say " (none)" # Reverse reconciliation: a git safe.directory entry with no matching allowlist line is an - # ORPHAN -- git still trusts the tree though nothing lists it (the allowlist line was + # ORPHAN -- git still trusts the tree though no allowlist line names it (the line was # hand-deleted, or an unclaim was interrupted before the safedir drop). Removing the stale # safedir (and its label) is the cleanup; the entry is not a claimed project, so it is not # offered --project-unclaim, which would refuse an unlisted target. Control-plane entries @@ -4098,7 +4098,7 @@ cmd_list() { # # The two are paired by tests/unit/man.sh, which asserts: the VERB sets match in both directions, # every long option named here is documented in the page, and every option the page's OPTIONS -# section documents is one a CLI parser actually accepts. So a verb added, renamed, or removed here +# section documents is one a CLI parser accepts. So a verb added, renamed, or removed here # changes the page in the same commit, and an option that outlives its parser fails the suite. # # The layout is load-bearing for that test: a verb line is indented FOUR spaces and starts with its @@ -4212,8 +4212,8 @@ handover_target() { printf '%s' "${first_named:-${PWD}}" } -# require_sudo_access <verb> [verb-args...] -- refuse a verb whose root helper this caller holds no -# sudo grant for, and say who can run it instead. +# require_sudo_access <verb> [verb-args...] -- refuse a verb whose root helper this caller does not +# hold a sudo grant for, and say who can run it instead. # # The case it exists for is an ai-ops-only account: in the operators group, in no sudoers rule. # That is a supported shape, not a misconfiguration -- it is what --for was built for -- and it is @@ -4238,13 +4238,13 @@ require_sudo_access() { # it reaches is the safe.directory registration, not the lockdown scan. --project-create) bin="${SAFEDIR_BIN}" what="creating a project"; delegable=true ;; --project-unclaim) bin="${UNCLAIM_BIN}" what="unclaiming a project"; delegable=true ;; - # --project-remove runs no ai-tools-unclaim: it deletes the tree instead of handing it + # --project-remove does not run ai-tools-unclaim: it deletes the tree instead of handing it # back, so the first helper it reaches is the safe.directory de-registration. --project-remove) bin="${SAFEDIR_BIN}" what="removing a project"; delegable=true ;; --sandbox-create) bin="${LOCKDOWN_BIN}" what="creating a sandbox clone" ;; - # The enable/disable pair edits ONE line of the caller's own registry and reaches no root + # The enable/disable pair edits ONE line of the caller's own registry and does not reach a root # helper at all, so a plain run is not probed: refusing it for a missing grant would deny a - # no-sudo service account the one pair of verbs it needs no help with. Only the --for form + # no-sudo service account the one pair of verbs it can run unaided. Only the --for form # needs one, where ai-tools-allowlist performs the edit on another operator's file. --project-enable|--project-disable) [[ -n "${FOR_OPERATOR}" ]] || return 0 @@ -4257,7 +4257,7 @@ require_sudo_access() { # account holding no general grant, since %ai-ops carries a dedicated NOPASSWD rule for # each of their helpers. Probing either is harmless -- `sudo -n -l <helper>` answers exit 0 # for those rules even though the drop-in pins each to its helper's zero-argument form (the - # trailing ""), because the probe passes no operand. Listing them here would only ever + # trailing ""), because the probe does not pass an operand. Listing them here would only ever # produce "grant present", so they stay out and the verbs reach sudo directly, which # reports a missing drop-in itself. # The pin also means --stop's FLAGGED forms fall outside the rule and meet sudo's ordinary @@ -4277,7 +4277,7 @@ require_sudo_access() { # account belongs to is not knowable here -- a service account, a person with a restricted # login, an administrator working from one deliberately -- and neither is who runs the # suggested command or what they are to each other. So there is no advice to obtain a grant, - # and nothing is described as anyone's: a message that guesses the arrangement is wrong in + # and the account is not described as anyone's: a message that guesses the arrangement is wrong in # exactly the deployments this refusal exists for. local -a advice=("Ask an administrator or an ai-ops operator with sudo to run:" "") if [[ -n "${FOR_OPERATOR}" ]]; then @@ -4294,8 +4294,8 @@ require_sudo_access() { source_dir="$(handover_target "$@")" clone_dir="${SANDBOX_ROOT}/$(basename "${source_dir}")" # The chown is not optional bookkeeping: the clone is created by whoever runs the first - # command, and a claim FOR another operator over a tree that operator does not own grants - # nothing (require_claimable_owner refuses it). Three commands, because the middle one is + # command, and a claim FOR another operator over a tree that operator does not own leaves + # the agent without access (require_claimable_owner refuses it). Three commands, because the middle one is # the only thing that makes the third do anything. advice+=( " ai-tools --sandbox-create ${source_dir}" \ " sudo chown -R ${INVOKING_USER} ${clone_dir}" \ @@ -4308,7 +4308,7 @@ require_sudo_access() { advice+=( " ai-tools ${verb}${rest}" ) fi # The trail is written to journald as well, which many hosts let an ordinary account read -- - # a partial view (the file sink is the authoritative one) but one that needs no one else. + # a partial view (the file sink is the authoritative one) but one the operator reads unaided. [[ "${verb}" == --audit ]] && advice+=( "" \ "Some of the same events reach the journal, readable without root on many hosts:" "" \ " journalctl -p notice --since '7 days ago' | grep ai-tools" ) @@ -4333,12 +4333,12 @@ require_sudo_access() { # # --project-claim runs steps as the acting operator too -- its traverse ACL, its drift probe, its # core.filemode pin -- and is not probed here. Each of those warns and continues on its own, so a -# claim on a Runas-restricted host loses those steps individually and leaves no part-built tree. +# claim on a Runas-restricted host loses those steps individually and leaves the tree whole. # Probing would refuse the whole claim over one step the rest does not need. # -# Each command the run actually executes is probed rather than one representative, for the reason +# Each command the run executes is probed rather than one representative, for the reason # require_sudo_access gives: a sudoers permitting some and not others is then answered accurately. -# `sudo -n -l -u <target> <cmd>` cannot prompt, so this costs no password, and it runs before +# `sudo -n -l -u <target> <cmd>` cannot prompt, so this costs the caller no password, and it runs before # require_for_target's snapshot -- the run's first real sudo -- like every other refusal here. require_runas_target() { local verb="${1:-}" @@ -4391,7 +4391,7 @@ snapshot_allowlist() { } # require_for_target <verb> [verb-args...] -- validate a --for run, resolve the target's group, and -# re-point ALLOWLIST at the target's registry. A no-op without the flag, so nothing below changes +# re-point ALLOWLIST at the target's registry. A no-op without the flag, so no code below changes # for an ordinary run. # # EVERY refusal here precedes snapshot_allowlist, which is the run's first sudo: a command that is @@ -4403,7 +4403,7 @@ snapshot_allowlist() { # covers the path: the registry pair, the two per-project root helpers that gate on allowlist # coverage, and the listing. Elsewhere it is REFUSED rather than ignored -- a --sandbox-create # --for that silently cloned as the invoker would leave the tree owned by the wrong operator with -# nothing to show the flag was disregarded. +# no output to show the flag was disregarded. # # The target must be ENROLLED in OPERATORS: ai-tools-setfacl and the handback helpers resolve a # path's owner over that list, so an entry written for an unenrolled name would create a launch diff --git a/src/usr/local/bin/claude.sh b/src/usr/local/bin/claude.sh index 5a6c08a2..08841ded 100755 --- a/src/usr/local/bin/claude.sh +++ b/src/usr/local/bin/claude.sh @@ -275,7 +275,7 @@ if [[ "${#excluded[@]}" -gt 0 ]]; then # Guarded on the count, not written as "${allowed[@]:-}": an EMPTY array expands # that way to one empty element, and "${dir}/"* is then the pattern /* -- which # matches every absolute path, so a parked project with no approved entries at all - # would report as carved out of nothing. + # would report as carved out of an empty set. if [[ "${#allowed[@]}" -gt 0 ]]; then for dir in "${allowed[@]}"; do [[ "${cwd}" == "${dir}/"* ]] || continue @@ -309,7 +309,7 @@ if [[ "${approved}" != true ]]; then # the consequence that distinguishes it -- option 1 does not start a session here). ai_tools_msg_block "Set up this project for the sandboxed agent" \ "The agent has no access here yet. Choose how it should work on this project." - # No terminal: take Cancel and refuse to launch, without asking. The menu itself carries + # No terminal: take Cancel and refuse to launch, without asking. The menu itself has # no default (it re-asks, then gives up), so the safe outcome of an unattended or piped # run is decided HERE, by the have_tty branch, rather than by a default index. sel=3 @@ -368,7 +368,7 @@ fi # chgrp or a relabel itself, it only detects, offers, and (on consent) calls the CLI: # ownership -- group not ai-tools, or no group-execute. The sandbox user runs with # this dir as its cwd, and Node's posix_spawn then fails EACCES on every -# child (hooks, the Bash tool): the session starts but can do nothing. +# child (hooks, the Bash tool): the session starts but cannot spawn a child. # FATAL. Closing it grants the agent recursive group access to this real # tree (a chgrp) -- the heavy LAST-RESORT path; the clean alternative is # an isolated sandbox clone, recommended first. @@ -462,7 +462,7 @@ if ${own_gap} || ${label_gap}; then elif ${safe_gap}; then # Ownership and label hold; the git safe.directory entry is the one piece missing. Offer to # register it via the SAFEDIR_BIN sudo helper -- the path reg_safedir uses (see - # ai-tools-safedir for the 644/sudo model). Defaults YES (a restrict-nothing change for a + # ai-tools-safedir for the 644/sudo model). Defaults YES (a change that takes away no access, for a # tree already approved to launch in); a non-interactive launch prints the command instead. ai_tools_msg_notice \ "claude: ${cwd} is not in git safe.directory; git will report \"dubious ownership\" here until it is registered." @@ -512,7 +512,7 @@ fi # dedicated NOTICE in ai-tools-run (services.lib marks it preflight=shim), so it is NOT repeated # here. Unlike the safe-paths load above, a health warning is NOT a security gate, so it must never # fail the launch closed: a missing lib skips the warning. The print-and-exit path exec'd earlier, -# so this reaches only a real project launch, and it prints nothing on a healthy host. Each down +# so this reaches only a real project launch, and it stays silent on a healthy host. Each down # service names its consequence (framed) and its exact remedy (plain, below the box so the command # stays copy-pasteable -- see messaging.rule.md). # shellcheck source=SCRIPTDIR/../lib/ai-tools/services.lib.sh @@ -538,7 +538,7 @@ export AI_TOOLS_AGENT_EXEC="${CLAUDE_REAL}" export AI_TOOLS_PROJECT_DIR="${cwd}" # prompt_args (if any) precede "$@": the operator.conf-sourced flag sits before the operator's own # arguments. A per-invocation system-prompt flag is detected earlier and suppresses prompt_args, so -# the two never collide here. The ${arr[@]+"..."} form expands to nothing (not an empty word) when +# the two never collide here. The ${arr[@]+"..."} form expands to no word at all (not an empty word) when # prompt_args is empty, safe under set -u. exec sudo -u ai-tools -g ai-tools -- /opt/ai-tools/bin/ai-tools-run \ ${prompt_args[@]+"${prompt_args[@]}"} "$@" diff --git a/src/usr/local/lib/ai-tools/agents.d/claude-code.conf b/src/usr/local/lib/ai-tools/agents.d/claude-code.conf index 40a7a1f8..57a95a65 100644 --- a/src/usr/local/lib/ai-tools/agents.d/claude-code.conf +++ b/src/usr/local/lib/ai-tools/agents.d/claude-code.conf @@ -8,7 +8,7 @@ npm_package=@anthropic-ai/claude-code launcher=claude display_name=Claude Code # Ownership handback is driven by this agent's own PostToolUse/Stop/SessionStart/SessionEnd -# hooks (settings.json), which converge the project tree per turn, so ai-tools-run adds no +# hooks (settings.json), which converge the project tree per turn, so ai-tools-run does not add a # session-end sweep of its own. Any other value -- including an absent key -- makes the shim # sweep instead, which is the safe direction: a redundant walk, never a tree left sandbox-owned. handback=hooks @@ -22,7 +22,7 @@ config_dir=.claude # /opt/ai-tools/skills are symlinked in here, one per skill, so a skill is authored and updated # in one place however many agents read it; a real directory placed here (an agent-specific or # operator-authored skill) is never displaced by a link. An agent that cannot read the SKILL.md -# format declares no skills_dir and takes no links. +# format leaves skills_dir unset, and is given no links. skills_dir=skills # Where this agent reads SUBAGENT definitions (its own delegate roles), relative to its config # directory. Claude Code calls them "agents", which is the word this project uses for a packaged @@ -32,12 +32,12 @@ subagents_dir=agents # The binary the SELinux domain transition keys on: it must carry ai_tools_exec_t or a session # would run unconfined. The stable and versioned "claude" links both resolve to this ELF, so it # is the entrypoint, not the launcher name. A file-context regex ([^/]+ spans the Node version -# directory), applied as a local rule by ai-tools-relabel-agent -- the base policy names no -# agent. The TYPE is not declared here: relabel.lib.sh pins ai_tools_exec_t, and the pattern is +# directory), applied as a local rule by ai-tools-relabel-agent -- the base policy is agent-agnostic +# here. The TYPE is not declared here: relabel.lib.sh pins ai_tools_exec_t, and the pattern is # accepted only under the sandbox toolchain root. entrypoint_fcontext=/opt/ai-tools/\.nvm/versions/node/[^/]+/lib/node_modules/@anthropic-ai/claude-code/bin/claude\.exe -# Provisioned by default when operator.conf names no explicit AI_TOOLS_AGENTS set: Claude Code -# is the baseline agent this project ships, and it widens no host surface beyond the sandbox. +# Provisioned by default when operator.conf omits an explicit AI_TOOLS_AGENTS set: Claude Code +# is the baseline agent this project ships, and it leaves host surface unchanged beyond the sandbox. default_enable=yes # Release provenance: where Anthropic publishes the per-release SHA-256 of every platform binary, # the key that signs it, and that key's fingerprint. entrypoint-verify.lib.sh reads these to prove diff --git a/src/usr/local/lib/ai-tools/claude-endpoint.lib.sh b/src/usr/local/lib/ai-tools/claude-endpoint.lib.sh index b8380581..9c121672 100644 --- a/src/usr/local/lib/ai-tools/claude-endpoint.lib.sh +++ b/src/usr/local/lib/ai-tools/claude-endpoint.lib.sh @@ -8,7 +8,7 @@ # launch (tests/unit/claude-endpoint.sh), the same split claude-prompt.lib.sh makes. # # Why a dedicated file, not operator.conf: one of the four values is a bearer token -# (ANTHROPIC_AUTH_TOKEN), and operator.conf is 644 world-readable ("carries no secret"). The +# (ANTHROPIC_AUTH_TOKEN), and operator.conf is 644 world-readable and must never hold a secret. The # endpoint file lives at /etc/ai-tools/endpoints/ mode 640 root:@SANDBOX_GROUP@ instead -- readable # by root and the sandbox account (which needs the token) but NOT world, and NOT by the operator # (who is not in @SANDBOX_GROUP@), so the credential does not leak. operator.conf only holds the @@ -83,7 +83,7 @@ _ai_tools_endpoint_is_local() { # ai_tools_claude_resolve_endpoint_setenv <out-array-name> <operator-conf> : append the # --setenv= options that route this session at a custom endpoint to the named array, reading the # endpoint file operator.conf's CLAUDE_BASE_URL_FILE points at. Returns: -# 0 applied (out holds the valid options) or nothing to apply (not configured / inert file). +# 0 applied (out holds the valid options) or no option to apply (not configured / inert file). # 1 a configured option is invalid, or the pointer names a missing/untrusted file. The caller # must REFUSE the launch rather than route the session with a partial or wrong endpoint. # As a deliberate side effect it EXPORTS ANTHROPIC_AUTH_TOKEN into the caller's environment when a @@ -144,7 +144,7 @@ ai_tools_claude_resolve_endpoint_setenv() { ai_tools_conf_read "${file_canon}" ANTHROPIC_MODEL 2>/dev/null && model="${_ai_tools_conf_value}" ai_tools_conf_read "${file_canon}" ANTHROPIC_DEFAULT_HAIKU_MODEL 2>/dev/null && haiku="${_ai_tools_conf_value}" - # A fully inert file (nothing uncommented) is the shipped default: no endpoint, launch normally. + # A fully inert file (no option uncommented) is the shipped default: no endpoint, launch normally. if [[ -z "${base_url}" && -z "${auth_token}" && -z "${model}" && -z "${haiku}" ]]; then return 0 fi diff --git a/src/usr/local/lib/ai-tools/claude-prompt.lib.sh b/src/usr/local/lib/ai-tools/claude-prompt.lib.sh index 78f920eb..0d8f7d02 100644 --- a/src/usr/local/lib/ai-tools/claude-prompt.lib.sh +++ b/src/usr/local/lib/ai-tools/claude-prompt.lib.sh @@ -92,7 +92,7 @@ _ai_tools_claude_argv_has_prompt_flag() { # or holds text (no binary/NUL content). The custom prompt is read as text and appended to (or # substituted for) the model's system prompt -- it is never executed -- so the only sanity bar is # that it is not a binary blob whose bytes would land in the prompt. An empty file is fine: it is the -# shipped inert default, and appending it adds nothing. `grep -I` reports a binary file as no-match. +# shipped inert default, and appending it leaves the prompt as it was. `grep -I` reports a binary file as no-match. _ai_tools_claude_is_text_file() { local path="$1" [[ -f "${path}" ]] || return 1 diff --git a/src/usr/local/lib/ai-tools/conf.lib.sh b/src/usr/local/lib/ai-tools/conf.lib.sh index b260d9a6..57b949c9 100644 --- a/src/usr/local/lib/ai-tools/conf.lib.sh +++ b/src/usr/local/lib/ai-tools/conf.lib.sh @@ -161,7 +161,7 @@ ai_tools_conf_split() { # named array, but ONLY when the key is present -- a present key REPLACES the array (an empty # value giving an empty array, an explicit "none"), while an absent key leaves it untouched and # returns 1. That is what makes an override key override: a caller seeds the array with its -# default and calls this, and a config that says nothing about the key keeps that default. +# default and calls this, and a config that omits the key keeps that default. ai_tools_conf_list() { local out_name="$1" file="$2" key="$3" ai_tools_conf_read "${file}" "${key}" || return 1 @@ -223,7 +223,7 @@ _ai_tools_conf_match_perms() { # ai_tools_conf_backup <file> : copy <file> to a fresh dated .bak and print that path. `cp -p` # keeps mode, ownership and timestamps, so the copy is a faithful restore point rather than a -# file the operator has to re-permission. Returns 1 printing nothing when no copy was made. +# file the operator has to re-permission. Returns 1 without printing a path when no copy was made. ai_tools_conf_backup() { local file="$1" target [[ -f "${file}" ]] || return 1 @@ -235,7 +235,7 @@ ai_tools_conf_backup() { # ai_tools_conf_reference <deployed> <shipped> : print the .shipped sidecar beside <deployed> that # holds the <shipped> baseline, copying it to a fresh dated path when no existing sidecar matches # it byte for byte. The copy takes the DEPLOYED file's owner and mode, not the source tree's. -# Returns 1 and prints no path when the baseline is absent or the copy fails. +# Returns 1 without printing a path when the baseline is absent or the copy fails. ai_tools_conf_reference() { local deployed="$1" shipped="$2" target existing [[ -f "${shipped}" ]] || return 1 @@ -255,7 +255,7 @@ ai_tools_conf_reference() { # ai_tools_conf_require_jq : succeed when jq is callable. jq is a package dependency, so its # absence is a broken install rather than a host variation -- this reports and fails instead of # degrading, and callers of the JSON paths below gate on it. Deliberately NOT checked when this -# library is sourced: the KEY=value grammar above needs no jq, and this file is sourced on every +# library is sourced: the KEY=value grammar above does not need jq, and this file is sourced on every # launch (by ai-tools-run, as the sandbox account) and by every root helper, so a source-time # failure would stop a session for a reason unrelated to what it asked for. ai_tools_conf_require_jq() { @@ -278,7 +278,7 @@ ai_tools_conf_require_jq() { # The shipped hook commands a deployed file does not declare, as "<event>: <command>". The # command binds to $command before the membership test: inside index(), `.` is that function's # own input -- the $have array -- so an unbound form asks whether the array contains itself and -# reports nothing wherever the event already declares a hook. +# does not report a gap wherever the event already declares a hook. # shellcheck disable=SC2016 # jq variables, bound by --slurpfile and jq's own `as` readonly _AI_TOOLS_CONF_HOOKS_MISSING_FILTER=' . as $cur @@ -309,7 +309,7 @@ readonly _AI_TOOLS_CONF_HOOKS_MERGE_FILTER=' # declarations into <deployed>. # returns 0 merged _ai_tools_conf_merge_added holds "<event>: <command>" per addition, # _ai_tools_conf_merge_backup the copy of what the operator had -# returns 1 no change the file already declares everything shipped; nothing written +# returns 1 no change the file already declares everything shipped; no write happens # returns 2 refused the file is byte-identical and _ai_tools_conf_merge_reference holds # the baseline dropped for a hand merge (empty if even that failed); # _ai_tools_conf_merge_reason says which check refused @@ -376,7 +376,7 @@ ai_tools_conf_merge_hook_declarations() { # whether the key is live or written as a commented-out default (`#KEY=` / `# KEY =`). Both # forms count as "mentioned", which is the point: a key an operator has deliberately commented # out is one they have already seen, so re-announcing it every upgrade would be noise. A comment -# indented further than one space is prose, not a default, and mentions nothing (below). +# indented further than one space is prose, not a default, and does not name an option (below). ai_tools_conf_keys() { local -n _ai_tools_conf_keys_out="$1" local file="$2" line key @@ -444,7 +444,7 @@ ai_tools_conf_new_keys() { # exclusions as globs, and this only decides what text the line denotes. # ai_tools_conf_path_entry <line> : set _ai_tools_conf_value to the entry <line> denotes and -# return 0; return 1 for a line that carries no entry (blank, or a whole-line comment), which +# return 0; return 1 for a line that does not carry an entry (blank, or a whole-line comment), which # is the caller's signal to skip it. A leading `!` is preserved on the result, so an exclusion # stays distinguishable after the quotes are stripped. ai_tools_conf_path_entry() { @@ -531,7 +531,7 @@ ai_tools_conf_allowlist_matching_lines() { # <path> exactly (compared without the `!`), and return 0 when at least one did. Exact-path like # ai_tools_conf_allowlist_has_exclusion, never glob-expanding: it serves the callers that must # EDIT the line an operator wrote to park a project (the CLI's re-enable, its de-registration, -# and the --for root helper), and a glob line names no single project to act on. +# and the --for root helper), and a glob line does not name a single project to act on. ai_tools_conf_allowlist_exclusion_lines() { local -n _ai_tools_conf_excluded="$1" local file="$2" want line entry @@ -560,7 +560,7 @@ ai_tools_conf_allowlist_exclusion_lines() { # and reports three outcomes apart: # 0 the file now holds the intended state (including "it already did") # 1 the edit could not be applied -- the file is missing or could not be written -# 2 the request does not apply from the CURRENT state, and nothing was written +# 2 the request does not apply from the CURRENT state, and no write happened # The 2 cases are what keep the four states honest: adding over an exclusion would leave both lines # present with the `!` still winning at the launch gate (a claim reporting success over a project # no session can start in), and enabling or disabling a path the file does not name would invent an @@ -613,7 +613,7 @@ ai_tools_conf_allowlist_add() { esac # A hand-edited registry can run to EOF part-way through its last line, and every reader here # keeps that entry (the read loops take a final unbroken line). So the append opens a new line - # first: written straight, it would join the two paths into a third that names no project, + # first: written straight, it would join the two paths into a third that no project matches, # dropping the claimed one from the launch gate while the entry above it changed meaning. [[ -n "$(tail -c 1 -- "${file}" 2>/dev/null)" ]] && line_break=$'\n' printf '%s%s\n' "${line_break}" "${path}" >> "${file}" 2>/dev/null || return 1 @@ -664,7 +664,7 @@ _ai_tools_conf_allowlist_retag() { # already exists -- the pair the old "append over an exclusion" bug created -- would leave two # live entries for one path. So the FIRST line naming the path survives, un-parked, in its own # position, and every later line naming it is dropped: one live entry per path, which is the - # invariant every reader of this file assumes. DISABLE needs no such rule, since parking each + # invariant every reader of this file assumes. DISABLE does not need that rule, since parking each # of several allow lines leaves them all excluded, which is one state and not two. local -a live_lines=() if [[ "${op}" == enable ]]; then diff --git a/src/usr/local/lib/ai-tools/confinement.lib.sh b/src/usr/local/lib/ai-tools/confinement.lib.sh index fd26f4d2..3d5e6080 100644 --- a/src/usr/local/lib/ai-tools/confinement.lib.sh +++ b/src/usr/local/lib/ai-tools/confinement.lib.sh @@ -60,7 +60,7 @@ _AI_TOOLS_CONFINEMENT_LIB_LOADED=1 # declared that unacceptable, so it refuses instead. Both are DAC-only LAUNCH exits with # require unset. # An "ok" launches: confined when the transition is verified (enforcing, correct label, covered -# manager); DAC-only when the kernel is not enforcing or the module is absent -- nothing to verify, +# manager); DAC-only when the kernel is not enforcing or the module is absent -- no transition to verify, # and require is unset (with require=yes those two DAC-only launches become refusals). # ai_tools_confinement_module_present <matchpathcon-type> # Classify the `module` verdict input from a probe of a CORE-module-owned path (e.g. @@ -78,7 +78,7 @@ ai_tools_confinement_verdict() { local enforce="$1" module="$2" want="$3" have="$4" mgrdom="$5" require="${6:-no}" if [[ "${enforce}" != "Enforcing" ]]; then - # DAC-only launch: nothing to verify -- unless the operator declared SELinux mandatory. + # DAC-only launch: no transition to verify -- unless the operator declared SELinux mandatory. [[ "${require}" == "yes" ]] && { printf 'require-not-enforcing'; return 1; } printf 'ok'; return 0 fi diff --git a/src/usr/local/lib/ai-tools/control-plane.lib.sh b/src/usr/local/lib/ai-tools/control-plane.lib.sh index ca62bec8..f7584b4a 100644 --- a/src/usr/local/lib/ai-tools/control-plane.lib.sh +++ b/src/usr/local/lib/ai-tools/control-plane.lib.sh @@ -3,7 +3,7 @@ # shellcheck disable=SC2034 # boundary-mode constants, read by install.sh and the perms test # /usr/local/lib/ai-tools/control-plane.lib.sh # Canonical boundary-mode constants for the /opt/ai-tools control plane. The control plane is -# owned root:ai-tools permanently -- the RPM ships it that way and nothing re-owns it to a person +# owned root:ai-tools permanently -- the RPM ships it that way and no step re-owns it to a person # -- so the agent (group ai-tools) reaches its state while root owns the locked control files. # This file is *sourced* (never executed) so the installer and the test suite assert the same # boundary modes the spec %files declares, from one source. See ownership-and-hooks.rule.md. @@ -42,7 +42,7 @@ readonly CP_HOME=/opt/ai-tools # state, one directory per integration named for its manifest # (integrations/<name>/...). Base owns the root and its single SELinux # file-context rule; each integration package owns its own directory and -# chooses the modes inside it, so a new toolchain adds no policy and no +# chooses the modes inside it, so a new toolchain brings neither policy nor # dotdir at the home root. # CP_SHARED_SKILLS / CP_SHARED_SUBAGENTS # the one place each SHARED asset kind lives, agent-agnostic: the base @@ -58,7 +58,7 @@ readonly CP_SHARED_SUBAGENTS="${CP_HOME}/subagents" readonly CP_INTEGRATIONS="${CP_HOME}/integrations" # Which agents are installed and enabled, and what each declares, comes from the provider -# manifests. Loaded best-effort: without it the resolver below yields nothing, which leaves a +# manifests. Loaded best-effort: without it the resolver below yields an empty set, which leaves a # caller asserting no agent config directory rather than guessing a path. # shellcheck source=SCRIPTDIR/providers.lib.sh source "${BASH_SOURCE[0]%/*}/providers.lib.sh" 2>/dev/null || true @@ -77,7 +77,7 @@ ai_tools_apply_mode() { # ai_tools_agent_config_dir_valid <name> : pure check -- succeed when <name> is usable as an # agent's config directory: ONE path component under the home, no traversal, no separator. The # value reaches root helpers as a path and a `semanage fcontext` pattern, so a manifest names a -# directory beneath the home and can address nothing else. +# directory beneath the home and cannot address a path outside it. ai_tools_agent_config_dir_valid() { local name="${1:-}" [[ -n "${name}" ]] || return 1 @@ -89,7 +89,7 @@ ai_tools_agent_config_dir_valid() { # every ENABLED agent declares in <manifest-field> (skills_dir, subagents_dir) -- a single # component inside that agent's config directory, so the agent names WHERE its own product # expects a kind of asset while the layout under the home stays ours. An agent that declares -# none takes no links of that kind: the shared assets are in the Claude Code format, so an +# none is given no links of that kind: the shared assets are in the Claude Code format, so an # agent that cannot read that format simply does not ask. ai_tools_agent_asset_dirs() { declare -F ai_tools_enabled_agents >/dev/null 2>&1 || return 0 diff --git a/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh b/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh index 2a8c285f..11096c67 100644 --- a/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh +++ b/src/usr/local/lib/ai-tools/entrypoint-verify.lib.sh @@ -4,7 +4,7 @@ # Verify that an agent's entrypoint is the binary its vendor published, and carry that verdict to # the launch in a record the sandbox account cannot write. # -# It names no agent: the release manifest, the signing key, and its fingerprint are optional fields +# It is agent-agnostic: the release manifest, the signing key, and its fingerprint are optional fields # on the agent's own manifest (providers.rule.md). Why the check exists, which caller runs which # half, what each outcome means, and where the pin lives are in updater.rule.md; this header covers # only what a reader of this file needs. @@ -34,7 +34,7 @@ _AI_TOOLS_ENTRYPOINT_VERIFY_LIB_LOADED=1 # The shared KEY=value grammar, for the strictness switch and the fingerprint list. Best-effort, # NOT required: the launch-side check (the hot path) needs neither, and every consumer that does # has already loaded conf.lib.sh through providers.lib.sh -- so a failure here degrades the two -# functions that use it in their permissive/refusing directions rather than defining nothing. Both +# functions that use it in their permissive/refusing directions rather than leaving them undefined. Both # guard on `declare -F` before calling into it. # shellcheck source=SCRIPTDIR/conf.lib.sh source "${BASH_SOURCE[0]%/*}/conf.lib.sh" 2>/dev/null || true @@ -61,7 +61,7 @@ _ai_tools_ev_warn() { # ── Pure decisions (no I/O, no privilege, no network) ──────────────────────────────────────── # ai_tools_entrypoint_platform_key <machine> [libc] : print the key a vendor release manifest -# lists this host's binary under, or nothing for an architecture with no mapping. <machine> is +# lists this host's binary under, or an empty string for an architecture with no mapping. <machine> is # uname -m; <libc> is `musl` or empty. Pure, so the mapping is unit-tested without needing the # architectures it maps. ai_tools_entrypoint_platform_key() { @@ -92,7 +92,7 @@ ai_tools_release_url_valid() { # ai_tools_release_manifest_url <template> <version> : print the fetchable URL for <version>, by # substituting the template's single {version} slot. A template without the slot is refused # rather than fetched as-is: it would pin every version to one manifest, which reads as "verified" -# while checking the wrong release. The version is admitted only in semver shape, so nothing a +# while checking the wrong release. The version is admitted only in semver shape, so no value a # package.json carries can inject a path segment into the URL. ai_tools_release_manifest_url() { local template="${1:-}" version="${2:-}" @@ -106,7 +106,7 @@ ai_tools_release_manifest_url() { # ai_tools_release_manifest_checksum <manifest-json> <platform-key> : print the SHA-256 the # manifest lists for that platform. Reads the passed string only -- no filesystem, no network -- # and admits the result only in exactly the 64-hex shape a SHA-256 has, so malformed JSON, an -# absent platform, or a crafted value yields NOTHING rather than a checksum that could match a +# absent platform, or a crafted value yields an EMPTY STRING rather than a checksum that could match a # crafted binary. jq is the parser (a hard dependency of the agent packages that declare these # fields); its absence is reported by the caller as "unable to verify", never as a mismatch. ai_tools_release_manifest_checksum() { @@ -123,7 +123,7 @@ ai_tools_release_manifest_checksum() { # Echoes a verdict token and returns the status contract above: # ok both present and equal # mismatch both present and different -- the tamper signal, status 1 -# unpinned no expected value: nothing has verified this entrypoint yet, status 2 +# unpinned no expected value: no run has verified this entrypoint yet, status 2 # unreadable no observed value: the entrypoint could not be hashed, status 2 # Absence is never a mismatch: a missing pin and a modified binary are different facts with # different remedies. Unit-tested over the truth table. @@ -137,7 +137,7 @@ ai_tools_entrypoint_pin_verdict() { # ── Impure: hashing, the pin, and the signed-manifest probe ────────────────────────────────── -# ai_tools_entrypoint_sha256 <path> : print the file's SHA-256, or nothing. Bounded to a regular +# ai_tools_entrypoint_sha256 <path> : print the file's SHA-256, or an empty string. Bounded to a regular # file so a fifo or device swapped into the path cannot block the caller forever. ai_tools_entrypoint_sha256() { local path="${1:-}" line @@ -154,7 +154,7 @@ ai_tools_entrypoint_sha256() { # template, the signing key's path AND its content, and the declared fingerprints. A pin records # this digest so a later run can tell "the same question, asked the same way" from a question that # has changed (a vendor key rotation, a repointed manifest host) without refetching anything. -# Prints nothing when any input is unusable, which resolves to a full verification. +# Prints an empty string when any input is unusable, which resolves to a full verification. ai_tools_entrypoint_inputs_digest() { local url_template="${1:-}" key_file="${2:-}" fingerprints="${3:-}" key_digest line [[ -n "${url_template}" ]] || return 1 @@ -217,8 +217,8 @@ _ai_tools_ev_write_record() { # reconciliation could do about <agent>'s labels. ROOT ONLY. # # `skipped` is the SELinux layer being inactive -- a DAC-only host, where there is no -# ai_tools_exec_t to assign and nothing to fix. The reason is a short TOKEN, not prose: every -# field here is read back through the stamp accessors' charset clamp, which admits no spaces, and +# ai_tools_exec_t to assign and no fault to fix. The reason is a short TOKEN, not prose: every +# field here is read back through the stamp accessors' charset clamp, which excludes spaces, and # the operator-facing detail (semanage's own message) belongs in the log the refusal already # writes. This says which class of failure, so the report can name the remedy. ai_tools_entrypoint_label_write() { @@ -249,7 +249,7 @@ _ai_tools_ev_pin_field() { printf '%s' "${line#*=}" } -# ai_tools_entrypoint_pin_read <agent> : print the SHA-256 recorded for that agent, or nothing. +# ai_tools_entrypoint_pin_read <agent> : print the SHA-256 recorded for that agent, or an empty string. ai_tools_entrypoint_pin_read() { local pin checksum pin="$(ai_tools_entrypoint_pin_path "${1:-}")" || return 1 @@ -432,8 +432,9 @@ ai_tools_entrypoint_verify_required() { } # ai_tools_entrypoint_check <agent> <entrypoint> : the launch-side gate. Hash the entrypoint and -# compare it to the agent's pin. Echoes the verdict token and returns the status contract; takes -# no network, no key, and no privilege, so it runs as the sandbox account on the launch path. +# compare it to the agent's pin. Echoes the verdict token and returns the status contract. It does not +# reach the network, read a key, or need privilege, so it runs as the sandbox account on the +# launch path. ai_tools_entrypoint_check() { local agent="${1:-}" entrypoint="${2:-}" expected observed expected="$(ai_tools_entrypoint_pin_read "${agent}" || true)" diff --git a/src/usr/local/lib/ai-tools/filters.lib.sh b/src/usr/local/lib/ai-tools/filters.lib.sh index ed3cb2b2..fb43754d 100644 --- a/src/usr/local/lib/ai-tools/filters.lib.sh +++ b/src/usr/local/lib/ai-tools/filters.lib.sh @@ -10,7 +10,7 @@ # the agent may run: the harness re-evaluates its own permission rules on the rewritten command, # so a rewrite can neither reach past a deny rule nor silence a prompt. Every failure direction # here -- an untrusted rules file, an unparseable line, a command the engine does not fully -# understand -- yields NO rewrite, which costs tokens and changes nothing else. +# understand -- yields NO rewrite, which costs tokens and changes the command in no other way. # # ── Rules are data ─────────────────────────────────────────────────────────────────────────── # One rule set per package, /usr/local/lib/ai-tools/filters.d/<name>.rules, root-owned and @@ -22,7 +22,7 @@ # wrap -- prepend <payload> as a wrapper command (rtk and the like) # blocking words that cancel the rule when the command already carries one, so a rule never # overrides a flag the agent chose; commas and whitespace both separate, and a -# lone `-` means "nothing blocks this rule" +# lone `-` means "no flag blocks this rule" # payload verbatim shell text, inserted as written -- root-owned data, so unlike the # agent's command it may carry quotes # @@ -50,9 +50,9 @@ # key present -> exactly the named sets; an EMPTY value is the kill switch, no filtering at all # -- ai_tools_filter_enabled is that verdict, and an adapter gates its noise # strip on it too, so the switch really does turn off every transform -# An untrusted operator.conf or filters.d is ignored, which likewise yields no filtering. Rule +# An untrusted operator.conf or filters.d is ignored, which likewise leaves the command unfiltered. Rule # sets are not gated on provider enablement: a rule is inert unless the agent runs the command it -# matches, so the gate would buy nothing and this runs on every Bash call. +# matches, so the gate would have no effect and this runs on every Bash call. # Include guard: an if-statement, not `[[ ]] && return`, which returns 1 for an unset guard and # trips the sourcing shell's set -e. @@ -62,8 +62,9 @@ fi # conf.lib.sh is REQUIRED: it carries the trust predicate that decides whether a rules file may be # read at all, and the grammar AI_TOOLS_FILTERS is written in. Without it this file can neither -# tell a root-owned rule set from a planted one nor read the kill switch, so it defines NOTHING -# and returns non-zero -- the adapter's `source ... && declare -F` guard then filters nothing. +# tell a root-owned rule set from a planted one nor read the kill switch, so it defines NO FUNCTION +# and returns non-zero -- the adapter's `source ... && declare -F` guard then leaves the command +# unfiltered. # shellcheck source=SCRIPTDIR/conf.lib.sh if ! source "${BASH_SOURCE[0]%/*}/conf.lib.sh" 2>/dev/null \ || ! declare -F ai_tools_conf_is_trusted >/dev/null 2>&1 \ @@ -219,7 +220,7 @@ ai_tools_filter_rules_load() { local set_name for set_name in "${rule_sets[@]}"; do # Allowlist the name before it becomes a path, as the manifest resolver does: rule-set - # names are plain identifiers, so nothing here can address a file outside filters.d. + # names are plain identifiers, so no value here can address a file outside filters.d. [[ "${set_name}" =~ ^[A-Za-z0-9._-]+$ && "${set_name}" != *..* ]] || continue [[ -e "${AI_TOOLS_FILTERS_DIR}/${set_name}.rules" ]] || continue _ai_tools_filter_load_file "${AI_TOOLS_FILTERS_DIR}/${set_name}.rules" || true @@ -228,7 +229,7 @@ ai_tools_filter_rules_load() { } # ai_tools_filter_rewrite <command> : print the rewritten command and return 0 when a loaded rule -# applies and actually changes it; return 1 printing nothing otherwise (the pass-through case, +# applies and actually changes it; return 1 printing no command otherwise (the pass-through case, # which is every failure direction). Call ai_tools_filter_rules_load first. ai_tools_filter_rewrite() { local command="${1-}" @@ -259,7 +260,7 @@ ai_tools_filter_rewrite() { # ai_tools_filter_strip_noise : copy stdin to stdout with terminal control noise removed -- ANSI # CSI and OSC sequences, stray escapes, and carriage-return redraws collapsed to the final state # a terminal would have shown. Byte-level noise only: no line is dropped, truncated, reordered -# or summarized, so nothing the model would have read is lost. The one real cost is a line that +# or summarized, so every line the model would have read survives. The one real cost is a line that # uses a carriage return as data rather than as a redraw (CR-only line endings), which keeps # only its last segment. ai_tools_filter_strip_noise() { diff --git a/src/usr/local/lib/ai-tools/log.lib.sh b/src/usr/local/lib/ai-tools/log.lib.sh index cc761b84..b864a24f 100644 --- a/src/usr/local/lib/ai-tools/log.lib.sh +++ b/src/usr/local/lib/ai-tools/log.lib.sh @@ -36,8 +36,8 @@ # and additive: ai_tools_log is unchanged, a caller passing no fields takes the identical path, # and a host whose logger(1) predates `--journald` falls back to it. A key=value MESSAGE is only # conventionally structured -- every consumer re-parses it and a value containing the delimiter -# is ambiguous -- whereas the native protocol delimits each field itself, so a field value needs -# no escaping and cannot forge a sibling field. Detail: .claude/rules/logging.rule.md. +# is ambiguous -- whereas the native protocol delimits each field itself, so a field value cannot +# forge a sibling field, and escaping is unnecessary. Detail: .claude/rules/logging.rule.md. # # Scope is a CALLER convention, not enforced here: log the privileged operations the # hooks and sudo helpers perform, the CLI's workflow milestones (project / sandbox @@ -188,11 +188,11 @@ _ai_tools_log_write_file() { # # WHY BOTH. A key=value MESSAGE is only conventionally structured: every consumer has to # re-parse it, and a value containing the delimiter is ambiguous. journald's native protocol -# delimits each field itself, so a field VALUE needs no escaping and cannot forge a sibling +# delimits each field itself, so a field VALUE cannot forge a sibling, and escaping is unnecessary # field. The MESSAGE stays the authoritative human rendering and the fields are the machine # one; callers pass both, and the two are expected to agree. # -# OPT-IN, and identical to ai_tools_log when unused: a caller that passes no fields, or a host +# OPT-IN, and identical to ai_tools_log when unused: a caller that passes fields, or a host # whose logger(1) predates `--journald`, takes exactly the plain path above. The fallback is # decided by ATTEMPTING the native write and falling back on its exit status rather than by # probing logger's capabilities, so there is no cached verdict to go stale and no fork spent on diff --git a/src/usr/local/lib/ai-tools/managed-assets.lib.sh b/src/usr/local/lib/ai-tools/managed-assets.lib.sh index 5287320c..e2984471 100644 --- a/src/usr/local/lib/ai-tools/managed-assets.lib.sh +++ b/src/usr/local/lib/ai-tools/managed-assets.lib.sh @@ -12,7 +12,7 @@ # themselves is never claimed or overwritten. Seeded copies are root:SANDBOX_GROUP (files 640, # dirs 750) in their shared root -- locked from the agent, updated only through the root-run # installer or `ai-tools-bootstrap`. Versioning is RFC-draft: the marker -# `x-ai-tools-version` is a monotonic integer bumped on every change, and a newer shipped version +# `x-ai-tools-version` is a monotonic integer bumped once per release, and a newer shipped version # is what drives the update offer. This file is *sourced* (never executed); its consumers # (install.sh, ai-tools-bootstrap) run as root and have already sourced msg.lib.sh. See # shipped-assets.rule.md. @@ -60,7 +60,7 @@ _ai_tools_asset_is_retired() { return 1 } -# An UPDATE replaces the live copy outright and keeps no sidecar, which is not an oversight: there +# An UPDATE replaces the live copy outright without keeping a sidecar, which is not an oversight: there # is no baseline to detect an edit against. The live copy is the previous version, so it differs # from the incoming one by definition, and the pristine datadir carries only the current version # (rpm has already replaced it by the time this runs). A copy-on-every-update would therefore fire @@ -226,7 +226,7 @@ ai_tools_remove_retired_assets() { # _ai_tools_asset_is_stale_copy <shared> <live> : true when <live> is a copy this project placed # under the pre-shared layout and is byte-identical to <shared> -- i.e. replacing it with a link -# loses nothing. Requires BOTH the ai-tools-managed marker (so an operator's own asset is never +# does not discard content. Requires BOTH the ai-tools-managed marker (so an operator's own asset is never # touched) and identical content (so an edited or drifted copy is never discarded). Without the # comparison tools it answers false, keeping the copy. _ai_tools_asset_is_stale_copy() { @@ -277,7 +277,7 @@ ai_tools_link_shared_assets() { # asset, which always wins -- or OUR copy from the layout before these assets were # shared, which should become a link so the shared file is the only one to maintain. # Convert only when it is BOTH ai-tools-managed and byte-identical to the shared - # copy: same provenance, nothing to lose. A managed copy that differs is left alone + # copy: same provenance, no content to lose. A managed copy that differs is left alone # and reported, because the difference is either an operator edit or version drift, # and this is not the place to resolve either. if _ai_tools_asset_is_stale_copy "${src}" "${dst}"; then diff --git a/src/usr/local/lib/ai-tools/msg.lib.sh b/src/usr/local/lib/ai-tools/msg.lib.sh index 5e4ae52a..8239fb7f 100644 --- a/src/usr/local/lib/ai-tools/msg.lib.sh +++ b/src/usr/local/lib/ai-tools/msg.lib.sh @@ -13,7 +13,7 @@ # # TERMINAL (the target fd is a tty): the text is wrapped and drawn in a titled box whose # every line begins with '#', so the whole block is a shell comment -- if a user -# copy-pastes it into a prompt nothing executes. Two frame classes give the reader a +# copy-pastes it into a prompt it executes as a comment. Two frame classes give the reader a # visual hierarchy: the severity ALERTS (ai_tools_msg_*) frame within 50 columns -- a # narrow box reads as an inline alert -- while the structural boxes (ai_tools_msg_block, # ai_tools_msg_headline) frame within 80, so a wide box reads as a section headline or a @@ -354,7 +354,7 @@ ai_tools_msg_block() { # takes the safe default (typically Cancel) and never blocks. Returns 0. # none there is no default: empty or out-of-range input RE-ASKS (three # attempts), and closed input, no terminal, or three unanswered attempts -# return NON-ZERO with nothing on stdout -- the library gives up rather +# return NON-ZERO with empty stdout -- the library gives up rather # than answering for the user, and the caller decides what that means. # A first argument that is neither is a caller error (return 2), never an assumed answer. ai_tools_msg_pick() { @@ -568,7 +568,7 @@ _AI_TOOLS_BANNER_ART=( # version NUMBER (starts with a digit, or a lone 'v' + digit: 0.1.0, v1.2) is rendered with # exactly one leading 'v'. Any other build id -- a `git describe` like 'proj-v1-144-gABCDEF', # or the literal 'dev' -- is printed VERBATIM, since 'v'-prefixing it would read as -# 'vproj-...'. An empty or 'unknown'/'none' value prints nothing, so the caller drops the +# 'vproj-...'. An empty or 'unknown'/'none' value is printed as an empty string, so the caller drops the # meta entirely. Echoes the display string on stdout; always succeeds. ai_tools_msg_version() { local v="${1:-}" @@ -589,7 +589,7 @@ ai_tools_msg_version() { # sibling repo sources this lib and calls it with its own subtitle, so the brand reads the # same across surfaces and repos. # -# Printed ONLY on a terminal ([ -t 1 ]); on a pipe/redirect/capture it prints nothing, so a +# Printed ONLY on a terminal ([ -t 1 ]); on a pipe/redirect/capture it stays silent, so a # tee'd install log, a piped run, or a `--version` scrape is never polluted with escape codes # or box-drawing glyphs. Colour is emitted once past that gate (there is a terminal to read # it). Always returns success. diff --git a/src/usr/local/lib/ai-tools/npm-verify.lib.sh b/src/usr/local/lib/ai-tools/npm-verify.lib.sh index 8151433c..9c2c1e06 100644 --- a/src/usr/local/lib/ai-tools/npm-verify.lib.sh +++ b/src/usr/local/lib/ai-tools/npm-verify.lib.sh @@ -84,7 +84,7 @@ ai_tools_npm_verdict() { # ai_tools_verify_npm_signatures: verify every globally installed npm package's registry # signature. Self-contained -- discovers the global tree (`npm root -g`) and the top-level -# package set (`npm ls -g`) itself; takes no arguments. Returns the status contract above. +# package set (`npm ls -g`) itself; it does not take arguments. Returns the status contract above. ai_tools_verify_npm_signatures() { local _p='npm-verify:' @@ -104,7 +104,7 @@ ai_tools_verify_npm_signatures() { || { printf '%s global node_modules not found -- nothing to verify\n' "${_p}" >&2; return 2; } # Top-level global packages at their installed versions, as a JSON deps object, from - # `npm ls -g --json`. A parse miss yields an empty object, handled as "nothing to verify". + # `npm ls -g --json`. A parse miss yields an empty object, handled as "no package to verify". local deps_json deps_json="$(npm ls -g --depth=0 --json 2>/dev/null | node -e ' const fs = require("fs"); diff --git a/src/usr/local/lib/ai-tools/operator.lib.sh b/src/usr/local/lib/ai-tools/operator.lib.sh index 5292ebef..c8e672c9 100644 --- a/src/usr/local/lib/ai-tools/operator.lib.sh +++ b/src/usr/local/lib/ai-tools/operator.lib.sh @@ -36,7 +36,7 @@ # # The handback helpers (ai-tools-chown/-setgid/-setfacl/-lockdown/-unclaim) source this lib # best-effort and, when it is absent, define a fail-closed ai_tools_resolve_owner stub that -# resolves no owner -- so a missing lib skips the handback (the path stays sandbox-owned) rather +# leaves the owner unresolved -- so a missing lib skips the handback (the path stays sandbox-owned) rather # than acting on the wrong identity. Each calls resolve_owner on the path it acts on, then restores # to that owner; a path no operator's allowlist covers is left untouched. @@ -51,7 +51,7 @@ readonly _AI_TOOLS_OPERATOR_LIB=1 # Shared KEY=value grammar (conf.lib.sh), so OPERATORS parses exactly like every other key in # operator.conf -- commas or whitespace between names, quotes optional, inline comments honored. # The load is fail-CLOSED by consequence rather than by refusal: without the parser -# ai_tools_load_operators resolves no operators, and "no owner" is already the answer that stops +# ai_tools_load_operators leaves the operator set empty, and "no owner" is already the answer that stops # a handback (see the header), so a missing lib skips the operation instead of acting on a # half-parsed identity. # shellcheck source=SCRIPTDIR/conf.lib.sh @@ -66,7 +66,7 @@ readonly AI_TOOLS_OPERATOR_CONF="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operato # ai_tools_load_operators: parse the OPERATORS list from AI_TOOLS_OPERATOR_CONF into the # global array AI_TOOLS_OPERATORS (one element per operator, order preserved). Returns 0 when # at least one operator is configured, 1 when unenrolled -- callers treat the unenrolled case -# as "nothing to do" (fail-closed: no operator means no ownership to restore). Idempotent. +# as "no work to do" (fail-closed: no operator means no ownership to restore). Idempotent. ai_tools_load_operators() { AI_TOOLS_OPERATORS=() declare -F ai_tools_conf_list >/dev/null 2>&1 || return 1 @@ -76,7 +76,7 @@ ai_tools_load_operators() { # ai_tools_load_operator: resolve the PRIMARY operator (the first in the list) into the globals # PROJECTS_USER, PROJECTS_HOME, PROJECTS_GROUP, and the derived PROJECTS_UID (numeric, -1 when -# unresolved so an owner-guard compare matches nothing). The single-operator identity contract +# unresolved so an owner-guard compare can never match). The single-operator identity contract # the components that need "an operator" rely on (the launch path, the CLI, the symlink/relabel # helpers); the per-path owner of a multi-operator host is resolved separately. Home and group # are derived from the name via getent/id. Returns 0 when an operator is configured, 1 when diff --git a/src/usr/local/lib/ai-tools/owner-only.lib.sh b/src/usr/local/lib/ai-tools/owner-only.lib.sh index fa7dc548..e2bbcff9 100644 --- a/src/usr/local/lib/ai-tools/owner-only.lib.sh +++ b/src/usr/local/lib/ai-tools/owner-only.lib.sh @@ -34,10 +34,10 @@ fi readonly _AI_TOOLS_OWNER_ONLY_LIB=1 # The sandbox group, substituted at install. Every arm of the strip is keyed on it, so a tree -# that never met a claim has nothing to strip. +# that never met a claim has no residue to strip. readonly AI_TOOLS_SANDBOX_GROUP="@SANDBOX_GROUP@" -# ai_tools_is_owner_only <octal-mode>: 0 when the mode carries no group and no other bits. +# ai_tools_is_owner_only <octal-mode>: 0 when the mode grants neither group nor other bits. # An empty or unparseable mode reads as sealed, so a path whose stat failed is skipped by the # walkers rather than granted. ai_tools_is_owner_only() { @@ -51,7 +51,7 @@ ai_tools_is_owner_only() { # inode, so it inherits that caller's TOCTOU guarantee. <ftype>/<group-name>/<octal-mode> are # the values the caller read from that same descriptor. # -# Returns 0 when something was stripped, 1 when there was nothing to strip. Sets: +# Returns 0 when something was stripped, 1 when there was no residue to strip. Sets: # AI_TOOLS_RESIDUE_ACTIONS what changed, as an array of acl / setgid / group # AI_TOOLS_RESIDUE_SURFACE 1 when a third-party group's setgid was left for the caller # to report diff --git a/src/usr/local/lib/ai-tools/path-dedup.sh b/src/usr/local/lib/ai-tools/path-dedup.sh index 2e3e9bab..14dce227 100644 --- a/src/usr/local/lib/ai-tools/path-dedup.sh +++ b/src/usr/local/lib/ai-tools/path-dedup.sh @@ -10,7 +10,7 @@ # 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 are bash's, so `operators add` reports a login shell that -# reads neither. The sandbox account needs no wiring: ai-tools-run pins the +# reads neither. The sandbox account takes its PATH elsewhere: ai-tools-run pins the # session PATH as a unit property. # # PATH is first-match-wins, so the order below runs least-writable first and @@ -54,7 +54,7 @@ _dedup_path() { # Opt-in: report missing directories, each at most once per shell process # (_PATH_DEDUP_WARNED is not exported, so every new shell starts clean). - # `|| [[ -n ... ]]` keeps the last entry: tr emits no trailing newline. + # `|| [[ -n ... ]]` keeps the last entry: tr leaves off the trailing newline. if [[ "${PATH_DEDUP_WARN-}" == "1" ]]; then local entry while IFS= read -r entry || [[ -n "${entry}" ]]; do diff --git a/src/usr/local/lib/ai-tools/providers.lib.sh b/src/usr/local/lib/ai-tools/providers.lib.sh index 0ece21ec..b6a7495d 100644 --- a/src/usr/local/lib/ai-tools/providers.lib.sh +++ b/src/usr/local/lib/ai-tools/providers.lib.sh @@ -12,8 +12,8 @@ # Both inputs are DATA -- parsed via conf.lib.sh, never sourced -- so a malformed or tampered file # cannot execute code in the scripts that read it (the same posture as operator.lib.sh / # skip-dirs.lib.sh). conf.lib.sh also carries the KEY=value grammar, so a manifest and -# operator.conf read identically; a load failure there leaves this file defining NOTHING and -# returning non-zero, so a consumer resolves no providers rather than guessing. +# operator.conf read identically; a load failure there leaves this file defining NO RESOLVER and +# returning non-zero, so a consumer falls back rather than guessing. # # Manifest -- /usr/local/lib/ai-tools/{agents,integrations}.d/<name>.conf, one per installed # member package. <name> (the basename) is the token an operator writes in AI_TOOLS_AGENTS / @@ -29,8 +29,8 @@ # key absent -> enabled = installed providers with default_enable=yes (the safe baseline) # conf unreadable/malformed/UNTRUSTED -> treated as absent (safe baseline, never "enable all") # a listed name with no installed manifest -> reported and skipped, never guessed -# A default_enable=yes on a manifest is the shipping package's claim that its provider widens no -# host surface beyond the sandbox; a surface-widening one ships default_enable=no and is enabled +# A default_enable=yes on a manifest is the shipping package's claim that its provider leaves +# host surface unchanged beyond the sandbox; a surface-widening one ships default_enable=no and is enabled # only when an operator names it. The operator's explicit list always overrides the default. # # ── The sandbox cannot widen its own surface ───────────────────────────────────────────────── @@ -52,8 +52,8 @@ fi # Shared KEY=value grammar + the trust predicate. REQUIRED: without it this file cannot parse a # manifest or tell a trusted input from a planted one, and guessing either would be exactly the -# fail-open this seam exists to prevent. Return non-zero and define nothing, so the consumer's -# `source ... && declare -F ...` guard resolves no providers. +# fail-open this seam exists to prevent. Return non-zero and define no resolver, so the consumer's +# `source ... && declare -F ...` guard then falls back. # shellcheck source=SCRIPTDIR/conf.lib.sh if ! source "${BASH_SOURCE[0]%/*}/conf.lib.sh" 2>/dev/null \ || ! declare -F ai_tools_conf_read >/dev/null 2>&1 \ @@ -167,7 +167,7 @@ _ai_tools_provider_dir_trusted() { # _ai_tools_warn_uninstalled <manifest-dir> <conf-key> <active> <list> : report each # explicitly-requested (allowlisted) name that has no <name>.conf in the manifest dir -- never # guessed into a package name. The baseline case (no allowlist) can only enable manifests that -# exist, so it has nothing to warn. +# exist, so it has no name to warn about. _ai_tools_warn_uninstalled() { local dir="$1" conf_key="$2" active="$3" list="$4" [[ "${active}" == yes ]] || return 0 diff --git a/src/usr/local/lib/ai-tools/relabel.lib.sh b/src/usr/local/lib/ai-tools/relabel.lib.sh index 84fabc2a..1cf678eb 100644 --- a/src/usr/local/lib/ai-tools/relabel.lib.sh +++ b/src/usr/local/lib/ai-tools/relabel.lib.sh @@ -138,7 +138,7 @@ _ai_tools_is_sandbox() { [[ "$1/" == "${AI_TOOLS_SANDBOX_ROOT}/"* ]]; } # -- is one a plain `restorecon` deliberately PRESERVES. Only `-F` resets those to the project # type. Skipping it leaves such a file unreadable to the confined agent (ai_tools_t), whose startup # workspace walk then denies on every one -- a per-file AVC that setroubleshootd amplifies into a -# host-wide CPU flood. `-F` rewrites nothing already correct (restorecon compares before it writes, +# host-wide CPU flood. `-F` does not rewrite a path that is already correct (restorecon compares before it writes, # so it is idempotent on a matching context and costs the same walk either way), so forcing pays # only for the drifted files it fixes. # @@ -159,7 +159,7 @@ ai_tools_label_project() { # Post-condition: verify the achieved label, do not trust restorecon's exit code. restorecon # exits 0 whenever it could WRITE a context -- including when the context it wrote is the wrong # type because no fcontext rule matched the path (a rule keyed on a prefix that libselinux - # aliases away, e.g. `/var/opt` -> `/opt` via file_contexts.subs_dist, matches nothing and + # aliases away, e.g. `/var/opt` -> `/opt` via file_contexts.subs_dist, does not match a path and # leaves the tree on its default type). Left unchecked that is a silent mislabel the confined # agent cannot use; treat it as a hard failure the caller reports rather than a false success. ai_tools_project_labelled "${dir}" || return 1 @@ -184,7 +184,7 @@ ai_tools_unlabel_project() { # `semanage fcontext` entries rather than carried in the base policy module: # # entrypoint_fcontext -> ai_tools_exec_t the launcher binary. Without this label its exec -# fires no domain transition and the session would run +# does not perform a domain transition and the session would run # unconfined (ai-tools-run refuses to launch instead). # config_dir -> ai_tools_home_t the agent's control-plane directory. Without it the # confined session cannot write its own state (the @@ -229,9 +229,9 @@ _ai_tools_entrypoint_path_reportable() { # ai_tools_entrypoint_reconcile_verdict <installed-path> <covered> <matched>: pure verdict, no I/O # -- reconcile what an agent's manifest DECLARES against what its package actually INSTALLED, # and print one of: -# ok the declared rule governs the installed entrypoint (or nothing is installed and the +# ok the declared rule governs the installed entrypoint (or no entrypoint is installed and the # rule matched a file anyway -- another Node version's copy, mid-upgrade) -# none nothing is installed and nothing matched: the agent is simply not provisioned yet +# none no entrypoint is installed and the rule matched no file: the agent is not provisioned yet # stale an entrypoint IS installed and the declared rule does not cover it # <installed-path> is the file the agent's launcher symlink resolves to, empty when it does not # resolve; <covered> is whether that file was among the pattern's matches; <matched> is whether @@ -260,7 +260,7 @@ ai_tools_entrypoint_reconcile_verdict() { # _ai_tools_entrypoint_policy_active: succeed when there is an ai_tools_exec_t to assign, i.e. # SELinux is on, the labelling tools are present, and the ai_tools module is loaded. Where it -# fails there is nothing to label and that is not an error -- the SELinux layer is optional. +# fails there is no entrypoint to label and that is not an error -- the SELinux layer is optional. _ai_tools_entrypoint_policy_active() { ai_tools_relabel_available || return 1 command -v semanage >/dev/null 2>&1 || return 1 @@ -334,7 +334,7 @@ _ai_tools_entrypoint_paths() { # the three cannot disagree about which file is the entrypoint. Runs as root, which can traverse # the 0750 toolchain the chain ends in. # -# Prints nothing and returns non-zero when the agent declares no usable launcher name, the link +# Prints an empty string and returns non-zero when the agent does not declare a usable launcher name, the link # does not resolve (the agent is not provisioned -- the ordinary pre-bootstrap state), or the # result is not reportable. The launcher name is allowlisted to one plain component before it # becomes a path, the same guard ai_tools_agent_manifest_field applies to an agent name, so a @@ -370,7 +370,7 @@ _ai_tools_verify_label() { # # The declared pattern stays the mechanism that APPLIES the label, because a `semanage fcontext` # rule is what makes the type survive a later restorecon -- resolution alone would relabel an -# inode nothing keeps labelled. Resolution is what CHECKS the result, so this helper's exit +# inode no rule keeps labelled. Resolution is what CHECKS the result, so this helper's exit # status answers the question the operator actually asked: will the next launch be confined? _ai_tools_label_agent_entrypoint() { local agent="$1" pattern path status=0 matched=no installed covered=no @@ -399,8 +399,8 @@ _ai_tools_label_agent_entrypoint() { done < <(_ai_tools_entrypoint_paths "${pattern}") case "$(ai_tools_entrypoint_reconcile_verdict "${installed}" "${covered}" "${matched}")" in - # 3, not 0: the rule registered and nothing failed, but no file took the type because none - # is installed yet. The caller reports that as "nothing to label" rather than as labels + # 3, not 0: the rule registered and no step failed, but no file took the type because none + # is installed yet. The caller reports that as "nothing to label" rather than as labels prose-check: allow # applied, which on an unprovisioned host is the difference between a true report and a # green line for work that did not happen. none) printf 'none %s its entrypoint\n' "${agent}" @@ -448,7 +448,7 @@ _ai_tools_label_agent_config_dir() { # stale <agent> <installed-path> an entrypoint IS installed and the agent's declared rule # does not cover it, so no relabel can label it -- the # agent package's manifest has to be updated -# skip <agent> <reason...> nothing to apply, or a declaration was refused +# skip <agent> <reason...> no rule to apply, or a declaration was refused # agent <agent> <ok|failed|none> that agent's whole outcome, closing its lines: every path it # declares took its type, one of them did not, or neither is # installed to label. This is what ai-tools-relabel-agent @@ -457,7 +457,7 @@ _ai_tools_label_agent_config_dir() { # toolchain they cannot read. # Returns 0 when every path it managed is correctly labelled, 1 when one is not, a rule could # not be registered, or a declaration is stale, and 2 when the SELinux layer is inactive -# (nothing to do). +# (no work to do). ai_tools_label_agent_paths() { _ai_tools_entrypoint_policy_active || return 2 declare -F ai_tools_enabled_agents >/dev/null 2>&1 || return 2 @@ -483,9 +483,9 @@ ai_tools_label_agent_paths() { # ai_tools_unlabel_agent_paths <agent>: drop that agent's declared file-context rules and restore # default labels on what they matched -- the erase-time counterpart, so a removed agent package -# leaves no rule behind for types its host may no longer define. Reads the manifest, so it runs +# leaves behind no rule for types its host may stop defining. Reads the manifest, so it runs # while that package's files are still present (rpm %preun). Returns 2 when the SELinux layer is -# inactive, 1 when the agent declares nothing usable, 0 otherwise. +# inactive, 1 when the agent does not declare a usable value, 0 otherwise. ai_tools_unlabel_agent_paths() { local agent="$1" pattern config_dir path dropped=1 _ai_tools_entrypoint_policy_active || return 2 @@ -517,7 +517,7 @@ ai_tools_unlabel_agent_paths() { # ai_tools_project_labelled <dir>: 0 if <dir>'s root currently carries # ai_tools_project_t. A cheap, read-only state check for idempotent callers -- it -# inspects the live label, makes no policy change, and needs no privilege. +# inspects the live label, leaves the policy alone, and does not need privilege. ai_tools_project_labelled() { local ctx ctx="$(ls -Zd "$1" 2>/dev/null | awk '{print $1}')" || return 1 diff --git a/src/usr/local/lib/ai-tools/safe-paths.lib.sh b/src/usr/local/lib/ai-tools/safe-paths.lib.sh index 6c599830..6999ffb0 100644 --- a/src/usr/local/lib/ai-tools/safe-paths.lib.sh +++ b/src/usr/local/lib/ai-tools/safe-paths.lib.sh @@ -25,7 +25,7 @@ # unchanged for every target that reaches it. # # Sourced (not executed) so every consumer shares ONE list and ONE matcher. Deployed -# 644 root:root (world-readable; carries no secrets; the operator wrapper, the CLI, and the +# 644 root:root (world-readable, and it must not hold a secret; the operator wrapper, the CLI, and the # root helpers all read it) like msg.lib.sh / log.lib.sh. # shellcheck disable=SC2034 # consumed by the sourcing scripts and the test suite @@ -67,16 +67,16 @@ ai_tools_protected_path_match() { # ai_tools_traverse_grant_allowed <path> <owner_user> # Return 0 when a TRAVERSE-ONLY ACL (u:SANDBOX_USER:--x) may be granted on <path>: it is a -# directory <owner_user> owns, and it either matches no protected path or matches ONLY as +# directory <owner_user> owns, and it either misses every protected path or matches ONLY as # <owner_user>'s own home root. Return 1 for every system directory, for /home itself, and for # any other user's home root. # # This is a SECOND, NARROWER predicate beside the target backstop above, not a relaxation of it. # ai_tools_protected_path_match still refuses a home root as the TARGET of a claim, an unclaim, a -# lockdown or any elevated walk, and nothing here changes that. What differs is the operation +# lockdown or any elevated walk, and this predicate leaves that unchanged. What differs is the operation # being vetted: a claim rewrites group, mode and ACLs across a whole tree, while this grants one # `--x` entry on one directory -- search permission on that directory alone, conveying no listing -# of it and nothing at all about the files inside, whose own modes and ACLs still decide. Refusing +# of it and no access at all to the files inside, whose own modes and ACLs still decide. Refusing # an operator's own home root for THAT is what made every project at /home/<user>/<proj> # permanently unreachable, with a sandbox clone the only way in. # diff --git a/src/usr/local/lib/ai-tools/secret-patterns.lib.sh b/src/usr/local/lib/ai-tools/secret-patterns.lib.sh index 3f9f0958..babe1a68 100644 --- a/src/usr/local/lib/ai-tools/secret-patterns.lib.sh +++ b/src/usr/local/lib/ai-tools/secret-patterns.lib.sh @@ -13,8 +13,8 @@ # 700 .config/ai-tools dir -- can neither read nor write it; the root helpers read it on the # user's behalf. # This mirrors how allowed-projects is owned and consumed. When the file is -# absent or yields no usable patterns, the built-in defaults below apply, so -# classification never silently degrades to "match nothing". +# absent or parses to an empty set, the built-in defaults below apply, so +# classification never silently degrades to an empty pattern set. # # Config-file format: one pattern per line; '#' comments and blank lines ignored; # surrounding whitespace trimmed. Patterns are basename globs matched @@ -60,7 +60,7 @@ readonly -a _AI_TOOLS_DEFAULT_SECRET_PATTERNS=( # overrides it (a test hook), else `<PROJECTS_HOME>/.config/ai-tools/secret-patterns` -- so a # caller that has resolved an operator first (ai_tools_resolve_owner for the path's owner, or # ai_tools_load_operator) reads that operator's file. Falls back to the built-in defaults when -# the file is unreadable or contains no patterns. Idempotent. +# the file is unreadable or parses to an empty set. Idempotent. ai_tools_load_secret_patterns() { AI_TOOLS_SECRET_PATTERNS=() local line 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 82da1abf..b60efa1d 100644 --- a/src/usr/local/lib/ai-tools/selinux-groups.lib.sh +++ b/src/usr/local/lib/ai-tools/selinux-groups.lib.sh @@ -85,7 +85,7 @@ ai_tools_selinux_group_valid() { # writing -- it then dies of SIGPIPE, and under the `set -o pipefail` every consumer of this # library runs with, the pipeline reports 141 for a probe that SUCCEEDED. The module reads as # absent at random, and each caller acts on that: no label registered, no group reported -# loaded. A here-string is fully written before grep starts, so nothing can exit early on it. +# loaded. A here-string is fully written before grep starts, so no reader can exit early on it. ai_tools_selinux_group_loaded() { local modules modules="$(semodule -l 2>/dev/null || true)" diff --git a/src/usr/local/lib/ai-tools/services.lib.sh b/src/usr/local/lib/ai-tools/services.lib.sh index 79124902..1500edc1 100644 --- a/src/usr/local/lib/ai-tools/services.lib.sh +++ b/src/usr/local/lib/ai-tools/services.lib.sh @@ -6,7 +6,7 @@ # warning (critical system units only), so the detection and the canonical purpose/remedy text live # here ONCE and each consumer only formats -- no duplicated service knowledge. # -# Pure data + detection: this library renders nothing (no msg.lib dependency). A consumer sources it, +# Pure data + detection: this library does not render output (no msg.lib dependency). A consumer sources it, # scans, and formats the result however it likes (a framed warn at launch, a plain table in --status). # # Detection is two-sourced, by scope. A system unit is queried live (`systemctl is-active`, which @@ -19,18 +19,18 @@ # in some state, so a unit an optional package never installed is reported as such rather than as # something this host merely cannot see. # -# A STAMP IS NOT TRUSTED INPUT, and nothing here pretends otherwise. Its writer is the sandbox +# A STAMP IS NOT TRUSTED INPUT, and no reader here treats it as such. Its writer is the sandbox # account, so that account can state any outcome it likes; the mode on the file and its directory # bound WHAT it can touch (one inode's contents -- not the directory, not another file, not a # symlink out of the tree), never whether the contents are true. Two things make that acceptable -# rather than a hole. The stamp gates NOTHING: it is rendered in one status report, is never +# rather than a hole. The stamp gates NO DECISION: it is rendered in one status report, is never # evaluated, and every value is read through ai_tools_service_stamp_field, which clamps it to a # short safe-charset token so no control byte or escape sequence reaches the operator's terminal. # And it is never the weakest link -- an agent able to write it can already write the toolchain the # stamp reports on, which is the more valuable target by far. (On an enforcing host the confined # ai_tools_t session can write neither: both resolve to usr_t, which the domain may only read.) # -# Sourced, not executed. Deployed 644 root:root -- it carries no secrets, and the two principals that +# Sourced, not executed. Deployed 644 root:root -- it must not hold a secret, and the two principals that # source it (the operator launch wrapper and the unprivileged CLI) both need to read a system unit's # is-active/is-enabled, which any user may. @@ -57,7 +57,7 @@ readonly _AI_TOOLS_SERVICES_LIB_LOADED=1 # stamp_mode = what that stamp says ABOUT THIS UNIT -- two units can share one stamp and read # different things from it, which is how the timer gets a verdict of its own: # result -- the run's RESULT is this unit's verdict (it IS the unit that ran), -# including a run that correctly did nothing (RESULT=skipped). +# including a run that correctly declined to act (RESULT=skipped). # fired -- only the RECENCY of a SYSTEMD-STARTED run matters: such a run, # successful or not, is proof this unit triggered it, so a failed run # leaves the trigger healthy. A run the operator started by hand is not @@ -94,7 +94,7 @@ ai_tools_service_field() { } # ai_tools_service_stamp_field <stamp-path> <KEY> -- PRINT the value of KEY from a last-run stamp -# file, or nothing; ALWAYS returns 0. A stamp is written by an UNPRIVILEGED sandbox-account job and +# file, or an empty string; ALWAYS returns 0. A stamp is written by an UNPRIVILEGED sandbox-account job and # read by the operator's terminal, so the read is defensive on every axis a writer controls: a # symlink or non-regular path is refused outright (the file is never followed somewhere else), only # the first 4 KiB is examined (an unbounded line cannot exhaust the reader), the line must match an @@ -110,8 +110,8 @@ ai_tools_service_stamp_field() { } # ai_tools_service_unit_property <unit> <property> -- PRINT one systemd property of a SYSTEM unit, -# or nothing. ALWAYS returns 0. A system unit's properties are world-readable, so this needs no -# privilege; a sandbox-user unit's are not reachable from here at all and are read from a stamp +# or an empty string. ALWAYS returns 0. A system unit's properties are world-readable, so this does +# not need privilege; a sandbox-user unit's are not reachable from here at all and are read from a stamp # instead. The value is clamped to the same display-safe charset as a stamp field: it reaches the # operator's terminal, and while systemd is a trusted writer, one reader for both records means one # place where that guarantee is made. @@ -126,15 +126,15 @@ ai_tools_service_unit_property() { } # ai_tools_service_stamp_age <stamp-path> [key] -- PRINT the whole seconds since the timestamp the -# stamp records under <key> (default FINISHED), or NOTHING when that cannot be determined (no stamp, -# no such key, an unparseable value, or no date(1)). ALWAYS returns 0. Consumers must treat -# "nothing" as "age unknown" and never as "old": a missing age must not manufacture a 'stale' +# stamp records under <key> (default FINISHED), or an EMPTY STRING when that cannot be determined +# (no stamp, no such key, an unparseable value, or no date(1)). ALWAYS returns 0. Consumers must +# treat the empty string as "age unknown" and never as "old": a missing age must not manufacture a 'stale' # verdict out of an absence. # The key is a parameter because more than one record in this grammar carries a time an operator # reads as an age -- the updater's stamp (FINISHED) and an entrypoint pin (VERIFIED) -- and both # must age through one implementation rather than two that can drift. # The value reaches date(1) only after ai_tools_service_stamp_field's charset clamp, and as a single -# argument, so a hostile stamp can make this fail to parse but nothing more. +# argument, so a hostile stamp can make this fail to parse and no worse. ai_tools_service_stamp_age() { local stamp="$1" key="${2:-FINISHED}" finished stamped now finished="$(ai_tools_service_stamp_field "${stamp}" "${key}")" @@ -155,9 +155,9 @@ ai_tools_service_stamp_age() { # registry ships with an OPTIONAL package, so absence is a normal state, not a fault to chase. # The account's own ~/.config/systemd/user is deliberately NOT searched: it sits in a home the # operator cannot traverse. Every unit named here is shipped to the system-wide directory, so the -# omission costs nothing. +# omission leaves the report complete. # AI_TOOLS_USER_UNIT_DIRS overrides the ':'-separated search path, so a test does not depend on -# which optional packages the host has. It widens nothing -- the value decides only what a +# which optional packages the host has. It does not widen access -- the value decides only what a # read-only report says, and its reader already runs as the operator, who can read these paths # anyway. IFS is pinned for the split: this library is sourced into scripts that set their own. _ai_tools_user_unit_installed() { @@ -176,8 +176,8 @@ _ai_tools_user_unit_installed() { # exit status). ai_tools_service_state_of below takes a whole record and is what consumers call. # active -- the unit is running (is-active), or its stamp records a recent healthy run. # skipped -- the last run ended in a transient condition it did not cause and could not fix (the -# updater offline: the registry was unreachable, so nothing changed and the previous -# toolchain stays). Not a fault -- there is nothing for an operator to do, and calling +# updater offline: the registry was unreachable, so the toolchain was left alone and +# the previous version stays). Not a fault -- there is no action for an operator, and calling # it FAILED spends attention a real fault then competes with -- but not a claim of # health either, so it stays distinct from 'active' and keeps AGEING: a host that is # offline once reads skipped, one that has been offline for days reads 'stale'. @@ -189,9 +189,9 @@ _ai_tools_user_unit_installed() { # point: a schedule that quietly stops firing leaves every recorded run successful and # would otherwise read as a permanent, and increasingly wrong, OK. # absent -- the unit is not installed on this host (e.g. relabel.path on a base-only install, -# or the nvm-update pair without the nodejs integration) -- nothing to warn about. +# or the nvm-update pair without the nodejs integration) -- no fault to warn about. # unknown -- not checkable here: systemctl missing, or a sandbox-user unit that is installed but -# publishes no stamp (or has not run since the stamp was introduced). +# does not publish a stamp (or has not run since the stamp was introduced). ai_tools_service_state() { local unit="$1" scope="$2" stamp="${3:-}" stamp_mode="${4:-result}" max_age="${5:-}" # A sandbox-user unit's live state needs that account's own bus, which the operator cannot @@ -211,7 +211,7 @@ ai_tools_service_state() { # 'fired' reads recency alone: a run happened, so whatever triggers it is working, and its # outcome belongs to the unit that ran (reported separately, in 'result' mode). # Only a run SYSTEMD started is evidence about the trigger. A run the operator did by hand - # says nothing about the schedule, and counting it would report a dead timer as healthy for + # is no evidence about the schedule, and counting it would report a dead timer as healthy for # the whole grace window -- and, worse, suppress the staleness that is the only way a # stopped schedule shows up at all. A stamp whose TRIGGER is anything else (a hand run, or # one written before this field existed) declines the judgment rather than guessing either @@ -249,11 +249,11 @@ ai_tools_service_state() { # A Type=oneshot service is 'inactive' whenever it is HEALTHY -- it runs, does its work and # exits -- so is-active cannot judge it and would read every successful run as 'down'. Its # verdict is the result of its last run instead, which is also the only way a run that failed - # hours ago is still visible. Read from the unit's own type, so a oneshot added later needs no + # hours ago is still visible. Read from the unit's own type, so a oneshot added later does not need a # registry field: the property is what makes is-active meaningless, not this unit's identity. if [[ "$(ai_tools_service_unit_property "${unit}" Type)" == oneshot ]]; then - # Never run: nothing to report, and Result reads 'success' on a unit that has done - # nothing, which would otherwise be an OK no run has earned. + # Never run: no result to report, and Result reads 'success' on a unit that has not + # run at all, which would otherwise be an OK no run has earned. [[ -n "$(ai_tools_service_unit_property "${unit}" ExecMainStartTimestamp)" ]] \ || { printf 'unknown'; return 0; } if [[ "$(ai_tools_service_unit_property "${unit}" Result)" == success ]]; then @@ -284,7 +284,7 @@ ai_tools_service_state_of() { # a problem (down, failed, stale). The single definition of "broken", so the scanner's set and the # CLI's report cannot drift apart. 'unknown' is deliberately NOT one: it says the vantage point # cannot tell, which is not the same as a fault. Nor is 'skipped': it reports a run that correctly -# did nothing, and it becomes 'stale' on its own if the condition persists -- so the escalation is +# declined to act, and it becomes 'stale' on its own if the condition persists -- so the escalation is # the grace window's job, not this predicate's. ai_tools_service_needs_attention() { case "$1" in down|failed|stale) return 0 ;; *) return 1 ;; esac diff --git a/src/usr/local/lib/ai-tools/session-env.d/dotnet.env.sh b/src/usr/local/lib/ai-tools/session-env.d/dotnet.env.sh index 8f86ebdb..c42e6796 100644 --- a/src/usr/local/lib/ai-tools/session-env.d/dotnet.env.sh +++ b/src/usr/local/lib/ai-tools/session-env.d/dotnet.env.sh @@ -6,7 +6,7 @@ # # ai-tools-run sources this when `dotnet` is enabled in /etc/ai-tools/operator.conf # (AI_TOOLS_INTEGRATIONS). It self-gates on a host dotnet, so it is inert on a host without -# one even when enabled -- this integration packages no runtime. +# one even when enabled -- this integration does not ship a runtime of its own. # # One state root backs it, provisioned by `sudo ai-tools-dotnet setup` -- every integration keeps # its sandbox-side state under /opt/ai-tools/integrations/<name>, so no toolchain adds a dotdir to @@ -32,7 +32,7 @@ [[ -x /usr/bin/dotnet ]] || return 0 # The host SDK/runtime tree at its RPM path, or wherever the muxer resolves to. The muxer is -# already on the session PATH, so PATH itself needs no dotnet entry. +# already on the session PATH, so PATH itself does not need a dotnet entry. dotnet_root=/usr/lib64/dotnet [[ -d "${dotnet_root}" ]] || dotnet_root="$(dirname -- "$(readlink -f /usr/bin/dotnet)")" diff --git a/src/usr/local/lib/ai-tools/skip-dirs.lib.sh b/src/usr/local/lib/ai-tools/skip-dirs.lib.sh index bb04eeb6..c0278e28 100644 --- a/src/usr/local/lib/ai-tools/skip-dirs.lib.sh +++ b/src/usr/local/lib/ai-tools/skip-dirs.lib.sh @@ -12,10 +12,10 @@ # - handback sweeps: a skipped tree's files are NOT reclaimed, so they stay agent-owned # (harmless -- world-readable and regenerable). To have a tree's contents handed back to # the operator, remove it from the skip list (or run `ai-tools --reclaim --full`). -# - setgid/ACL normalization: a skipped tree receives no setgid bit or ACL. +# - setgid/ACL normalization: a skipped tree is given neither a setgid bit nor an ACL. # - secret lockdown: a skipped tree is not scanned for secret-named files. # -# Sourced, not executed. Deployed 644 root:root -- it carries no secrets (the names are +# Sourced, not executed. Deployed 644 root:root -- it must not hold a secret (the names are # documented) and three principals source it: the root helpers, the hooks (as the agent), # and the unprivileged CLI (the claim drift scan classifies hits under these names). # The matcher skips DIRECTORIES only @@ -50,7 +50,7 @@ AI_TOOLS_SKIP_CACHE_DIRS=(__pycache__) # regenerable caches # commas or whitespace between items, quotes optional, inline comments honored. Parsed, never # sourced, so a malformed/tampered config cannot execute code in the privileged helpers; # AI_TOOLS_OPERATOR_CONF is the same root-only test hook. A PRESENT key replaces its category's -# default (an empty value therefore means "skip nothing in this category"). +# default (an empty value therefore means "skip no directory in this category"). # # The load is fail-SOFT, unlike the provider gating: a skip list is a walk-cost optimization, not # an access boundary (see the header), so a missing conf.lib.sh leaves the compiled-in defaults in @@ -75,7 +75,7 @@ fi # Build the skip set for a consumer from the LIB-OWNED per-consumer defaults below, and # expose it two ways: AI_TOOLS_SKIP_NAMES (the flat directory-name list) and # AI_TOOLS_SKIP_FIND_EXPR (a find fragment "( -type d ( -name a -o -name b ) ) -prune -o", -# empty when nothing is skipped). Splice the fragment into a find between the start dir and +# empty when no directory is skipped). Splice the fragment into a find between the start dir and # the action predicates. The consumer only names itself -- the lib supplies the categories # AND whether .git is skipped. The optional second arg (true|false) overrides the .git # default for that one call; consumers do not normally pass it ('' keeps the default). @@ -92,10 +92,10 @@ fi # setgid heavy + .git. Claim-time normalization; .git normalized separately by # setfacl setfacl --with-git. # unclaim heavy + .git. Unclaim reversal; .git reverted in its own pass. -# lockdown heavy + .git. Secret sweep; .git object names are hashes -- nothing to match. +# lockdown heavy + .git. Secret sweep; .git object names are hashes -- no name to match. # reclaim heavy only. On-demand reclaim WALKS .git (the one tree the per-session # sweeps leave behind). -# reclaim-full nothing. Reclaim the entire tree, heavy trees and .git included. +# reclaim-full none. Reclaim the entire tree, heavy trees and .git included. ai_tools_skip_find_expr() { local consumer="${1:?ai_tools_skip_find_expr: consumer required}" skip_git_arg="${2:-}" local root="${3:-}" 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 2d707b66..20997120 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-admin.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-admin.sh @@ -108,11 +108,11 @@ 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 +# which drives one function with sudo stubbed -- it does not assert anything 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 + # --help and --version read no host state and leave the host as it is, 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. @@ -148,7 +148,7 @@ export AI_TOOLS_MSG_FULLWIDTH=1 # Edits ONLY the OPERATORS line in an existing file, preserving every other setting the # operator maintains there (the SKIP_* categories; template: src/etc/ai-tools/operator.conf, # reference: skip-dirs.lib.sh); seeds a minimal file when absent. 644: world-readable (the -# agent hooks and the root helpers both read it; it carries no secret) and root-write-only, +# agent hooks and the root helpers both read it; it is free of secrets) and root-write-only, # so the agent cannot rewrite the identity root hands files back to. write_operators() { install -d -o root -g root -m 755 /etc/ai-tools @@ -287,7 +287,7 @@ wire_dedup() { # never refuses: it names the --for command that claims on the account's behalf. # # A non-zero answer is a refusal only while sudo is answering at all -- for a command no rule -# matches, `sudo -l` exits non-zero and prints NOTHING, so there is no message separating that from +# matches, `sudo -l` exits non-zero with EMPTY output, so there is no message separating that from # a sudo which failed for its own reasons (an unreachable sudoers backend, a host that refuses -l). # It is separated by a second probe, the same way the CLI's sudo_grant_missing does it: listing the # account's whole rule set, which succeeds for anyone this command has just enrolled, since the @@ -347,7 +347,7 @@ op_add() { # The sandbox account needs a systemd --user instance without an interactive login: its # nvm-update timer and each ai-tools-run session unit run there, and it has no login shell, so # only linger keeps that instance alive. An operator runs claude from its own active login, - # so it needs no linger here; enabling operator linger for other reasons is host policy. + # so it does not need linger here; enabling operator linger for other reasons is host policy. log "enabling linger for ${SANDBOX_USER}" loginctl enable-linger "${SANDBOX_USER}" 2>/dev/null || log "warn: could not enable linger for ${SANDBOX_USER}" @@ -392,7 +392,7 @@ op_list() { # which compiles a group from source in a repo checkout -- this runs on any installed host. # 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). +# no policy to manage) when SELinux is disabled; dies when semodule is absent (a real gap). require_selinux() { if [[ "$(getenforce 2>/dev/null)" == "Disabled" ]]; then log "SELinux is disabled on this host -- no policy groups to manage" @@ -500,7 +500,7 @@ sel_list() { # # The treatment follows the file's CONTENT, rather than one generic merge covering all three: # json hook DECLARATIONS merge additively -- they are control plane, and a declaration the -# file lacks means a shipped hook installs but nothing invokes it. The permission arrays +# file lacks means a shipped hook installs but no event invokes it. The permission arrays # are the host's and stay exactly as written (claude-settings.rule.md). # keyval reported, never rewritten. An absent key already means its default, so a stale file # costs knowledge rather than behaviour, and its layout is the operator's own prose. @@ -579,7 +579,7 @@ _pu_json() { log " merged. the previous file is saved as ${_ai_tools_conf_merge_backup}" # Offer the cleanup against what is actually left. Once the permission rules match too, the - # .rpmnew has nothing further to say and keeping it only invites a second look later. + # .rpmnew has no difference left to report and keeping it only invites a second look later. if command -v diff >/dev/null 2>&1 && diff -q "${deployed}" "${rpmnew}" >/dev/null 2>&1; then log " ${deployed} now matches the shipped file exactly." _pu_cleanup "${rpmnew}" y @@ -690,7 +690,7 @@ system_dispatch() { } # 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 command. +# defined and no command dispatched, so the caller's arguments are not read as a command. [[ "${BASH_SOURCE[0]}" == "${0}" ]] || return 0 # --help/-h and --version are answered above, before the root check. 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 9936d8a6..d36e8a6d 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh @@ -16,18 +16,18 @@ # The allowlist is the LAUNCH GATE: an entry here is what lets that operator's agent start in # the directory, and what makes the ownership handback restore files to them. Editing another # operator's gate stays inside the trust model's "%ai-ops operators are trusted" boundary, but -# it is not something the sandbox account may ever reach, so the helper is 750 root:root, holds +# it is not something the sandbox account may ever reach, so the helper is 750 root:root, and holds # NO NOPASSWD grant (the invoking human authenticates, like ai-tools-lockdown/-setfacl/-relabel), # and every mutation is logged with both the caller and the target. # # Every gate below resolves to LESS access on failure, never more: -# - no SUDO_UID (a bare root call, or an unclean sudo context) -> refuse, change nothing -# - the CALLER is not in OPERATORS -> refuse, change nothing -# - the TARGET is not in OPERATORS -> refuse, change nothing -# - the target is the sandbox account or root -> refuse, change nothing -# - the path is not a real directory, or is a protected system -> refuse, change nothing +# - no SUDO_UID (a bare root call, or an unclean sudo context) -> refuse, write no entry +# - the CALLER is not in OPERATORS -> refuse, write no entry +# - the TARGET is not in OPERATORS -> refuse, write no entry +# - the target is the sandbox account or root -> refuse, write no entry +# - the path is not a real directory, or is a protected system -> refuse, write no entry # directory (safe-paths backstop) -# - a required library will not load -> refuse, change nothing +# - a required library will not load -> refuse, write no entry # A refused run leaves the target's allowlist byte-identical, so a failure can only ever leave # the agent with fewer places to launch than the operator intended, never more. # @@ -122,7 +122,7 @@ fi # ── Caller gate ────────────────────────────────────────────────────────────────── # The identity that authorizes this edit is the operator who invoked sudo, resolved from the # kernel-supplied SUDO_UID rather than from SUDO_USER (a name is spoofable through the -# environment; the uid sudo sets is not). A direct root call carries no such context and is +# environment; the uid sudo sets is not). A direct root call arrives without that context and is # refused rather than defaulting to some operator. caller_uid="${SUDO_UID:-}" [[ -n "${caller_uid}" ]] \ @@ -176,7 +176,7 @@ readonly caller allowlist target_home target_group # ── print ──────────────────────────────────────────────────────────────────────── # Read-only, and the only action that does not require a path. An absent allowlist prints -# nothing and succeeds: "this operator has approved no projects" is a complete answer, and the +# an empty list and succeeds: "this operator has approved no projects" is a complete answer, and the # caller (the CLI's snapshot) treats an empty list exactly as it treats a file of comments. if [[ "${ACTION}" == print ]]; then [[ -r "${allowlist}" ]] || exit 0 @@ -230,7 +230,7 @@ case "${ACTION}" in printf 'ai-tools-allowlist: added %s for %s\n' "${canonical}" "${OPERATOR}" ;; remove) - # A missing allowlist has nothing to remove -- report it and succeed, so an unclaim that + # A missing allowlist has no entry to remove -- report it and succeed, so an unclaim that # runs twice is not an error. The library drops the exclusion line too, so de-registering # a project the operator had parked leaves no '!' behind to park whatever is claimed at # that path next. @@ -248,7 +248,7 @@ case "${ACTION}" in printf 'ai-tools-allowlist: removed %s for %s\n' "${canonical}" "${OPERATOR}" ;; enable) - # The one action that WIDENS the target's launch gate. It adds no line: the '!' comes off + # The one action that WIDENS the target's launch gate. It does not append a line: the '!' comes off # the line the operator wrote, in place, and a path the file does not name is refused # rather than registered -- registering one is a claim, which scans for secrets first. rc=0; ai_tools_conf_allowlist_enable "${allowlist}" "${canonical}" || rc=$? diff --git a/src/usr/local/libexec/ai-tools/ai-tools-audit.sh b/src/usr/local/libexec/ai-tools/ai-tools-audit.sh index fcac4b0d..abdb000a 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-audit.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-audit.sh @@ -8,7 +8,7 @@ # The detections already exist and are already recorded -- what they lacked was a # reader, and a detection nobody reads is decoration. # -# It INVENTS no detection and parses no per-case wording. The root-only file sink already +# It does not invent a detection, nor parse per-case wording. The root-only file sink already # encodes severity in its line format (`<ts> <LEVEL> [<pid>] <msg>`, written by log.lib.sh and, # in the same format, by the handback daemon), so a finding is simply a line at NOTICE or above. # That is what keeps this from drifting: a helper that adds a new warning is reported here the @@ -22,7 +22,7 @@ # own account of itself, reportable but not proof, and are shown in a separately titled section # rather than mixed into the first (see .claude/rules/logging.rule.md). # -# Root-only: the file sink is unreadable to anyone else, so there is nothing for a non-root +# Root-only: the file sink is unreadable to anyone else, so there is no trail for a non-root # caller to do here. Reached through `sudo ai-tools --audit` with no NOPASSWD grant, like # ai-tools-lockdown and ai-tools-reclaim. # @@ -45,7 +45,7 @@ readonly DEFAULT_SINCE='7 days ago' readonly SANDBOX_USER='@SANDBOX_USER@' -# Shared leveled logger. This helper WRITES no audit line of its own -- reading a trail is not +# Shared leveled logger. This helper does not write an audit line of its own -- reading a trail is not # an event worth adding to it -- but it uses the sanitizer, which reduces a log line to # safe-for-display characters before it reaches the operator's terminal. That is load-bearing # here, not decorative: every line this command prints came from a file recording @@ -97,7 +97,7 @@ readonly SINCE_DISPLAY # ── The authoritative source: the root-only file sink ──────────────────────────────────────── # collect_file_findings -- PRINT one `<component>|<timestamp>|<level>|<message>` per finding. # Two passes by design: a cheap severity grep over the whole file, then a date comparison only -# on the lines that survived it. Findings are rare, so the expensive half runs on almost nothing. +# on the lines that survived it. Findings are rare, so the expensive half runs on almost no line. collect_file_findings() { local log_file component line entry_timestamp entry_level entry_epoch entry_message for log_file in "${AI_TOOLS_LOG_DIR}"/*.log; do @@ -123,7 +123,7 @@ collect_file_findings() { # ai-tools-run recorded, in the same shape as a file finding so it collapses through the same # renderer: a refusal that recurs on every launch attempt would otherwise flood the report # exactly as the handback lines did. Filtered by the sandbox account's uid as every documented query is: the tag alone -# attributes nothing, and here the legitimate writer IS the account under scrutiny -- which is +# does not establish identity, and here the legitimate writer IS the account under scrutiny -- which is # exactly why these are reported apart from the file sink's evidence. collect_launch_refusals() { local sandbox_uid line entry_timestamp entry_message @@ -149,11 +149,11 @@ collect_launch_refusals() { # one line per occurrence -- the handback daemon's refusals alone run to hundreds over a week on # a host that exercises them -- and a report that lists each one buries the single ERROR that # needs acting on under a wall of a condition already understood. That is the same reason INFO -# is out of scope entirely: an audit nobody finishes reading reports nothing. +# is out of scope entirely: an audit nobody finishes reading is one nobody acts on. # # Findings are grouped by their message with digit runs replaced by `#`, so occurrences that # differ only in a pid, a count, or a timestamp collapse into one line carrying the number of -# times it happened and the most recent example in full. Nothing is hidden -- the count states +# times it happened and the most recent example in full. No occurrence is hidden -- the count states # what was folded, and the underlying files are named above. # # Ordering is by severity first and recency second, because those are the two questions actually 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 a80dae84..e6506e95 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-bootstrap.sh @@ -8,7 +8,7 @@ # so it is a command run once by the operator -- never an RPM scriptlet, which must succeed # offline and inside build chroots. The scheduled nvm-update timer maintains the tree afterwards. # -# Agent-agnostic: it installs no hardcoded agent. Which agents to provision -- their npm package +# Agent-agnostic: it does not install a hardcoded agent. Which agents to provision -- their npm package # and launcher name -- comes from the per-package manifests under # /usr/local/lib/ai-tools/agents.d, gated by operator.conf AI_TOOLS_AGENTS (providers.lib.sh). # With no manifests deployed yet it provisions Node alone; a re-run after an ai-tools-agents-* @@ -64,7 +64,7 @@ resolve_nvm_version() { # seed a safe default (ai-tools@<domain-or-hostname>); this is the one interactive point both # install flows share (an RPM %post cannot prompt), so the operator can adopt their own git # identity, keep the default, or edit the file by hand. Runs only when the control plane is -# present (the gitconfig exists) -- a bootstrap that precedes install.sh has nothing to +# present (the gitconfig exists) -- a bootstrap that precedes install.sh has no gitconfig to # configure and skips. Past that gate msg.lib is deployed, so it is REQUIRED like every other # prompting consumer (a missing lib is a broken install and dies, not a silent skip); an # unattended run keeps the default via msg.lib's no-tty path. @@ -126,9 +126,9 @@ configure_git_identity() { # seed_managed_assets_step: (re)seed the ai-tools-managed agents/skills from the pristine datadir # copies into the config directory of each agent that uses that asset format. The directories come -# from the manifests (control-plane.lib.sh), so this names no path of its own. Runs only when the +# from the manifests (control-plane.lib.sh), so this helper does not hardcode a path itself. Runs only when the # control plane is present (a config dir and the /usr/share/ai-tools pristine copies exist) and -# the seeder lib is deployed; a bootstrap that precedes install.sh has nothing to seed and skips. +# the seeder lib is deployed; a bootstrap that precedes install.sh has no source to seed and skips. # Past that gate msg.lib is deployed, so the update confirm requires it like every other prompting # consumer. Same non-overwrite and version rules as install.sh -- only ai-tools-* assets carrying # x-ai-tools-managed are touched, and an existing one updates only on confirm (default keep). See @@ -181,7 +181,7 @@ command -v curl >/dev/null 2>&1 || die "curl is required to fetch nvm" # Run from a neutral, world-traversable directory. The sudo -u ${SANDBOX_USER} steps below # inherit this process's CWD; invoked from an operator's private dir (e.g. ~/Downloads, mode # 0700) the sandbox account cannot traverse back into it, so nvm/npm's internal `find` warns -# "Failed to restore initial working directory". Nothing here depends on CWD (every path is +# "Failed to restore initial working directory". No step here depends on CWD (every path is # absolute), and / is always reachable, so move off the caller's directory up front. cd / @@ -222,7 +222,7 @@ done # re-run picks up the agents. Its stderr warns of an enabled-but-uninstalled agent. _providers_lib=/usr/local/lib/ai-tools/providers.lib.sh _agent_packages=(); _agent_launchers=() -# Guarded load: providers.lib.sh returns non-zero and defines nothing when its own dependency +# Guarded load: providers.lib.sh returns non-zero and leaves its resolvers undefined when its own dependency # (conf.lib.sh, the shared KEY=value grammar) is missing, so probe the resolver rather than assume # the source succeeded -- a bare `source` under set -e would abort the provision instead of falling # back to Node-only. @@ -309,7 +309,7 @@ fi # launcher is present. Runs as root: the agent cannot create top-level entries in the home # root. bin is the locked control-plane dir (0551 root:ai-tools); root writes the symlinks # here, and install.sh / the RPM repoint them through the root symlink helper afterwards. -# Agent runtime state needs no seeding: ai-tools-run pins CLAUDE_CONFIG_DIR to the +# Agent runtime state is not seeded here: ai-tools-run pins CLAUDE_CONFIG_DIR to the # group-writable .claude dir, where claude creates its own state files (.claude.json # included). if [[ ${#_agent_launchers[@]} -gt 0 ]]; then diff --git a/src/usr/local/libexec/ai-tools/ai-tools-chown.sh b/src/usr/local/libexec/ai-tools/ai-tools-chown.sh index 1ff06e5e..f923e26e 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-chown.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-chown.sh @@ -95,7 +95,7 @@ source "${SAFE_PATHS_LIB}" # Shared config grammar (ai_tools_conf_path_entry; see conf.lib.sh), which reads the # allowlist this helper gates every path on. REQUIRED like safe-paths.lib.sh: the bare source -# under set -e aborts if it is missing, rather than leaving a parser that matches nothing and +# under set -e aborts if it is missing, rather than leaving a parser that does not match any name and # silently declines every hand-back. Include-guarded, so a second source is a no-op. # shellcheck source=SCRIPTDIR/../../lib/ai-tools/conf.lib.sh source /usr/local/lib/ai-tools/conf.lib.sh diff --git a/src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh b/src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh index c0bc1aff..cb390be1 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-dotnet.sh @@ -3,8 +3,8 @@ # /usr/local/libexec/ai-tools/ai-tools-dotnet # Provision and inspect the dotnet integration for the ai-tools sandbox. Root/sudo helper. # -# The .NET SDK/runtime itself is the HOST's RPM-managed dotnet (this integration adds no runtime -# and carries no dotnet RPM dependency). This helper sets up the sandbox-side directories and +# The .NET SDK/runtime itself is the HOST's RPM-managed dotnet (this integration does not ship +# a runtime of its own, and does not take a dotnet RPM dependency). This helper sets up the sandbox-side directories and # SELinux labels the session-env fragment (session-env.d/dotnet.env.sh) relies on, and installs # shared global tools an operator wants available to every project. "Modifications require sudo": # the tools dir is root-owned and read-only to the agent, so only this helper changes it. @@ -77,7 +77,7 @@ selinux_active() { # label_state <path> : give <path> the label the base policy already maps it to. No `semanage`: # the integrations root carries a STATIC rule in ai_tools.fc, so every integration's state is -# covered by one base-owned rule and a new toolchain adds no policy of its own. A failure is +# covered by one base-owned rule and a new toolchain does not add policy of its own. A failure is # fatal -- a silently unlabelled dir breaks the integration only later, inside a confined # session, as an opaque denial. label_state() { diff --git a/src/usr/local/libexec/ai-tools/ai-tools-handback.py b/src/usr/local/libexec/ai-tools/ai-tools-handback.py index 0fc04ab4..f650a930 100644 --- a/src/usr/local/libexec/ai-tools/ai-tools-handback.py +++ b/src/usr/local/libexec/ai-tools/ai-tools-handback.py @@ -177,7 +177,7 @@ def _send(text): def main(): - # Resolve the expected sandbox UID at startup. A missing account means nothing + # Resolve the expected sandbox UID at startup. A missing account means no path # valid can connect, so refuse all requests (fail closed). try: expected_uid = pwd.getpwnam(_SANDBOX_USER).pw_uid diff --git a/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh b/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh index 6e4916ab..5463d03f 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh @@ -3,10 +3,10 @@ # /usr/local/libexec/ai-tools/ai-tools-launcher-symlink # Atomically repoints an agent's stable launcher symlink -- /opt/ai-tools/bin/<launcher> -- at a # versioned binary under the sandbox account's nvm. Idempotent: it skips the repoint (and its log -# line) when the link already points at the target and that target's entrypoint needs no relabel, +# line) when the link already points at the target and that target's entrypoint is already labelled, # so the daily same-version updater run is a quiet no-op. # -# It names no agent: the launcher is the TARGET's own basename, accepted only when an ENABLED +# It is agent-agnostic: the launcher is the TARGET's own basename, accepted only when an ENABLED # agent manifest claims it -- the same allowlist ai-tools-run builds -- so the link it writes is # always <bin>/<launcher> for a declared launcher, and never differs from the binary it points at. # @@ -49,7 +49,7 @@ readonly LINK="${BIN_DIR}/${LAUNCHER}" # ...and that name must belong to an ENABLED agent: without this, any binary sitting in a # versioned bin/ could be given a stable link in the locked control-plane dir. An unresolvable # allowlist REFUSES rather than degrading to "allow anything", so probe the resolver rather than -# assume the source succeeded (providers.lib.sh defines nothing when its dependency is missing). +# assume the source succeeded (providers.lib.sh leaves its resolvers undefined when its dependency is missing). readonly PROVIDERS_LIB="/usr/local/lib/ai-tools/providers.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/providers.lib.sh if ! source "${PROVIDERS_LIB}" 2>/dev/null \ @@ -71,7 +71,7 @@ done < <(ai_tools_enabled_agents 2>/dev/null) [[ -d "${BIN_DIR}" ]] || err "${BIN_DIR} missing" # Idempotency guard. The repoint is also the sole trigger for the ai-tools-relabel.path watcher -# (the rename below changes an entry in the watched bin directory), so skipping it when nothing +# (the rename below changes an entry in the watched bin directory), so skipping it when no change # changed must not skip a pending relabel: entrypoint_relabel_pending reports whether the binary # the link resolves to still needs its ai_tools_exec_t label -- true for a freshly (re)minted # entrypoint, including a same-version reinstall. Any uncertainty answers "pending", so the @@ -92,7 +92,7 @@ entrypoint_relabel_pending() { } # Skip the repoint only when the stable link already points at TARGET AND no relabel is -# pending: nothing to do, so the daily no-op timer run stops churning the symlink and the +# pending: no work to do, so the daily no-op timer run stops churning the symlink and the # log. Otherwise fall through to the atomic repoint below. if [[ "$(readlink -- "${LINK}" 2>/dev/null || true)" == "${TARGET}" ]] \ && ! entrypoint_relabel_pending; then @@ -111,7 +111,7 @@ mv -Tf "${tmp}" "${LINK}" ai_tools_log_info "repointed ${LINK} -> ${TARGET}" printf 'ai-tools-launcher-symlink: %s -> %s\n' "${LINK}" "${TARGET}" -# This helper does NOT relabel the new entrypoint: it runs in ai_tools_handback_t, which holds +# This helper does NOT relabel the new entrypoint: it runs in ai_tools_handback_t, which is granted # no relabel rights by design (ai_tools.te), so the privilege stays off the agent-reachable # domain. The rename above instead trips the root-side ai-tools-relabel.path watcher, which # watches the bin DIRECTORY and so fires for whichever agent's link moved; `ai-tools --relabel` diff --git a/src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh b/src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh index fdef0bfd..4c56e928 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-lockdown.sh @@ -44,7 +44,7 @@ readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh" source "${OPERATOR_LIB}" 2>/dev/null || ai_tools_resolve_owner() { return 1; } # Directory-skip selector from the shared library (single source of truth, shared with -# session-hook.sh and ai-tools-setgid). A missing lib leaves a stub that skips nothing. +# session-hook.sh and ai-tools-setgid). A missing lib leaves a stub that descends everywhere. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -215,9 +215,9 @@ done < <(find "${expr[@]}" 2>/dev/null) # later chmod; owner-only.lib.sh is the reference for what comes off. # # `! -perm /077` selects "no group and no other bit set", the owner-only predicate, in the -# kernel -- so the filter costs no stat per path. A sealed DIRECTORY is printed and then pruned, +# kernel -- so the filter avoids a stat per path. A sealed DIRECTORY is printed and then pruned, # taking its subtree with it exactly as ai-tools-setgid/-setfacl do: the sandbox account cannot -# enter it, so nothing inside is reachable through it. Secret-named paths are left to the lock +# enter it, so no path inside is reachable through it. Secret-named paths are left to the lock # pass above, which seals them itself. declare -a sealed=() while IFS= read -r -d '' path; do @@ -314,9 +314,9 @@ _safe_apply() { } # _safe_seal <path>: strip the sandbox residue from an already-owner-only path, through a pinned -# fd like _safe_apply. Changes no mode bits and no ownership -- it removes only what the sandbox -# put there (owner-only.lib.sh) -- so unlike _safe_apply it needs no confirmation. -# Returns 0 when something was stripped, 1 when there was nothing to strip or the path is out of +# fd like _safe_apply. It leaves mode bits and ownership as they are -- it removes only what the sandbox +# put there (owner-only.lib.sh) -- so unlike _safe_apply it asks for no confirmation. +# Returns 0 when something was stripped, 1 when there was no residue to strip or the path is out of # scope. Sets AI_TOOLS_RESIDUE_SURFACE for the caller (a third-party setgid it declined to clear), # and AI_TOOLS_RESIDUE_ACTIONS to what came off. # Under --dry-run every gate above still runs and the strip itself reports instead of acting @@ -355,7 +355,7 @@ _safe_seal() { } # _seal_pass: run _safe_seal over every enumerated owner-only path and report. One pass serves -# both modes -- a dry run reports what would come off and changes nothing, an apply reports what +# both modes -- a dry run reports what would come off and applies none of it, an apply reports what # did -- so the preview cannot describe a pass other than the one that follows it. Under --dry-run # each hit names its path AND what it carries, since "3 paths would change" is not something an # operator can check before answering. diff --git a/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh b/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh index 4f67ef33..4697bc92 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh @@ -5,14 +5,14 @@ # -- the operator-invoked counterpart to the session sweeps. It walks <project> and hands each # @SANDBOX_USER@-owned path to ai-tools-chown, the SAME per-path trust boundary (allowlist # re-validation, exclusions, secret rules, TOCTOU-safe chown) the handback and sweeps use, so it -# carries none of its own. Nothing is .git-specific: .git is simply the one tree the per-session +# carries none of its own. No part of it is .git-specific: .git is simply the one tree the per-session # sweeps skip, so its objects linger @SANDBOX_USER@-owned, which is the usual reason to run this -- # e.g. before an ACL-unaware backup, where ownership (not the user:<operator> ACL) is what survives # an rsync/tar. By default the heavy/transient trees (node_modules, .venv, ...) are left untouched # -- their agent ownership is harmless (world-readable, regenerable) -- while .git is included; # --full reclaims those too, for a fully operator-owned tree (a complete, ACL-independent backup). # -# The walk is two-phase: collect, then apply. Nothing to hand back is reported as exactly +# The walk is two-phase: collect, then apply. An empty hand-back set is reported as exactly # that before any change; otherwise ONE confirmation covers the whole set (count + a # sample with owner/group/mode), and each path is applied via ai-tools-chown --yes so the # per-path prompt never fires inside the batch. @@ -66,7 +66,7 @@ if ! source "${LOG_LIB}"; then fi # Directory-skip selector (shared single source of truth). A missing lib leaves a stub that -# skips nothing -- a slower but correct walk. +# descends everywhere -- a slower but correct walk. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -89,23 +89,23 @@ canonical="$(realpath -e -- "${TARGET}" 2>/dev/null)" || exit 0 [[ -d "${canonical}" ]] || exit 0 # Refuse the whole walk if the project root is a protected system directory, before find. ai_tools_assert_safe_target "${canonical}" "reclaim" || exit 3 -# Not under any operator's allowed-projects -> nothing legitimately to reclaim. Say so rather than +# Not under any operator's allowed-projects -> no path legitimately to reclaim. Say so rather than # exiting silently, so a direct `sudo ai-tools-reclaim` (past the CLI's own front-line check) still -# reports why it did nothing. The path is operator-supplied, so it prints without log_sanitize. +# reports why it reclaimed no path. The path is operator-supplied, so it prints without log_sanitize. ai_tools_resolve_owner "${canonical}" || { printf 'ai-tools-reclaim: %s is not under any claimed project -- nothing to reclaim\n' "${canonical}" >&2 ai_tools_log_info "reclaim: ${canonical} not under any claimed project" exit 0 } -# Default reclaim walks .git but skips the heavy trees; --full skips nothing. The lib owns +# Default reclaim walks .git but skips the heavy trees; --full descends everywhere. The lib owns # both defaults -- the helper only names the consumer. if ${FULL}; then ai_tools_skip_find_expr reclaim-full '' "${canonical}"; else ai_tools_skip_find_expr reclaim '' "${canonical}"; fi # find <project> -xdev <skip dirs> -prune -o ( file|dir ) -user SANDBOX_USER -print0 declare -a expr=( "${canonical}" -xdev "${AI_TOOLS_SKIP_FIND_EXPR[@]}" \ '(' -type f -o -type d ')' -user "${SANDBOX_USER}" -print0 ) -# Two-phase: collect first, so a run with nothing to hand back says so and stops before +# Two-phase: collect first, so a run with no path to hand back says so and stops before # any change, and a run with work confirms ONCE for the whole set -- ai-tools-chown --yes # then applies each path without re-asking (one question, not one per .git object). The # sample carries owner/group/mode columns so what is about to change is visible up front. diff --git a/src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh b/src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh index cffbbf12..854c950c 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-relabel-agent.sh @@ -5,7 +5,7 @@ # # 1. PIN -- verify it against the checksum its vendor signed and record the result, which the # launch shim compares against (entrypoint-verify.lib.sh). Runs on every host, -# including the DAC-only one where step 2 has nothing to do. +# including the DAC-only one where step 2 has no label to apply. # 2. LABEL -- apply the SELinux file-context rules each agent declares and restore the labels on # what they match: its launcher binary -> ai_tools_exec_t, so its exec fires the -> # ai_tools_t domain transition, and its config directory -> ai_tools_home_t, so the @@ -15,7 +15,7 @@ # Why both live in one helper, and why a mismatch fails the run while an unverifiable entrypoint # does not: .claude/rules/updater.rule.md. # -# It names no agent: each ai-tools-agents-* package declares its own paths (entrypoint_fcontext +# It is agent-agnostic: each ai-tools-agents-* package declares its own paths (entrypoint_fcontext # and config_dir in its manifest under /usr/local/lib/ai-tools/agents.d), and this helper # registers them as local file-context rules. The labelling body lives in relabel.lib.sh, shared # with selinux/install-selinux.sh's verify pass so the two cannot drift. @@ -61,7 +61,7 @@ die() { ai_tools_log_error "$*"; printf 'ai-tools-relabel-agent: error: %s\n' "$ [[ "${EUID}" -eq 0 ]] || die "must run as root (via sudo)" # The labelling body + the manifest resolver it reads. REQUIRED: without them this helper can -# resolve no agent and would silently label nothing, leaving the next launch to fail closed on a +# resolve no agent and would silently label no file, leaving the next launch to fail closed on a # mislabelled entrypoint with no explanation. Bare source under set -e. # shellcheck source=SCRIPTDIR/../../lib/ai-tools/relabel.lib.sh source /usr/local/lib/ai-tools/relabel.lib.sh @@ -111,7 +111,7 @@ fi # pin_agent_entrypoint <agent> : verify one agent's installed entrypoint against its vendor's # signed release manifest and record the result. Returns 1 only on a mismatch. # -# AI_TOOLS_ENTRYPOINT_PIN_REUSE=1 lets a run answer from the existing pin when nothing that +# AI_TOOLS_ENTRYPOINT_PIN_REUSE=1 lets a run answer from the existing pin when no input that # decides the verdict has changed, skipping two network fetches and a gpgv per agent. It is # OPT-IN, and the two unattended callers are what it is for: the ai-tools-relabel.path watcher, # which an upgrade can fire several times for one change, and the agent package's %post. An @@ -134,7 +134,7 @@ pin_agent_entrypoint() { fi # The installed version, read from the package metadata beside the entrypoint. It is # sandbox-owned, so it is accepted only in semver shape -- and claiming a different version - # buys nothing: every candidate manifest is signed, so a false claim yields a checksum that + # gains the caller no advantage: every candidate manifest is signed, so a false claim yields a checksum that # does not match rather than one that does. version="$(_installed_agent_version "${entrypoint}")" if [[ -z "${version}" ]]; then @@ -174,7 +174,7 @@ pin_agent_entrypoint() { # _installed_agent_version <entrypoint> : print the MAJOR.MINOR.PATCH the package beside the # entrypoint declares, walking up to the nearest package.json the way ai-tools-run does for the -# launch banner. Bounded read; anything not semver-shaped yields nothing. +# launch banner. Bounded read; anything not semver-shaped yields an empty string. _installed_agent_version() { local dir="${1%/*}" declared for _ in 1 2 3; do @@ -223,7 +223,7 @@ record_label_outcome() { if (( status == 2 )); then say "SELinux confinement inactive -- no agent labelling needed" - # Recorded rather than left silent: on a DAC-only host there is nothing to label and nothing to + # Recorded rather than left silent: on a DAC-only host there is no entrypoint to label and no fault to # fix, which is a different report from "this vantage point cannot tell". for label_agent in "${enabled_agents[@]:-}"; do [[ -n "${label_agent}" ]] || continue diff --git a/src/usr/local/libexec/ai-tools/ai-tools-relabel.sh b/src/usr/local/libexec/ai-tools/ai-tools-relabel.sh index 4e3e3db8..9e6df2c3 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-relabel.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-relabel.sh @@ -126,7 +126,7 @@ ai_tools_relabel_lock ai_tools_log_warn "proceeding without the relabel lock -- ${AI_TOOLS_RELABEL_LOCK_NOTE}"; } if ai_tools_relabel_available; then :; else - # SELinux off or restorecon absent -- nothing to do, and not an error: the + # SELinux off or restorecon absent -- no work to do, and not an error: the # confinement layer simply is not in play on this host. echo "ai-tools-relabel: SELinux inactive -- no labelling needed for ${dir}" exit 0 diff --git a/src/usr/local/libexec/ai-tools/ai-tools-safedir.sh b/src/usr/local/libexec/ai-tools/ai-tools-safedir.sh index 393a58e2..bdbf9adc 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-safedir.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-safedir.sh @@ -53,8 +53,8 @@ fi readonly TARGET REMOVE FROM_CWD # Operator-identity resolver (operator.lib.sh): on ADD, confirms an operator's allowlist covers -# the path. A missing lib leaves ai_tools_resolve_owner a fail-closed stub, so an ADD finds no -# owner and leaves the file untouched. +# the path. A missing lib leaves ai_tools_resolve_owner a fail-closed stub, so an ADD leaves the owner +# unresolved and the file untouched. readonly OPERATOR_LIB="/usr/local/lib/ai-tools/operator.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/operator.lib.sh source "${OPERATOR_LIB}" 2>/dev/null || ai_tools_resolve_owner() { return 1; } @@ -97,7 +97,7 @@ _listed() { } # _confirm_cwd <question>: a shared-confirm gate (default yes -- registering safe.directory is a -# restrict-nothing convenience) that fires only when the path was defaulted from the current +# convenience that takes away no access) that fires only when the path was defaulted from the current # directory AND a terminal is present, so a bare interactive `sudo ai-tools-safedir` confirms # before registering/dropping cwd. When an explicit path was given (the tooling passes one) or # the run is non-interactive, it is a no-op, which keeps the helper from double-prompting after diff --git a/src/usr/local/libexec/ai-tools/ai-tools-setfacl.sh b/src/usr/local/libexec/ai-tools/ai-tools-setfacl.sh index a2b3e7db..5f39a44a 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-setfacl.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-setfacl.sh @@ -9,9 +9,9 @@ # what lets the operator co-write the tree -- work tree, and .git under --with-git -- without # joining @SANDBOX_GROUP@ and without waiting on the ownership handback. # -# Owner-only paths are never granted. When a path's mode carries no group and no other bits +# Owner-only paths are never granted. When a path's mode grants neither group nor other bits # (0600, 0700), no grant is applied to it -- not group:@SANDBOX_GROUP@:rwX (the agent's) nor -# user:<operator>:rwX (the operator's) -- a directory gets no default ACL, the mask is not +# user:<operator>:rwX (the operator's) -- a directory is given no default ACL, the mask is not # recalculated, and the mode bits are untouched. That mode is the operator's standing decision to # keep the path out of the sandbox account's reach, and a claim does not overrule it. What the # walk does instead is STRIP the sandbox residue such a path still carries (owner-only.lib.sh), @@ -76,7 +76,7 @@ readonly ACL_BASE="group:${GROUP}:rwX,other::---" # Two identities may legitimately hold a project tree's files: the resolved operator and the # sandbox account. A file belonging to a third party (root, another developer) is left untouched -- # claim must not pull a foreign file into the agent's group, even one the operator placed in the -# tree -- and COUNTED, so a walk that granted nothing is reported rather than silent; the project +# tree -- and COUNTED, so a walk that granted no path is reported rather than silent; the project # root hitting the guard is called out on its own, since it means the claim granted no access at # all. Matched by numeric UID; PROJECTS_UID is the resolved operator (set below). SANDBOX_UID="$(id -u "@SANDBOX_USER@" 2>/dev/null || echo -1)" @@ -95,7 +95,7 @@ fi # Directory-skip selector from the shared library (single source of truth, also used by # session-hook.sh and ai-tools-setgid). A missing lib (broken install) leaves a stub that -# skips nothing -- a slower but correct walk. +# descends everywhere -- a slower but correct walk. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -117,7 +117,7 @@ _is_secret_name() { ai_tools_is_secret_basename "$(basename -- "$1")" } -# Without setfacl (or on a filesystem without ACL support) there is nothing to do -- +# Without setfacl (or on a filesystem without ACL support) there is no ACL to apply -- # warn once and exit cleanly (best-effort, mirrors the other helpers' fail-soft). command -v setfacl >/dev/null 2>&1 \ || { ai_tools_log_warn "setfacl not found -- skipping ACL normalization for ${TARGET}"; exit 0; } @@ -145,7 +145,7 @@ canonical="$(realpath -e "${TARGET}" 2>/dev/null)" || exit 0 # Refuse the whole pass if the project root is a protected system directory. ai_tools_assert_safe_target "${canonical}" "ACL grant" || exit 3 -# Resolve the operator that owns this project (operator.lib.sh); no owner -> do nothing. The guard +# Resolve the operator that owns this project (operator.lib.sh); no owner -> exit without acting. The guard # below then acts only on paths the resolved operator or the sandbox account hold. ai_tools_resolve_owner "${canonical}" || exit 0 readonly ALLOWLIST="${AI_TOOLS_RESOLVED_ALLOWLIST}" PROJECTS_UID @@ -214,7 +214,7 @@ _safe_setfacl() { # Owner guard (checked on the pinned inode, TOCTOU-safe): only the projects user's # or the sandbox account's own files are eligible; anything else is left untouched. # Returns 3, not 1, so the walk can tell a third-party owner from a stat failure and - # report it: a walk that grants nothing must not read as one that had nothing to grant. + # report it: a walk that granted no path must not read as one with no path to grant. if [[ "${got_uid}" != "${PROJECTS_UID}" && "${got_uid}" != "${SANDBOX_UID}" ]]; then exec {fd}<&- return 3 @@ -225,7 +225,7 @@ _safe_setfacl() { # shows `-rw-------` and only the trailing `+` hints anything changed. Strip the residue the # path carries instead (owner-only.lib.sh), and report it: an owner-only .git under # --with-git is a deliberate no-op the operator has to be told about, not a share that - # quietly did nothing. + # quietly skipped. if ai_tools_is_owner_only "${got_mode}"; then ai_tools_strip_sandbox_residue "${fd}" "${got_ftype}" "${got_grp}" "${got_mode}" \ "${PROJECTS_GROUP:-}" || true @@ -312,7 +312,7 @@ find "${expr[@]}" 2>/dev/null \ "${owneronly}" >&2 fi # Surfaced for the same reason as the setgid walk's: the owner guard is the one skip - # that can leave a claim reporting success having granted nothing. + # that can leave a claim reporting success having granted no access. if (( thirdparty )); then ai_tools_log_warn "left ${thirdparty} path(s) under ${canonical} untouched: owned by neither ${PROJECTS_USER} nor @SANDBOX_USER@" if ${root_thirdparty}; then diff --git a/src/usr/local/libexec/ai-tools/ai-tools-setgid.sh b/src/usr/local/libexec/ai-tools/ai-tools-setgid.sh index df599cb8..a372ed3a 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-setgid.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-setgid.sh @@ -43,7 +43,7 @@ readonly GROUP="@SANDBOX_GROUP@" # Two identities may legitimately hold a project tree's dirs: the resolved operator and the sandbox # account. A directory belonging to a third party (root, another developer) is left untouched -- # normalization must not pull a foreign dir into the agent's group -- and COUNTED, so a walk that -# normalized nothing is reported rather than silent; the project root hitting the guard is called +# normalized no directory is reported rather than silent; the project root hitting the guard is called # out on its own, since it means the whole claim granted no access. Matched by numeric UID; # PROJECTS_UID is the resolved operator (set below). SANDBOX_UID="$(id -u "@SANDBOX_USER@" 2>/dev/null || echo -1)" @@ -62,7 +62,7 @@ fi # Directory-skip selector from the shared library (single source of truth, also used by # session-hook.sh and ai-tools-lockdown). A missing lib (broken install) leaves a stub that -# skips nothing -- a slower but correct walk. +# descends everywhere -- a slower but correct walk. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -107,7 +107,7 @@ canonical="$(realpath -e "${TARGET}" 2>/dev/null)" || exit 0 # Refuse the whole pass if the project root is a protected system directory. ai_tools_assert_safe_target "${canonical}" "setgid normalization" || exit 3 -# Resolve the operator that owns this project (operator.lib.sh); no owner -> do nothing. The +# Resolve the operator that owns this project (operator.lib.sh); no owner -> exit without acting. The # owner-guard below then acts only on dirs the resolved operator or the sandbox account hold. ai_tools_resolve_owner "${canonical}" || exit 0 readonly ALLOWLIST="${AI_TOOLS_RESOLVED_ALLOWLIST}" PROJECTS_UID @@ -164,10 +164,10 @@ _safe_setgid() { # Owner guard: only the projects user's or the sandbox account's own dirs are # eligible (re-verified TOCTOU-safe on the pinned inode below); skip anything else. # Return 3, not 1, so the walk can tell a third-party owner from a stat failure and - # report it: a walk that touches nothing must not be indistinguishable from one that - # had nothing to do. + # report it: a walk that touched no directory must not be indistinguishable from one + # with no directory to touch. [[ "${owner_uid}" == "${PROJECTS_UID}" || "${owner_uid}" == "${SANDBOX_UID}" ]] || return 3 - # Nothing to do when already group GROUP and already setgid -- unless the dir is owner-only, + # No work to do when already group GROUP and already setgid -- unless the dir is owner-only, # where that state is inherited residue the pinned-fd path below strips. if [[ "${grp}" == "${GROUP}" ]] && (( (0${mode} & 02000) != 0 )) \ && ! ai_tools_is_owner_only "${mode}"; then @@ -209,7 +209,7 @@ _safe_setgid() { [[ "${grp}" != "${GROUP}" ]] && { chgrp -- "${GROUP}" "/proc/self/fd/${fd}"; regrouped=1; } chmod -- g+s "/proc/self/fd/${fd}" exec {fd}<&- - # Record the actual change (the early return above logs nothing for a no-op dir). + # Record the change (the early return above stays silent for a no-op dir). if (( regrouped )); then ai_tools_log_info "normalized ${dir} (group ${grp} -> ${GROUP}, +setgid)" else @@ -244,7 +244,7 @@ find "${expr[@]}" 2>/dev/null \ elif (( rc == 3 )); then thirdparty=$(( thirdparty + 1 )) # The project ROOT is the case that decides whether the claim did anything at - # all: every directory below it inherits nothing, so the agent cannot enter the + # all: every directory below it inherits neither, so the agent cannot enter the # tree. Called out separately from the count for that reason. [[ "${d}" == "${canonical}" ]] && root_thirdparty=true fi @@ -254,7 +254,7 @@ find "${expr[@]}" 2>/dev/null \ ai_tools_log_info "left ${sealed} owner-only path(s) under ${canonical} out of the agent's reach" fi # Surfaced, never silent: the owner guard is the one skip that can leave a claim having - # granted NOTHING while every other step succeeds -- an operator-owned tree claimed for a + # granted NO ACCESS AT ALL while every other step succeeds -- an operator-owned tree claimed for a # different operator hits it on every directory. A count on stderr is what turns that from # an invisible no-op into something the claim can report. if (( thirdparty )); then diff --git a/src/usr/local/libexec/ai-tools/ai-tools-stop.sh b/src/usr/local/libexec/ai-tools/ai-tools-stop.sh index 7b58afbc..f33e0c63 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-stop.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-stop.sh @@ -7,7 +7,7 @@ # # THE PROPERTY THIS FILE EXISTS TO HOLD: a stop that is asked for and reported as done HAS # HAPPENED. The design that follows from it -- why sessions are found by CGROUP rather than by -# process tree, why it takes no target, where containment ends, and the residual failure +# process tree, why it does not take a target, where containment ends, and the residual failure # modes -- is documented once, in docs/session-stop.md. This header states only what a reader of # THIS FILE needs; each function below carries its own local mechanism. # @@ -26,8 +26,8 @@ # base system: `id`, `date`, `logger`, `timeout`, `systemctl` and `sudo` are each outside the # kill path or best-effort within it, and the two that can BLOCK -- the attribution calls into # the sandbox account's user manager -- run under `timeout`, since a stop that hangs is a stop -# that did not happen. NO project library is load-bearing here: this helper takes no input -# that decides WHICH sessions to stop, so there is nothing left for one to gate. +# that did not happen. NO project library is load-bearing here: this helper does not take any +# input deciding WHICH sessions to stop, so there is no input left for one to gate. # 2. THE CONFIRMATION DEFAULTS TO YES (messaging.rule.md requires NO). A pipe, a cron run, an # absent msg.lib.sh and a bare Enter all proceed; only a deliberate `n` declines. -n/--dry-run # is how this command is looked at without acting. @@ -65,14 +65,14 @@ # refuse_positional_argument. # # WHAT A SUCCESSFUL EXIT MEANS, stated exactly rather than generously. Exit 0 means: every session -# that existed at ENUMERATION was stopped, and a final re-enumeration found nothing still live. It +# that existed at ENUMERATION was stopped, and a final re-enumeration found no process still live. It # does NOT mean no session can exist afterwards -- the launch/stop window is a stated residual. It -# says nothing about the user manager, whose restoration is reported separately and never folded +# does not describe the user manager, whose restoration is reported separately and never folded # into this status. # -# Exit: 0 stopped and verified gone (or nothing was running) +# Exit: 0 stopped and verified gone (or no session was running) # 1 something survived SIGKILL -- the only outcome that is not a stop -# 2 usage (an unknown option, or a path -- this command takes no target) +# 2 usage (an unknown option, or a path -- this command does not take a target) # 4 declined at the confirmation (a deliberate `n`; never a degraded path) # 5 this helper could not run (no cgroup2 hierarchy, no sandbox uid) -- distinct from 1, # so a caller can tell a broken tool from a surviving process @@ -88,7 +88,7 @@ set -uo pipefail # A fixed PATH, set before anything is resolved. This helper runs as root and is reachable directly # as well as through the CLI, so it must not resolve `sleep`, `id` or `realpath` through a PATH an # invoker chose. sudoers `secure_path` normally covers the sudo route; this covers the direct one -# too, and costs nothing. +# too, at no cost. PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH @@ -96,7 +96,7 @@ export PATH # dot is skipped by default globbing. Every name inside the delegated subtree is the DELEGATEE's to # choose (see the delegation note above), so without `dotglob` a session could place itself in a # cgroup called `.hidden` and drop out of the enumeration -- including under --all, the form that -# must hold against a hostile session. `nullglob` makes a childless cgroup yield nothing rather than +# must hold against a hostile session. `nullglob` makes a childless cgroup expand to an empty list rather than # the unexpanded pattern. Set once, at file scope: every walk here depends on it. shopt -s dotglob nullglob @@ -116,7 +116,7 @@ STOP_EXIT_REACHED=false # ── Optional libraries, every one behind a fallback ────────────────────────────────────────── # Loaded for quality of output ONLY -- a logger and a message renderer. Neither gates anything, and # neither is allowed to prevent a stop. safe-paths.lib.sh and operator.lib.sh were loaded to vet and -# authorize a caller-supplied target; with no target to take, this helper has nothing for them to +# authorize a caller-supplied target; with no target to take, this helper has no input for them to # decide and does not load them at all. AI_TOOLS_LOG_TAG="ai-tools-stop" AI_TOOLS_LOG_FILE="stop.log" @@ -234,7 +234,7 @@ parse_command_line() { } # resolve_run_context -- establish who is asking and what account is being stopped, and arm the -# trail's traps. Everything here either succeeds or exits; nothing below it runs on a guess. +# trail's traps. Everything here either succeeds or exits; no code below it runs on a guess. resolve_run_context() { if [[ "$(id -u)" != "0" ]]; then say_error "ai-tools-stop must run as root: stopping a session means signalling ${SANDBOX_USER}'s cgroups" \ @@ -243,8 +243,8 @@ resolve_run_context() { fi # Who sudo says invoked this -- written by a root process, unreachable by the sandbox account. - # This is recorded for the TRAIL and authorizes nothing -- the command takes no authorization - # input, so a caller identity decides nothing about what is terminated. It is still cross-checked + # This is recorded for the TRAIL only: the command does not take an authorization + # input, so a caller identity has no bearing on what is terminated. It is still cross-checked # rather than taken at face value, because "who asked for this" is the line an operator reads # first after an incident and a wrong name there is worse than no name. # @@ -325,7 +325,7 @@ resolve_cgroup_layout() { # subtree. systemd places login session scopes (`session-N.scope`) as SIBLINGS of the manager # service, under the same per-user slice -- so a scan rooted at the manager alone has a blind # spot for anything not started by that manager. The sandbox account has no login shell and no - # password, so nothing should ever appear there; scanning the wider root costs one directory + # password, so no process should ever appear there; scanning the wider root costs one directory # level and removes the need for that to be true. # # It stays scoped to the ACCOUNT's slice rather than widening to uid alone, which is what keeps @@ -333,7 +333,7 @@ resolve_cgroup_layout() { # the INVOKING user's slice. readonly SANDBOX_SLICE="${CGROUP2_MOUNT}/user.slice/user-${SANDBOX_UID}.slice" # The manager unit, named exactly rather than by basename. It is descended into but never - # emitted as a session; nothing is exempt from the stop. See find_session_cgroups. + # emitted as a session; every cgroup in the slice is swept. See find_session_cgroups. readonly MANAGER_SERVICE="${SANDBOX_SLICE}/user@${SANDBOX_UID}.service" } @@ -361,8 +361,8 @@ find_session_cgroups() { # manager and ask for a unit by any name it likes, so an exemption expressed as a name # is one the monitored system can claim for itself. Expressed as an exact path it is # still a destination: moving tasks into a spared cgroup is the other DAC-only escape. - # Sparing nothing closes both at once, and costs no distro-specific knowledge -- no - # unit name appears in this file, so there is nothing here to differ between EL9, + # Sweeping every cgroup closes both at once, without distro-specific knowledge -- no + # unit name appears in this file, so there is no name here to differ between EL9, # EL10 and Fedora. # # `init.scope` is therefore enumerated and stopped like anything else, the account's @@ -444,19 +444,19 @@ cgroup_unit_name() { # ── Classification (advisory, exactly like attribution) ────────────────────────────────────── # The account's slice holds more than agent sessions: its own `systemd --user` and `init.scope`, a # dbus broker, and a login session scope for every `sudo -u` that crossed pam_systemd. All of them -# are terminated -- nothing is exempt -- but calling four such cgroups "4 agent sessions" in the +# are terminated -- no cgroup is exempt -- but calling four such cgroups "4 agent sessions" in the # table an operator confirms against, and in the line they read first after an incident, is untrue, # and untrue in the direction that inflates how much agent work was running. # # THIS CHANGES A LABEL AND A COUNT, NEVER A TARGET. It has the same standing as # unit_working_directory and carries the same caveat: a unit name inside the delegated subtree is -# the delegatee's to choose, so a session can name itself out of the agent class -- and gains -# nothing by it, because both classes are enumerated, listed and killed identically. Nothing here +# the delegatee's to choose, so a session can name itself out of the agent class, and does +# not gain an exemption by it, because both classes are enumerated, listed and killed identically. Nothing here # is consulted to decide what a stop reaches; that remains cgroup-slice membership alone. # session_is_agent <unit-name> -- succeed for a unit ai-tools-run started. It names every session # `<SANDBOX_USER>-<agent>-<pid>.service`, so this matches THIS PROJECT's own prefix rather than any -# distro's unit names -- the file still contains no name that differs between EL9, EL10 and Fedora. +# distro's unit names -- the file still does not hold a unit name that differs between EL9, EL10 and Fedora. session_is_agent() { [[ "$1" == "${SANDBOX_USER}-"*.service ]] } @@ -559,7 +559,7 @@ cgroup_is_live() { # # Failing to read it means the process is GONE: this runs as root, the pid came from a cgroup being # torn down, and root's only reason to fail on /proc/<pid>/stat is that the entry no longer exists. -# So a read failure skips the pid, which is correct rather than fail-open -- there is nothing left +# So a read failure skips the pid, which is correct rather than fail-open -- there is no process left # to signal. The readability test comes first purely to keep the common vanished-pid case off # stderr; the read is still checked, because the pid can exit between the two. pid_start_time() { @@ -576,7 +576,7 @@ pid_start_time() { # signal_pids_validated <signal> <pid>... -- signal each pid only if its start time still matches # what it had when it was collected, so a pid recycled in between is skipped rather than signalled -# blind. `kill` is a bash builtin, so this needs no external binary. A pid that has already exited +# blind. `kill` is a bash builtin, so this runs without an external binary. A pid that has already exited # is not an error -- that is the outcome being aimed at. signal_pids_validated() { local signal="$1"; shift @@ -630,7 +630,7 @@ kill_outright() { while (( waited < REAP_SECONDS )); do cgroup_is_live "${cgroup_directory}" || return 0 # Re-asserted every pass, not written once before the loop. The write is idempotent and - # costs nothing, and writing it once would make the whole guarantee rest on a single + # costs one write, and doing it once would make the whole guarantee rest on a single # syscall whose failure is invisible. Re-asserting also covers a nested cgroup created # between passes, which the one-shot form would leave to the pid fallback alone. # @@ -677,15 +677,15 @@ end_session() { # manager cannot be reached. The machine transport is preferred: this runs as root, where the # system bus already authorizes it, whereas `sudo -u` needs that account's own bus to accept the # connection, which a host can refuse while the manager is healthy. A failure here costs -# attribution and nothing else. +# attribution alone. # # BOUNDED IN TIME, because "the user manager is wedged" is not a hypothetical here -- it is one of # the states an operator reaches for this command IN. A d-bus call to a hung manager blocks # indefinitely, and a stop that hangs while attributing sessions is a stop that did not happen, # which is the one outcome this file exists to prevent. Both calls therefore run under a short # `timeout`, and every way that can fail -- the manager not answering, `timeout` itself absent -- -# yields no attribution, which refuses the SCOPED form and sends the operator to --all. --all needs -# no attribution at all, so the undeclinable form cannot be delayed by this at all. +# leaves attribution empty, which refuses the SCOPED form and sends the operator to --all. --all does +# not need attribution at all, so the undeclinable form cannot be delayed by this at all. unit_working_directory() { local raw raw="$(timeout 5 systemctl --user -M "${SANDBOX_USER}@.host" show --property=WorkingDirectory "$1" 2>/dev/null)" @@ -699,8 +699,8 @@ unit_working_directory() { # unit-file spelling of the same flag and is stripped too, so neither rendering reaches the # comparison below. (Observed: dbus-broker.service reports `WorkingDirectory=!/home/<user>`.) if [[ "${raw}" == '!'* || "${raw}" == '-'* ]]; then raw="${raw:1}"; fi - # ONLY AN ABSOLUTE PATH IS A RESULT; anything else yields nothing and the session reads as - # `unknown`. Attribution decides nothing here, so this is not a gate -- it is what keeps a value + # ONLY AN ABSOLUTE PATH IS A RESULT; anything else yields an empty value and the session reads as + # `unknown`. Attribution does not decide any part of the sweep here, so this is not a gate -- it is what keeps a value # this helper cannot interpret from being printed as though it could be used. The concrete case # is print_reclaim_guidance, which turns each attributed directory into a command the operator # is invited to run: an unstripped marker emitted `ai-tools --reclaim !/opt/ai-tools`, which is @@ -708,7 +708,7 @@ unit_working_directory() { # # The shape is ALLOWLISTED rather than the markers enumerated, so a rendering systemd adds later # degrades to `unknown` instead of reaching the operator as a broken command. `~` - # (WorkingDirectory=~, the account's home) carries no path and is correctly refused here. + # (WorkingDirectory=~, the account's home) does not carry a path and is correctly refused here. [[ "${raw}" == /* ]] || raw="" printf '%s' "${raw}" } @@ -716,10 +716,10 @@ unit_working_directory() { # ── Restoring the user manager ─────────────────────────────────────────────────────────────── # restore_user_manager -- put `user@<uid>.service` back after a stop that necessarily took it down. # -# WHY IT HAS TO EXIST. The enumeration spares nothing, the account's own `systemd --user` included +# WHY IT HAS TO EXIST. The enumeration covers every cgroup, the account's own `systemd --user` included # (see find_session_cgroups): an exemption is a destination a session can move into, and on a # DAC-only host it can also ask that manager for a unit outside any subtree we chose to sweep. -# Sparing nothing closes both. The price is that the manager is gone afterwards -- and SIGKILL +# Covering every cgroup closes both. The price is that the manager is gone afterwards -- and SIGKILL # leaves `user@<uid>.service` FAILED rather than restarting it, so the next launch would find no # --user instance. This pays that price back instead of buying it with an exemption. # @@ -812,7 +812,7 @@ confirm_stop() { # Agent sessions are listed FIRST, ahead of the account's plumbing, because the table is read under # time pressure and the rows that answer "what was running" must not be interleaved with rows that # are always there. Order is presentation only; every selected cgroup is ended by the loop in main() -# in its own order, and nothing is dropped from either pass. +# in its own order, and no cgroup is dropped from either pass. print_session_table() { local verb="$1" index note printf ' %-11s %-40s %6s %s\n' "" "SESSION" "PROCS" "PROJECT" @@ -841,7 +841,7 @@ print_session_table() { # and its WorkingDirectory is routinely a path `ai-tools --reclaim` would refuse outright: the # account's dbus broker reports `/opt/ai-tools`, the control plane, which the safe-paths backstop # protects. Emitting it produced a remedy that cannot run, offered to an operator mid-incident with -# nothing to distinguish it from the one that can. +# no marker to distinguish it from the one that can. print_reclaim_guidance() { local directory local -A seen=() @@ -864,7 +864,7 @@ print_reclaim_guidance() { main() { # There is one scope and no way to ask for another, so it is a constant rather than a decision: # every live cgroup in the account's slice. Kept as a named value because it is what the - # headline, the trail and the nothing-running message all read. + # headline, the trail and the no-sessions message all read. local scope="every agent session" # Recorded before anything is selected, so a run interrupted part-way still left a record of @@ -882,7 +882,7 @@ main() { while read -r cgroup_directory; do [[ -n "${cgroup_directory}" ]] || continue # Counted in the shell rather than through `wc -l`: the pid list is already being read, so - # a pipe to an external command buys nothing and puts one more binary on the path. + # a pipe to an external command does not buy accuracy and puts one more binary on the path. mapfile -t session_pids < <(cgroup_pids "${cgroup_directory}") pid_count=${#session_pids[@]} (( pid_count )) || continue @@ -890,7 +890,7 @@ main() { working_directory="" [[ -n "${unit}" ]] && working_directory="$(unit_working_directory "${unit}")" # NOTHING IS FILTERED. Attribution is read for the table and the reclaim guidance only -- - # it selects nothing, so a session whose working directory cannot be read is stopped + # it does not select a target, so a session whose working directory cannot be read is stopped # exactly like one whose can, and simply shows as `unknown`. That is what makes the # attribution safe to take from the account being stopped: a unit that misreports its # project misleads a reader, and cannot buy itself survival. @@ -1011,8 +1011,8 @@ main() { # shared with ai-tools-run -- but it converts a silent miss into a reported one, which is the # honest bound. See the exit contract at the top of this file for what a success means. # - # The assertion is now the simple one, because the sweep spares nothing: the account's slice - # holds no live cgroup at all. + # The assertion is the simple one, because the sweep covers every cgroup: the account's slice + # does not hold any live cgroup at all. local -a remaining=() while read -r cgroup_directory; do [[ -n "${cgroup_directory}" ]] && cgroup_is_live "${cgroup_directory}" \ @@ -1021,8 +1021,8 @@ main() { # The sweep's verdict is SET-BASED, not a count comparison. `survivors` counts SESSIONS that # reported alive; `remaining` counts LIVE CGROUPS afterwards. Those are different units -- one # session cgroup holds many tasks, and a cgroup that appeared after the loop was never in - # `survivors` at all -- so comparing the two numbers detects nothing reliably: it misses a new - # cgroup whenever `survivors` was already non-zero, and can fire when nothing is wrong. + # `survivors` at all -- so comparing the two numbers does not detect any case reliably: it misses a new + # cgroup whenever `survivors` was already non-zero, and can fire on a healthy host. # # So: ANY live cgroup after a run is a failure, full stop. And separately, a live cgroup that is # not one the loop already reported alive is the interesting case -- something appeared in, or @@ -1083,12 +1083,12 @@ main() { } # ── Entry point ────────────────────────────────────────────────────────────────────────────── -# SOURCING THIS FILE IS INERT: it defines the functions and returns here, having parsed nothing, -# resolved nothing about the host, armed no trap and signalled nothing. That is what lets the unit +# SOURCING THIS FILE IS INERT: it defines the functions and returns here, having parsed no +# argument, resolved no host state, armed no trap and signalled no process. That is what lets the unit # suite drive the enumeration and liveness predicates -- the two things a reading review has # repeatedly failed to get right -- against a FIXTURE cgroup tree, on any host, with no session # running and no privilege. It is not a mode and not a hook: there is no environment variable to -# set, no branch inside any function, and nothing an invoker can reach that changes what a real run +# set, no branch inside any function, and no input an invoker can reach that changes what a real run # does. Running the file is unchanged. if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then return 0 diff --git a/src/usr/local/libexec/ai-tools/ai-tools-unclaim.sh b/src/usr/local/libexec/ai-tools/ai-tools-unclaim.sh index 6247d108..3cd809a5 100755 --- a/src/usr/local/libexec/ai-tools/ai-tools-unclaim.sh +++ b/src/usr/local/libexec/ai-tools/ai-tools-unclaim.sh @@ -33,12 +33,12 @@ # default the whole tree is authorized by its allowlist entry, and every eligible path # in it is reverted. # --unlisted the tree is in NO allowlist (a claimed project copied or moved elsewhere and -# never unclaimed), so it carries no authorization of its own. The membership +# never unclaimed), so it does not carry authorization of its own. The membership # check is replaced by a per-path residue gate (_is_residue): a path is touched # only while it still bears the ai-tools fingerprint -- owned by the sandbox # account, grouped to it, or carrying its named ACL entry. A path that was # never part of a claim is left byte-for-byte as it is, so running this on the -# wrong directory changes nothing. This mode additionally hands sandbox-OWNED +# wrong directory leaves it exactly as it was. This mode additionally hands sandbox-OWNED # inodes back to the invoking operator (ai-tools-reclaim, which normally does # that, refuses an unlisted path) and resets a leftover ai_tools_project_t # label. --full extends the walk into the skip-listed heavy trees, where @@ -56,7 +56,7 @@ # This is the one refusal here that leaves MORE access than acting would: the inode keeps its # group, so after the project is deregistered the agent still holds those files through it. That # is accepted rather than resolved, because the alternative is worse -- the second name is outside -# the tree and this pass authorizes nothing out there, and for the common case (`git clone +# the tree and this pass does not authorize a change out there, and for the common case (`git clone # --local`, which hardlinks .git/objects to the source repo) acting would silently rewrite the # ORIGIN's objects. What the guard owes the operator instead is disclosure: refusals are counted, # reported to the terminal with what they leave behind, and handed the `find -links +1` that lists @@ -69,16 +69,16 @@ # to git history. # # NOT a round trip. The reversal normalizes; it does not restore. `setfacl -b` clears every -# extended ACL -- including entries that predate the claim and have nothing to do with the +# extended ACL -- including entries that predate the claim and are unrelated to the # agent -- and group write comes off, so a path the claim opened lands on 640 (750 when the # owner has execute) under the target group. What it does NOT do is put back the world bits: # the claim's ACL walk set other::--- on every path it touched, so 644 and 664 both arrive # here as 660 and leave as 640. An owner-only path (0600/0700) is the exception at both ends -# -- the claim skips it as out of the agent's reach, so there is nothing to reverse and it -# passes through unchanged. No prior state is recorded anywhere, so nothing can restore it; +# -- the claim skips it as out of the agent's reach, so there is no change to reverse and it +# passes through unchanged. No prior state is recorded anywhere, so no pass can restore it; # the CLI says so before it asks and tells the operator to back up first. # -# Idempotent: re-running on an already-unclaimed tree clears nothing new, regroups to the +# Idempotent: re-running on an already-unclaimed tree finds no ACL left to clear, regroups to the # same group, and removes an already-absent write bit -- all no-ops. # # Deploy: @@ -137,7 +137,7 @@ if ! source "${LOG_LIB}" 2>/dev/null; then fi # Directory-skip selector (shared single source of truth). A missing lib leaves a stub that -# skips nothing -- a slower but correct walk. +# descends into every directory -- a slower but correct walk. readonly SKIP_DIRS_LIB="/usr/local/lib/ai-tools/skip-dirs.lib.sh" # shellcheck source=SCRIPTDIR/../../lib/ai-tools/skip-dirs.lib.sh source "${SKIP_DIRS_LIB}" 2>/dev/null \ @@ -202,7 +202,7 @@ if ${UNLISTED}; then [[ "${caller}" == "${AI_TOOLS_OPERATORS[0]}" ]] && is_primary=primary ALLOWLIST="$(_ai_tools_operator_allowlist "${caller}" "${is_primary}")" else - # Resolve the operator that owns this project (operator.lib.sh); no owner -> do nothing. The guard + # Resolve the operator that owns this project (operator.lib.sh); no owner -> exit without acting. The guard # below then acts only on paths the resolved operator or the sandbox account hold. ai_tools_resolve_owner "${canonical}" || exit 0 ALLOWLIST="${AI_TOOLS_RESOLVED_ALLOWLIST}" @@ -269,7 +269,7 @@ fi # the three is what makes the gate safe to run outside the allowlist: a partially handed-back tree # can retain any one of them alone, and testing only the group would leave an ai-tools-OWNED file # untouched, where the agent keeps access through the user bits. Read from the pinned fd, so it -# describes the same inode the mutation acts on. Only the uid/gid arms cost nothing; the ACL read +# describes the same inode the mutation acts on. Only the uid/gid arms are free; the ACL read # runs only when both miss. _is_residue() { local fd="$1" uid="$2" gid="$3" @@ -309,7 +309,7 @@ _safe_unclaim() { ;; *) exec {fd}<&-; return 1 ;; # never touch symlinks/fifos/devices esac - # Residue gate (--unlisted only): outside the allowlist the tree carries no authorization + # Residue gate (--unlisted only): outside the allowlist the tree does not carry authorization # of its own, so a path is touched ONLY while it still bears the ai-tools fingerprint. A # path that never belonged to a claim is left byte-for-byte as it is. if ${UNLISTED} && ! _is_residue "${fd}" "${got_uid}" "${got_gid}"; then @@ -356,7 +356,7 @@ _safe_unclaim() { # Walk the project's directories and files (one filesystem; heavy trees skipped unless --full). # A '!'-excluded or secret-named directory has its whole subtree skipped; an excluded or # secret regular file is skipped on its own. find runs WITHOUT -L, so a symlink is listed but -# never descended: a symlink loop inside the tree is unreachable by construction and needs no +# never descended: a symlink loop inside the tree is unreachable by construction and needs neither # cycle detection, and -xdev keeps the walk off other filesystems and bind mounts. if ${FULL}; then # --full: the skip list is a walk-cost optimization, and residue hidden in a skipped tree @@ -391,7 +391,7 @@ find "${expr[@]}" 2>/dev/null \ # operator asked to change that keeps the group it has -- so after the project is # deregistered those inodes still carry the agent's group, which is the one thing an # unclaim is for. The refusal is still right (the inode is reachable from outside the tree, - # and this pass authorizes nothing out there), so what the operator needs is to be told + # and this pass does not authorize a change out there), so what the operator needs is to be told # plainly and handed the command that lists them, not a silent difference between counts. if (( hardlinked )); then ai_tools_log_warn "left ${hardlinked} hardlinked file(s) under ${canonical} untouched -- they keep group @SANDBOX_GROUP@" @@ -440,7 +440,7 @@ fi # SELinux label reset (--unlisted only): a tree that was MOVED rather than copied keeps the # ai_tools_project_t label it was claimed with. No fcontext rule names the new path, so -# ai-tools-relabel --remove has nothing to remove; a forced restorecon resets the tree to the +# ai-tools-relabel --remove has no rule to remove; a forced restorecon resets the tree to the # default its location resolves to. Gated on the root actually carrying the label, so a tree # that never had it is not relabelled as a side effect of unclaiming. Best-effort: a label # left behind is a defence-in-depth gap, not an access grant -- the DAC reversal above has diff --git a/src/usr/local/share/man/man1/ai-tools.1 b/src/usr/local/share/man/man1/ai-tools.1 index f79ec7bf..31b1fdf4 100644 --- a/src/usr/local/share/man/man1/ai-tools.1 +++ b/src/usr/local/share/man/man1/ai-tools.1 @@ -66,7 +66,7 @@ in .BR operator.conf (5). A general .BR sudo (8) -grant is neither of them \(em the host's own sudoers decides it, and nothing in +grant is neither of them \(em the host's own sudoers decides it, and no file in this package writes or records it. .PP An operator holding only the two launches agent sessions and reads the reports. @@ -115,9 +115,9 @@ exist: only the final component is created, so a mistyped path is refused rather than manufactured. Every filesystem step runs as the operator the run acts for, so a create under .B \-\-for -produces a tree that operator owns. A refusal leaves nothing behind. +produces a tree that operator owns. A refusal removes the directory it made. .IP -It takes no options and asks no confirmation of its own: the tree it makes is empty, +It does not take options, and asks for no confirmation of its own: the tree it makes is empty, so the claim that follows infers the answers its prompts would otherwise ask for \(em there are no pre-existing permissions to warn about, no secret-named files to scan a brand-new directory for, and no git history to expose in a repository with no commits. @@ -184,7 +184,7 @@ to release one and keep the files. The authorization is an .I exact .B allowed\-projects -entry and nothing else: there is no +entry alone: there is no .BR \-\-force , an ancestor or a path inside a project is refused, and so is a project that contains another claimed project \(em deleting it would take that one with it and @@ -195,7 +195,7 @@ registries before deleting, so a failed deletion leaves an unregistered tree rat than a half-deleted one. .IP It confirms twice \(em a default-NO prompt, then a typed-name challenge \(em and -neither is answered by a run with no terminal, so nothing is deleted unattended +neither is answered by a run with no terminal, so a delete needs an operator at the prompt without .BR \-y , which additionally requires an explicit @@ -214,7 +214,7 @@ agree and a park-and-restore round trip leaves the file as it was. It is a registry change and .I only a registry change: the tree keeps its group, its ACLs, its setgid bits and its -SELinux label, so nothing has to be re-applied when it comes back. What stops +SELinux label, so no step has to be re-applied when it comes back. What stops along with the launches is every root helper that resolves a path's owner through the allowlist \(em so while a project is disabled the ownership handback does .I not @@ -235,7 +235,7 @@ A project inside another claimed project is also refused. The .B ! line it would write is indistinguishable from an exclusion withholding a subtree from the project -above it, and nothing in the file could tell the two apart afterwards \(em so re-enabling it later +above it, and no field in the file could tell the two apart afterwards \(em so re-enabling it later could only be a guess, on an edit that widens what the agent reaches. The refusal names the two ways to get the intended effect: unclaim the nested project, or park the one above it. Editing the line by hand is unaffected; the tool simply will not write that one for you. @@ -245,8 +245,8 @@ Restore a project disabled earlier (default: the current directory): the .B ! is deleted from its line, in place. Sessions may start there again. .IP -It grants nothing that was not already granted \(em the tree's permissions never -changed \(em so it runs no secret scan and asks nothing. A path the allowlist does +It restores the entry alone \(em the tree's permissions never +changed \(em so it does not run a secret scan and does not prompt. A path the allowlist does not name is refused and points at .BR \-\-project\-claim : claiming scans for secrets before granting an agent access to a tree, which is a @@ -301,7 +301,7 @@ never rewritten. Default: .IR sandbox/ <leaf>, where <leaf> is the last component of .BR \-\-from . -The default carries no host, machine, or operator identity. +The default omits host, machine, and operator identity. .TP .B \-\-dir \fIname\fR Directory name for the clone under the sandbox area (default: the repository's base name). @@ -401,7 +401,7 @@ fails the command says so and names the fix, and the next launch needs it before start a session. Success means every session that existed at enumeration was terminated and verified gone from its .BR cgroup ; -it does not mean none can start afterwards, and it says nothing about the manager. +it does not mean none can start afterwards, and it does not describe the manager. .IP The report counts agent sessions apart from the account's own plumbing \(em its user manager, its dbus broker, and a login session scope per @@ -409,9 +409,9 @@ dbus broker, and a login session scope per that crossed pam_systemd. All are terminated; the plumbing is listed after the sessions and marked .IR "(account plumbing)" , so the headline does not report four units of scaffolding as four agent sessions. The split is -presentational \(em it labels and orders rows and decides which get a reclaim command, and selects -nothing. Because the restarted manager is itself inside the swept slice, running the command twice -in a row is not silent: the second run reports no agent session, then terminates and restarts the +presentational \(em it labels and orders rows and decides which get a reclaim command, without +selecting any target. Because the restarted manager is itself inside the swept slice, running the command twice +in a row is not silent: the second run reports zero agent sessions, then terminates and restarts the manager again. It is idempotent in end state, not in what it prints. .IP Exit status carries the outcome: @@ -473,7 +473,7 @@ group that its build needs is not loaded. .TP .B \-\-audit \fR[\fB\-\-since\fI when\fR] Report what has refused, been rejected, been stranded, or been flagged since a given -time \(em the detections the sandbox already records but that nothing previously read +time \(em the detections the sandbox already records but that no command reads back. Needs .BR sudo : the trail it reads is @@ -495,7 +495,7 @@ toolchain auto-update timer and update service \(em as OK, SKIPPED, DOWN, FAILED STALE, or not-installed \(em then, per agent whose package declares one, whether its entrypoint carries a verified checksum. Read-only. Exits non-zero when anything is reported broken, so it can be run unattended from a monitor or a cron job. SKIPPED is not broken: it -reports a run that correctly did nothing, such as an update that could not reach the +reports a run that correctly declined to act, such as an update that could not reach the package registry, and turns STALE if the condition persists. .TP .B \-\-list @@ -535,7 +535,7 @@ For report what would be locked, and what would be stripped from the paths you have already sealed by mode, without changing anything. For .BR "\-\-project\-unclaim \-\-force" : -list every path that would change, with ownership and mode, and change nothing. +list every path that would change, with ownership and mode, and apply none of them. A registered project's unclaim previews itself, so this pairs with .B \-\-force only. @@ -575,7 +575,7 @@ act on a tree the allowlist does cover \(em a claimed project copied or moved elsewhere and never unclaimed. It replaces the allowlist gate with a per-path one: a path is touched only while it still carries ai-tools ownership, group, or an ai-tools ACL entry, so running it -on a directory that was never claimed changes nothing. It does +on a directory that was never claimed leaves it untouched. It does .B not relax the protected-paths backstop, the owner guard, the hardlink guard, or the secret and exclusion skips, and it is refused on a registered project. @@ -633,13 +633,13 @@ Accepted on .BR \-\-project\-remove ", " \-\-lockdown ", " \-\-reclaim ", and " \-\-list ; refused elsewhere rather than ignored. It cannot be combined with .BR "\-\-project\-unclaim \-\-force" , -whose tree carries no allowlist entry naming an owner and is therefore bound to +whose tree has no allowlist entry naming an owner and is therefore bound to the invoking operator. .SH EXIT STATUS .TP .B 0 The command completed. -A dry run that printed its plan, and an idempotent verb that found nothing left +A dry run that printed its plan, and an idempotent verb that found no work left to do, both land here. .TP .B 1 @@ -667,7 +667,7 @@ every elevated helper refuses it, whatever the allowlist says. .B 5 .B \-\-stop could not run at all: it was not invoked as root, the sandbox account has no -uid, or the host exposes no cgroup v2 hierarchy. +uid, or the host has no cgroup v2 hierarchy. No session was signalled. .TP .B 130 diff --git a/src/usr/local/share/man/man5/operator.conf.5 b/src/usr/local/share/man/man5/operator.conf.5 index 5d02ca81..86be22d8 100644 --- a/src/usr/local/share/man/man5/operator.conf.5 +++ b/src/usr/local/share/man/man5/operator.conf.5 @@ -14,11 +14,11 @@ directories the maintenance walks skip. .PP Every option is optional, and the shipped file sets only .BR OPERATORS . -An absent key keeps its default, so a fresh install needs no edits and a file +An absent key keeps its default, so a fresh install runs unedited and a file written for an earlier version keeps working unchanged when a new option appears. Anything the file does not say falls back to that default \(em the fail-closed direction \(em so a session receives exactly what is enabled here -and nothing more. +and no other key. .PP Edit it as root; it is owned by .B root @@ -148,7 +148,7 @@ must resolve under be root-owned and not group/other-writable, and hold readable text \(em that directory is the one location the confined session is granted read on. Its .B full contents are sent to the model verbatim -(nothing is stripped), so it must contain only prompt text. A +(no markup is stripped), so it must contain only prompt text. A configured prompt that cannot be applied .B refuses the launch rather than silently reverting to the default, so a broken value fails @@ -205,7 +205,7 @@ The token belongs in that file, which ships (not world-readable), never in this file. Each uncommented option is validated at launch and a configured-but-invalid one .B refuses -the launch (fail closed); a localhost endpoint needs no token. These become +the launch (fail closed); a localhost endpoint does not need a token. These become process environment variables, over which a Claude Code settings .B env block \(em authoritatively diff --git a/src/usr/local/share/man/man8/ai-tools-admin.8 b/src/usr/local/share/man/man8/ai-tools-admin.8 index e94d1702..dcaf1536 100644 --- a/src/usr/local/share/man/man8/ai-tools-admin.8 +++ b/src/usr/local/share/man/man8/ai-tools-admin.8 @@ -129,7 +129,7 @@ restores the projects it had. 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. +Reports that there is no policy to manage when SELinux is disabled. .TP .B selinux groups enable \fIname\fR Load the prebuilt module for @@ -165,7 +165,7 @@ 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 +Both options read no host state and leave the host as it is, so both answer any caller rather than only root, and each ignores any further argument. .TP .BR \-\-help ", " \-h @@ -177,8 +177,8 @@ Print the installed version and exit. .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. +An idempotent command that found no work left lands here, as does a +command with no policy to manage because SELinux is disabled. .TP .B 1 The operation failed. diff --git a/src/usr/share/ai-tools/skills/README.md b/src/usr/share/ai-tools/skills/README.md index 227c61aa..36cd434a 100644 --- a/src/usr/share/ai-tools/skills/README.md +++ b/src/usr/share/ai-tools/skills/README.md @@ -51,7 +51,7 @@ root. A real directory always wins: the linker never displaces one, so a name th exactly as you left it. This is also how an agent-specific *override* of a shared skill works — same name, real directory, no link. (The one thing that is converted to a link is an `x-ai-tools-managed` copy that is byte-identical to the shared skill: that is the project's own -copy from the older per-agent layout, so nothing is lost. An edited one is kept.) +copy from the older per-agent layout, so no content is lost. An edited one is kept.) ## Versioning: stable name, RFC-draft frontmatter @@ -60,7 +60,8 @@ above, in the RFC-draft form every shipped asset shares — `x-ai-tools-managed` marker (this one is maintained by the project), `x-ai-tools-status` the lifecycle stage (`draft` while it is still being refined), and `x-ai-tools-version` a monotonic integer. -Bump `x-ai-tools-version` and set `x-ai-tools-updated` whenever you change a shipped skill. On the +A shipped skill takes one version bump per release in which it changed, along with a new +`x-ai-tools-updated`. On the next install or bootstrap a newer version is **offered** as an update (default: keep, so Enter leaves your copy as it is) and an unchanged one is a quiet no-op. One version is installed at a time, so the stable name always resolves to the current text and cross-references between skills diff --git a/src/usr/share/ai-tools/skills/ai-tools-capable-systems-governance/references/framework.md b/src/usr/share/ai-tools/skills/ai-tools-capable-systems-governance/references/framework.md index 9658e409..29361ad0 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-capable-systems-governance/references/framework.md +++ b/src/usr/share/ai-tools/skills/ai-tools-capable-systems-governance/references/framework.md @@ -113,7 +113,7 @@ acquire additional infrastructure, compute, or accounts. Any persistence, replication, migration, or resource acquisition MUST carry verifiable authorization from the control plane. The verifying code and the verifying key MUST be outside the system's write and read boundary respectively; verification performed by the system, or against a key it can reach, -provides no assurance. +does not provide assurance. **Enforced form:** ephemeral compute, and no write access to its own deployment, scheduler, artifacts, prompts, or tool definitions. diff --git a/src/usr/share/ai-tools/skills/ai-tools-engineering-principles/SKILL.md b/src/usr/share/ai-tools/skills/ai-tools-engineering-principles/SKILL.md index a3be7bbb..9615e9fe 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-engineering-principles/SKILL.md +++ b/src/usr/share/ai-tools/skills/ai-tools-engineering-principles/SKILL.md @@ -24,7 +24,7 @@ required. The "One-line test" and the anti-patterns below double as the review p When concerns pull against each other, resolve them in this order: -1. **Correctness** — it has to do the right thing; a fast, elegant wrong answer is worth nothing. +1. **Correctness** — it has to do the right thing; a fast, elegant wrong answer is worthless. 2. **Security** — get the boundary right before the speed. An input that reaches a log, a shell, a query, or the filesystem is untrusted until proven otherwise. 3. **Performance** — then make it fast: avoid needless work, allocations, and chatty round-trips; @@ -77,7 +77,7 @@ understands in one pass — the code is the best documentation. correctness gate.) - **Sanitize with a fail-closed allowlist, not a blocklist.** Permit a known-safe subset and reject everything else by construction. A blocklist is open-ended and never provably complete; an - allowlist needs no maintenance to stay safe. Prefer the simple, foolproof rule over exhaustive + allowlist does not need maintenance to stay safe. Prefer the simple, foolproof rule over exhaustive enumeration. - **Lightest mechanism that works.** Resolve an inconsistency at write-time against ground truth (the code) rather than building tooling to police it later; reach for a lint/CI gate only for a @@ -101,7 +101,7 @@ understands in one pass — the code is the best documentation. - **Scope a change to what it requires.** Touch only what the change needs — reconcile the doc passages it actually invalidates, don't ride unsolicited cross-cutting refactors or new doc sections along with a fix. Raise a broader idea separately. -- **Trace, don't guess.** When something "does nothing" with no error, observe the running behaviour +- **Trace, don't guess.** When something silently fails to act, observe the running behaviour (a trace, an exit code, a log) before theorising — silent no-ops (a swallowed error, a mis-set flag) don't reveal themselves by inspection. diff --git a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md index dccf6b7c..6065f0ab 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md +++ b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/SKILL.md @@ -3,7 +3,7 @@ name: ai-tools-technical-docs # ai-tools managed asset — provenance/versioning (RFC-draft lifecycle); the name above is stable. x-ai-tools-managed: true x-ai-tools-status: draft -x-ai-tools-version: 3 +x-ai-tools-version: 2 x-ai-tools-updated: 2026-09-04 description: > Technical writing standard for every software engineering artifact. Use when writing or @@ -123,6 +123,18 @@ State what the reader can rely on. Prefer "X is available when ⟨condition⟩" ⟨condition⟩" where both state the same fact. Describe what a component does rather than what it does not do. +**A rewrite changes the wording, not the claim.** Where a sentence states a security boundary — +what a mode permits, what a file may hold, who may act — carry that boundary through the edit +unchanged: keep the same subject, the same sets, and the same modality (`must`, `only`, `never`). +Where the new wording cannot hold the claim, keep the sentence as it stands. + +Swapping the set is how it goes wrong. `carries no secret` and `contains only settings` are different +propositions: the first says the contents and the secrets do not intersect, the second says the +contents are settings — and a setting can be a token, so the second does not imply the first. Only +the first justifies the `644` mode it was written to justify. Turning a negation positive is sound +over a set provably disjoint from the one the negation excluded, and nowhere else; where that does +not hold, keep the negation and write it with `does not`. + Keep this structural: no praise, no intensifiers, no tone words, and never overstate a guarantee. No single sentence looks upbeat; across a corpus the effect accumulates, and the documentation reads as capable and dependable. @@ -130,14 +142,16 @@ documentation reads as capable and dependable. **Write a negation with `does not`.** Fronting the quantifier instead — `writes no entry`, `takes no argument` — attaches the negative to the object instead of the verb. It reads formal to archaic, and it is the determiner statutes are built from (*no person shall*, *no warranty is -given*). It is also the shorter form, and clarity outranks brevity: the razor takes the fewest -words that stay clear. +given*). The fronted form is the shorter one, and the longer one wins anyway: the razor takes the +fewest words that stay clear. -- In style: `does not write any entries`, `does not take any path arguments` -- Off style: `writes no entry`, `takes no path argument` +- In style: `does not write any entries`, `does not take a path argument` +- Off style: `writes no entries`, `takes no path argument` -Pluralize an indefinite object under `any`. A single instance takes its article — `does not -write an entry` — and so does a definite one: `does not increment the counter`. +**The object's number follows the code, not a preference.** `does not take any path arguments` +and `does not take a path argument` are different claims about arity — a variadic parameter +against a single one — so the signature decides which is true. A definite object keeps its +article: `does not increment the counter`. The same applies to `nothing` as a subject or object, which the checklist already catches: name the absent input instead. @@ -272,7 +286,7 @@ point of the prose is to name that constraint. Judge each file on its own. A header at a good altitude stays as it is, and a change that merely touches a file edits only the passages it invalidates. On a header that has grown past its -purpose, expand it first to surface what actually matters, then reduce to purpose and the +purpose, expand it first to surface what matters, then reduce to purpose and the load-bearing why. ### Self-contained @@ -280,14 +294,21 @@ load-bearing why. Prose is read without the conversation that produced it. Name the concrete mechanism; leave out session shorthand, internal labels, ticket tags, and "as discussed" back-references. -### Resolve conflicts against the code, while writing +### Resolve a doc/code conflict while writing, in the right direction + +Where a doc and the code disagree, resolve it then — do not default to either side, and do not +commit a known inconsistency. Which side moves depends on what the prose is doing: -Where a doc and the code disagree, resolve it then, against the code — do not default to -either side, and do not commit a known inconsistency. Ask when the correct behaviour is -genuinely unclear. +- **A description of behaviour** — a file header, a doc comment, most rule prose. The code decides + what it says, and the stale side is not reliably the prose. +- **An invariant** — a `CLAUDE.md` guarantee, a stated MUST, a security property. The prose stands + and the code is the defect: raise it. Rewriting the invariant to match retires a guarantee by + editing prose. +- **A migration in progress** — the prose leads and the code follows: describe the target state as + current, and record the dependency where that forces a mention of something not yet built. The + gap is expected, so it is recorded rather than resolved away in either direction. -While a migration is in progress, describe the target state as current. Where that forces a -mention of something not yet built, record the dependency and keep writing to the target. +Ask when which of the three applies is genuinely unclear, rather than committing a guess. ## The three axes @@ -393,15 +414,9 @@ advisory. `*.rule.md` holds the principles common to its domain plus the cross-file story. A file header holds that file's local mechanism. -**The code is true for behaviour, and invariants have to hold.** Code, header, and rule describe -one system at three altitudes, each in the present tense, and touching any of them obligates -reconciling the others at the time of writing. - -Where a description disagrees with the code, the code decides what the description says — the -stale side is not reliably the prose. Where the **code** contradicts an invariant a `CLAUDE.md` -or a rule states, that is a defect in the code: raise it, and leave the invariant standing. -Rewriting the invariant to match would retire a guarantee by editing prose. Ask when the correct -behaviour is genuinely unclear, rather than committing a guess. +**Code, header, and rule describe one system at three altitudes**, each in the present tense, so +touching any of them obligates reconciling the others at the time of writing — in the direction +*Resolve a doc/code conflict* sets. Each tier states the system as it now is. What changed belongs to the changelog and to git. @@ -570,18 +585,30 @@ Scan the finished text for each of these, since every one is checkable: size of its code says the code stopped being self-descriptive, and prose that is merely short has not thereby passed.) -**Run the checkable ones.** `prose-check.py` ships beside this file and greps items 2, 3, 4, 5, +**A finding names a symptom. Fix the claim, not the token.** Every rule here is about what a +sentence claims, so the repair restates the claim — from the code, or from the invariant, in the +direction *Resolve a doc/code conflict* sets — and writes the sentence again from that. Editing +around the flagged word keeps the shape and costs something else: `grants nothing` redrafted as +`confers no authority` clears the grep, trades a domain term for a legal one, and still fronts the +quantifier — while re-reading the code gives `uses a grant the caller already holds`. The same +move settles the questions a rule cannot answer in the abstract, arity among them. + +**Run the checkable ones.** `prose-check.py` ships beside this file and reports items 2, 3, 4, 5, 7 and 9 plus the `does not` rule, so the pass is a command rather than an act of attention: ```bash python3 /opt/ai-tools/skills/ai-tools-technical-docs/prose-check.py <file>... ``` -It reports and never blocks. Two of its checks are near-exact; the rest (`--all`) report correct -prose often enough to need a reader on every hit. Quoted and backticked spans are skipped, so a -document may quote the prose it warns against; mark anything else deliberate with -`prose-check: allow` on the line. Run it before committing prose, and on the commit message -too — the universal rules cover that artifact like any other. +It reads rejoined sentences, reports, and does not block. Items 4 and 9 and the `does not` rule +run by default and are near-exact. `--all` adds the shape checks, each of which greps a sub-shape +of its rule, because the rules themselves are about meaning: a word stem repeated across the pivot +is the mirror in item 3 and the restated head noun in item 2, and an absolute in a sentence with +no subordinating conjunction has nowhere for item 5's guard clause to be. Those four still want a +reader on every hit. Quoted, backticked, and fenced spans are skipped, so a document may quote the +prose it warns against; mark anything else deliberate with `prose-check: allow` on the line. Run +it before committing prose, and on the commit message too — the universal rules cover that +artifact like any other. When in doubt: describe what the code does, name the mechanism that does it, and use fewer words. diff --git a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py index b97ca838..14ad50d7 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py +++ b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/prose-check.py @@ -8,16 +8,42 @@ # # python3 /opt/ai-tools/skills/ai-tools-technical-docs/prose-check.py <file>... # -# Three modes. `--staged` reads the added lines of the git index, which is what a pre-commit -# hook runs; `--message` reads a commit message, an artifact this standard covers like any -# other; named paths are read whole, for a sweep. -# Shell files contribute their comment lines, Markdown and man pages every line. The patterns -# match English, so they carry to any codebase. +# Four modes. `--staged` reads the added lines of the git index, which is what a pre-commit hook +# runs; `--message` reads a commit message, an artifact this standard covers like any other; named +# paths are read whole, for a sweep; `--kept` compares the two sides of a diff, and enforces a +# different rule -- see its own heading below. `--staged` sees only the added half of a sentence an +# edit split, so a hit it reports alone is worth re-checking against the whole file. +# Source files contribute their comments and docstrings, Markdown and man pages every line. The +# patterns match English, so they carry to any codebase. # -# The default checks are the two that scored above 95% precision when sampled against this -# repository. `--all` adds four more that report correct prose often enough to need a reader on -# every hit -- `cannot` scored 0 of 6, because the rule it implements ("with no guard named in the -# same sentence") is not a property a regex can see. +# `--kept`: A REWRITE CHANGES THE WORDING, NOT THE CLAIM. +# Every other check reports how a sentence is written. This one reports a rewrite that changed +# what a sentence CLAIMS, which is a defect of a different kind: the prose still has to state the +# same security boundary afterwards. The usual way it goes wrong is a swapped set -- +# `carries no secret` becomes `contains only settings`, which reads better and stops justifying +# the 644 mode it was written to justify, because a setting can be a token. Whether two sets are +# disjoint is not something a regex can decide, so this reports the security term a rewrite +# dropped and leaves the judgement to a reader. It compares one hunk at a time, so a term that +# merely moved to another hunk of the same file reports as dropped; check the file before acting. +# +# Checks read rejoined SENTENCES rather than raw lines. Wrapped prose puts the guard clause of an +# absolute on the next line, and the shape checks compare the two halves of a pivot, so both need +# the whole sentence to report anything worth reading. +# +# `--all` adds the four shape checks. Each one greps a sub-shape of its rule -- the half a regex +# can see -- because the rules themselves are about meaning: "an absolute with no guard in the +# same sentence" and "a clause mirrored across a pivot" are not properties of any word list. A +# vocabulary grep for them reported correct prose on most of what it flagged when it was sampled +# against this repository, so each check now carries a second condition: +# +# unbacked-absolute the sentence holds an absolute AND no subordinating conjunction, since a +# guard clause is what those conjunctions introduce. +# mirrored-clause a word stem repeats across `rather than` / `instead of`, which is the +# mirror itself; a plain contrast puts different words on each side. +# definitional a head noun repeats across `is not a`, which is the restatement that makes +# the sentence a definition instead of a description. +# history the past-tense markers only. `no longer` describes a current state as often +# as a change, so it is left to the reader. # # A line carrying `prose-check: allow` is skipped, which is how a style guide keeps the labelled # bad examples it has to contain. @@ -63,16 +89,65 @@ def suggest(name, match, static_hint): """What to write instead, derived from the match where the fix is mechanical.""" if name == "fronted-quantifier": verb, obj = match.group(1), match.group(2) - return f"`does not {base_form(verb)} any {obj}`" + # `a` or `any` is a claim about arity, so the code decides: one parameter takes the + # article, a variadic one pluralizes under `any`, an uncountable object takes neither. + return f"`does not {base_form(verb)} a/any {obj}`" return static_hint +# A subordinating conjunction is how a guard clause attaches, so a sentence carrying one has +# somewhere for the guard to be and is left to the reader. +GUARD = re.compile(r"\b(so|because|since|unless|when|while|until|once|only|if|where|after" + r"|before|without|through|via|whenever|as long as)\b") +ABSOLUTE = re.compile(r"\b(never|always|cannot)\b") + +MIRROR_PIVOT = re.compile(r"\b(rather than|instead of)\b") +DEFINITIONAL_PIVOT = re.compile(r"\b(?:is|are) not (?:a|an|the)\b") + +# Four characters is the shortest prefix that separates the stems this repository uses +# (`stop`/`stay`, `read`/`real`) while still tying `costs` to `costing` and `control` to +# `controls`. Words of three letters or fewer carry no stem worth matching. +WORD = re.compile(r"[a-z][a-z-]{3,}") +# Words each side of the pivot. Five is what separates a mirror from a sentence that happens to +# reuse its own subject: `a verb on ai-tools-admin rather than a binary of its own` repeats +# `binary` from six words back, and that repeat is the topic, not a mirrored clause. +MIRROR_WINDOW = 5 + + +def stems(text, limit=None): + """The four-character stems of the words in `text`, optionally the first or last `limit`.""" + words = WORD.findall(text.lower()) + if limit is not None: + words = words[-limit:] if limit > 0 else words[:-limit] + return {word[:4] for word in words} + + +def mirrored(sentence, pivot): + """True when a word stem repeats across `pivot`, which is the mirror the rule names. + + `costs you a label rather than costing the sweep a target` repeats `cost`; `shipped in the + package rather than downloaded` does not share a stem and is a plain contrast. + """ + match = pivot.search(sentence) + if not match: + return None + left = stems(sentence[:match.start()], MIRROR_WINDOW) + right = stems(sentence[match.end():], -MIRROR_WINDOW) + return match if left & right else None + + +def unbacked_absolute(sentence): + """An absolute in a sentence with no subordinating conjunction to hang a guard on.""" + match = ABSOLUTE.search(sentence) + return match if match and not GUARD.search(sentence) else None + + EXTRA_CHECKS = [ - ("mirrored-clause", re.compile(r"\brather than\b"), "state the fact once, in one direction"), - ("definitional", re.compile(r"\bis not (a|an|the)\b|\bis no\b"), "describe the mechanism"), - ("unbacked-absolute", re.compile(r"\b(never|always|cannot)\b"), - "name the guard in the same sentence"), - ("history", re.compile(r"\b(used to|previously|no longer|was changed)\b"), + ("mirrored-clause", lambda s: mirrored(s, MIRROR_PIVOT), + "state the fact once, in one direction"), + ("definitional", lambda s: mirrored(s, DEFINITIONAL_PIVOT), "describe the mechanism"), + ("unbacked-absolute", unbacked_absolute, "name the guard in the same sentence"), + ("history", re.compile(r"\b(used to|previously|was changed|formerly)\b"), "state current behaviour"), ("filler", re.compile(r"\b(simply|obviously|clearly|basically|naturally|effectively" r"|actually|essentially|robust|elegant|powerful|flexible)\b"), @@ -81,44 +156,201 @@ def suggest(name, match, static_hint): PROSE_WHOLE_FILE = (".md", ".1", ".5", ".8") +# Terms that mark a sentence as stating a SECURITY BOUNDARY rather than describing behaviour. +# A rewrite that drops one of these has probably changed the claim; see the `--kept` heading above. +INVARIANT_TERMS = re.compile( + r"\b(secret|secrets|credential|credentials|token|password|privilege|privileged|sudo" + r"|world-readable|root-only|owner-only|unprivileged|untrusted|trusted|forge|forged|tamper" + r"|escalate|escalation|fail-closed|fail closed|confine|confined|allowlist|refuses|refuse" + r"|0[0-7]{3}|[0-7]{3,4} root:)\b", re.I) + MESSAGE = "<message>" # the path a commit message is reported under +# A line that carries its own prose and does not continue onto the next one: a Markdown heading +# or table row, a man-page macro. Joining a table would let a guard word in one row suppress a +# finding in another. +STANDALONE = re.compile(r"^\s*(\||#{1,6}\s|\.[A-Za-z])") +FENCE = re.compile(r"^\s*(```|~~~)") +SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+") -def is_prose_line(path, line): - """True when this line carries prose: any line of a document, a comment in a script. - A commit message inverts the script rule -- its body is prose and its `#` lines are the - template git strips -- so it is passed under its own path and tested here. +LINE_COMMENT = re.compile(r"^\s*(#(?!!)|//+)\s?") +BLOCK_MARGIN = re.compile(r"^\s*\*(?!/)\s?") # the ` * ` margin inside a /* */ block +# `/*` opens a comment only when a space, a second `*`, or the line end follows. A shell `case` +# pattern (`/*|./*|../*)`) begins the same way, and reading one as a comment opener swallows every +# line to the next `*/` -- which in a shell script is the rest of the file. +BLOCK_OPEN = re.compile(r"^\s*/\*(\s|\*|$)") +TRIPLE_QUOTE = re.compile(r'"""|\'\'\'') + + +def source_prose(line, state): + """The prose a source line carries, and the block state after it. + + A source file contributes its comments AND its docstrings: `#`, `//`, a `/* */` block, and a + triple-quoted Python string are all places the artifacts this standard covers live. The marker + is dropped so the sentences rejoin cleanly. """ - if path == MESSAGE: - return not line.lstrip().startswith("#") - if path.endswith(PROSE_WHOLE_FILE): - return True - stripped = line.lstrip() - return stripped.startswith("#") and not stripped.startswith("#!") + if state: # inside a docstring or a /* */ block; state holds its closing delimiter + end = line.find(state) + body = line if end < 0 else line[:end] + if state == "*/": + body = BLOCK_MARGIN.sub("", body) + return body, (state if end < 0 else None) + stripped = line.strip() + quote = TRIPLE_QUOTE.match(stripped) + if quote: + delimiter = quote.group(0) + body = stripped[len(delimiter):] + return (body.split(delimiter)[0], None) if delimiter in body else (body, delimiter) + if BLOCK_OPEN.match(line): + body = stripped[2:] + return (body.split("*/")[0], None) if "*/" in body else (body, "*/") + return (LINE_COMMENT.sub("", line), None) if LINE_COMMENT.match(line) else (None, None) + + +def prose_lines(source): + """Yield (path, line number, raw line, prose or None), holding block state per file. + + A document or man page contributes every line. A commit message inverts the source rule -- its + body is prose and its `#` lines are the template git strips. + """ + last_path, state = None, None + for path, number, line in source: + if path != last_path: + last_path, state = path, None + if path == MESSAGE: + yield path, number, line, None if line.lstrip().startswith("#") else line + elif path.endswith(PROSE_WHOLE_FILE): + yield path, number, line, line + else: + text, state = source_prose(line, state) + yield path, number, line, text + + +def block_sentences(path, lines): + """Split one joined block into sentences, each reported at the line it starts on.""" + if not path or not lines: + return + joined, offsets = "", [] + for number, text in lines: + if joined: + joined += " " + offsets.append((len(joined), number)) + joined += text.strip() + position = 0 + for part in SENTENCE_SPLIT.split(joined): + part = part.strip() + if not part: + continue + start = joined.index(part, position) + yield path, max(n for offset, n in offsets if offset <= start), part + position = start + len(part) + + +def sentences(source): + """Yield (path, line number, sentence) with wrapped prose rejoined. + + A block ends at a blank line, a line carrying no prose, a standalone line, or a change of + file. Fenced code in a document is skipped: it is not the author's prose. + """ + block_path, block, fenced = None, [], False + for path, number, line, text in prose_lines(source): + if text is not None and path.endswith(PROSE_WHOLE_FILE): + if FENCE.match(line): + fenced = not fenced + text = None + elif fenced: + text = None + if text is not None and ALLOW_MARKER in line: + text = None + standalone = bool(text and text.strip() and STANDALONE.match(text)) + if not (text and text.strip()) or path != block_path or standalone: + yield from block_sentences(block_path, block) + block_path, block = path, [] + if not (text and text.strip()): + continue + if standalone: + yield from block_sentences(path, [(number, text)]) + continue + block.append((number, text)) + yield from block_sentences(block_path, block) def staged_lines(): - """Yield (path, line) for every line this commit adds, from the index.""" + """Yield (path, line number, line) for every line this commit adds, from the index.""" diff = subprocess.run( ["git", "diff", "--cached", "-U0", "--no-color", "--diff-filter=ACM"], capture_output=True, text=True, check=False).stdout - path = None + path, number = None, 0 for line in diff.splitlines(): if line.startswith("+++ b/"): + path, number = line[6:], 0 + elif line.startswith("@@"): + hunk = re.search(r"\+(\d+)", line) + number = int(hunk.group(1)) - 1 if hunk else 0 + elif line.startswith("+") and not line.startswith("+++") and path: + number += 1 + yield path, number, line[1:] + + +def diff_hunks(revisions): + """Yield (path, removed prose lines, added prose lines) for each hunk of a diff.""" + command = ["git", "diff", "--no-color", "--diff-filter=M", "-U0"] + command += revisions.split() if revisions else ["--cached"] + diff = subprocess.run(command, capture_output=True, text=True, check=False).stdout + path, removed, added = None, [], [] + for line in diff.splitlines(): + if line.startswith("diff --git") or line.startswith("@@"): + if path: + yield path, removed, added + removed, added = [], [] + elif line.startswith("+++ b/"): path = line[6:] + elif line.startswith("-") and not line.startswith("---") and path: + removed.append(line[1:]) elif line.startswith("+") and not line.startswith("+++") and path: - yield path, line[1:] + added.append(line[1:]) + if path: + yield path, removed, added + + +def _singular(term): + return term[:-1] if term.endswith("s") and not term.endswith("ss") else term + + +def invariant_terms(path, lines): + """The invariant vocabulary the prose among these lines uses, lowercased.""" + found = set() + for line in lines: + text, _ = (line, None) if path.endswith(PROSE_WHOLE_FILE) else source_prose(line, None) + if text: + # Singular and plural are one term: `carries no secrets` restated as `must not hold a + # secret` keeps the claim, and reporting that as a drop trains a reader to ignore it. + found.update(_singular(match.group(0).lower()) + for match in INVARIANT_TERMS.finditer(text)) + return found + + +def kept_findings(revisions): + """Report a security term a hunk removed from prose without restating it. + + Reports the drop; whether the new wording still rules out the same thing is the reader's call. + """ + for path, removed, added in diff_hunks(revisions): + dropped = invariant_terms(path, removed) - invariant_terms(path, added) + for term in sorted(dropped): + context = next((line.strip() for line in removed if term in line.lower()), "") + yield path, term, context def file_lines(paths): - """Yield (path, line) for every line of every readable path.""" + """Yield (path, line number, line) for every line of every readable path.""" for path in paths: try: with open(path, errors="ignore") as handle: - for line in handle: - yield path, line.rstrip("\n") + for number, line in enumerate(handle, 1): + yield path, number, line.rstrip("\n") except OSError as exc: print(f"prose-check: cannot read {path}: {exc}", file=sys.stderr) @@ -127,8 +359,8 @@ def file_lines(paths): QUOTED_SPAN = re.compile(r"`[^`]*`|\"[^\"]*\"") -def author_prose(path, line): - """The line with the spans that are not the author's own prose blanked out. +def author_prose(path, text): + """The sentence with the spans that are not the author's own prose blanked out. A backticked span is a code reference in either kind of file. A double-quoted span is a quotation in a DOCUMENT -- most often the labelled bad example a style guide has to contain -- @@ -138,18 +370,16 @@ def author_prose(path, line): span = QUOTED_SPAN if path.endswith(PROSE_WHOLE_FILE) else BACKTICK_SPAN # " -- " rather than a space: a removed span must still separate the words around it, or # `takes \x60--for\x60 no target` fuses into a phrase the patterns then match. - return span.sub(" -- ", line) + return span.sub(" -- ", text) def findings(source, checks): - for path, line in source: - if ALLOW_MARKER in line or not is_prose_line(path, line): - continue - subject = author_prose(path, line) - for name, pattern, hint in checks: - match = pattern.search(subject) + for path, number, sentence in sentences(source): + subject = author_prose(path, sentence) + for name, check, hint in checks: + match = check.search(subject) if hasattr(check, "search") else check(subject) if match: - yield path, name, match.group(0), suggest(name, match, hint), line.strip() + yield path, number, name, match.group(0), suggest(name, match, hint), sentence def main(): @@ -160,26 +390,41 @@ def main(): parser.add_argument("--message", metavar="FILE", help="check a commit message; template comments skipped") parser.add_argument("--all", action="store_true", - help="add the lower-precision checks") + help="add the shape checks") + parser.add_argument("--kept", metavar="REVISIONS", nargs="?", const="", + help="report an invariant term a rewrite dropped (default: the index)") parser.add_argument("paths", nargs="*", help="files to read whole") args = parser.parse_args() + if args.kept is not None: + count = 0 + for path, term, context in kept_findings(args.kept): + count += 1 + print(f"{path}: dropped [{term}] -- restate it, or confirm the new wording still " + f"rules out the same thing") + print(f" - {context[:110]}") + if count: + print(f"\n{count} dropped term(s). A rewrite changes the wording, not the claim. " + f"A term that only moved to another hunk reports here too.") + return 1 if count else 0 + modes = [args.staged, bool(args.message), bool(args.paths)] - if sum(1 for m in modes if m) != 1: + if sum(1 for mode in modes if mode) != 1: parser.error("give exactly one of --staged, --message FILE, or one or more paths") checks = DEFAULT_CHECKS + (EXTRA_CHECKS if args.all else []) if args.staged: source = staged_lines() elif args.message: - source = ((MESSAGE, line.rstrip("\n")) for line in open(args.message, errors="ignore")) + source = ((MESSAGE, number, line.rstrip("\n")) + for number, line in enumerate(open(args.message, errors="ignore"), 1)) else: source = file_lines(args.paths) count = 0 - for path, name, token, hint, text in findings(source, checks): + for path, number, name, token, hint, text in findings(source, checks): count += 1 - print(f"{path}: {name} [{token}] -- {hint}") + print(f"{path}:{number}: {name} [{token}] -- {hint}") print(f" {text[:110]}") if count: print(f"\n{count} finding(s). See the ai-tools-technical-docs skill; " diff --git a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/references/man-pages.md b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/references/man-pages.md index aa8c99c0..3c6cda7a 100644 --- a/src/usr/share/ai-tools/skills/ai-tools-technical-docs/references/man-pages.md +++ b/src/usr/share/ai-tools/skills/ai-tools-technical-docs/references/man-pages.md @@ -132,7 +132,7 @@ A man page is part of the same coupled set as the CLI it documents: an added fla default, or a new exit code lands in the same change as the page. Where a page and the command disagree, the command decides what the page says. -State the current interface. A page carries no changelog; what changed belongs to the changelog +State the current interface. A page does not carry a changelog; what changed belongs to the changelog and to git. ## Check before finishing diff --git a/src/usr/share/ai-tools/subagents/README.md b/src/usr/share/ai-tools/subagents/README.md index c8206dbf..28ea0b5c 100644 --- a/src/usr/share/ai-tools/subagents/README.md +++ b/src/usr/share/ai-tools/subagents/README.md @@ -37,9 +37,10 @@ x-ai-tools-updated: 2026-07-15 ``` The invocation name stays stable (`ai-tools-reference-architect`); the version and date ride -in frontmatter, RFC-draft style — a monotonic `x-ai-tools-version` bumped on every change, -plus the `x-ai-tools-updated` date. On install or bootstrap a newer shipped version is offered -as an update and an unchanged one is a no-op; overwriting an existing managed asset asks first -and defaults to keep, so a copy you tuned on the host is never discarded silently. +in frontmatter, RFC-draft style — a monotonic `x-ai-tools-version` bumped once per release in +which the subagent changed, plus the `x-ai-tools-updated` date. On install or bootstrap a newer +shipped version is offered as an update and an unchanged one is a no-op; overwriting an existing +managed asset asks first and defaults to keep, so a copy you tuned on the host survives the +upgrade. Mechanism and invariants: `.claude/rules/shipped-assets.rule.md`. diff --git a/src/var/opt/ai-tools/README.md b/src/var/opt/ai-tools/README.md index e9deadfb..01867506 100644 --- a/src/var/opt/ai-tools/README.md +++ b/src/var/opt/ai-tools/README.md @@ -36,7 +36,7 @@ ai-tools --sandbox-create [path] \ `--branch` takes any valid git ref, so the `sandbox/<leaf>` default is only a convention — use `--branch hotfix/urgent`, a flat `--branch mywork`, or any other shape. Nothing downstream depends on the name (`--sandbox-push` tracks the clone's -upstream, not a naming pattern). The default deliberately carries no host or operator +upstream, not a naming pattern). The default deliberately omits host and operator identity; on a shared remote, pass `--branch` to disambiguate concurrent sandboxes. ## Why this is the boundary diff --git a/tests/boundary/access.sh b/tests/boundary/access.sh index 747502b5..39d8dbb7 100644 --- a/tests/boundary/access.sh +++ b/tests/boundary/access.sh @@ -267,7 +267,7 @@ fi # left with after `chmod 700` inside a claimed tree, and the point of removing the inherited # group/setgid/ACL is that the mode is then the only thing standing -- so assert it stands, from # the account it exists to stop. The parent is deliberately traversable, or it would be what -# denies access and the assertion would prove nothing. +# denies access and the assertion would prove no boundary. mktestdir sealed="${TESTDIR}/sealed" mkdir -p "${sealed}" @@ -301,7 +301,7 @@ fi # session may start. Reaching it would let the agent approve its own projects, so this is the # boundary half of the pair whose runtime half (each of the helper's gates fires) is in # tests/unit/allowlist-helper.sh. The helper is 750 root:root inside a 750 root:root directory and -# the sandbox account holds no sudo rule, so it is unreachable three ways over; assert the two the +# the sandbox account does not hold a sudo rule, so it is unreachable three ways over; assert the two the # filesystem can show. alhelper=/usr/local/libexec/ai-tools/ai-tools-allowlist if [[ ! -e "${alhelper}" ]]; then @@ -357,7 +357,7 @@ else pass "the agent cannot write its own entrypoint pin" fi -# The labelling record, for a different reason from the pin: it gates nothing, it is REPORTED. An +# The labelling record, for a different reason from the pin: it does not gate a launch, it is REPORTED. An # agent that could write it could tell `ai-tools --status` its labels were applied on a host where # the relabel had failed -- turning the operator's one window onto the labelling half into # something the sandbox account writes. @@ -381,7 +381,7 @@ fi # forgery does reach the trail, so the uid is load-bearing rather than ceremony) and must NOT # appear under _UID=0 (the documented form excludes it). The probe line names itself as a test so # a later reader of the real trail is not misled by it. A host with no journald skips: absence of -# the line proves nothing either way. +# the line is evidence either way. if ! command -v logger >/dev/null || ! command -v journalctl >/dev/null; then skip "journald _UID attribution" "logger or journalctl not available" else @@ -436,9 +436,9 @@ fi # already running. A control the monitored system can disarm is not a control, so the claim rests # on this vantage -- the account a session runs as can neither run the helper (which would let a # session terminate every operator's work, or exhaust the trail with noise) nor alter it. There is -# no authorization input for it to aim at either: the command takes no target and no allowlist, so +# no authorization input for it to aim at either: the command accepts neither a target nor an allowlist, so # what it terminates is decided by cgroup-slice membership alone. What the agent CAN do is be -# stopped: the kill is delivered by root to a cgroup, and nothing inside the cgroup takes part. +# stopped: the kill is delivered by root to a cgroup, and no process inside the cgroup takes part. _stop_bin=/usr/local/libexec/ai-tools/ai-tools-stop if [[ ! -e "${_stop_bin}" ]]; then skip "stop helper not agent-reachable" "not installed at ${_stop_bin}" @@ -505,7 +505,7 @@ fi # tree, and an agent that could invoke it could destroy the operator's work. # # Both are driven with NO path argument, so a regression that let one through would still -# have nothing to act on -- the create refuses a missing path outright, and the remove would +# have no path to act on -- the create refuses a missing path outright, and the remove would # resolve the agent's own cwd, which is not a claimed project of the agent's. The assertion # is on the principal guard's own wording, not merely on a non-zero exit, since every one of # these commands has other reasons to fail. @@ -520,7 +520,7 @@ for _verb in --project-create --project-remove; do done # And it cannot reach the runas seam those verbs use under --for. `sudo -u <operator>` is how -# a --for run acts as the target; the agent holds no sudo rule at all, and the session runs +# a --for run acts as the target; the agent does not hold a sudo rule at all, and the session runs # under PR_SET_NO_NEW_PRIVS, which drops sudo's SUID bit. Either alone is sufficient here. if runuser -u "${SANDBOX_USER}" -- sudo -n -u "${PROJECTS_USER}" true >/dev/null 2>&1; then fail "the agent can run commands as ${PROJECTS_USER} via sudo -u -- the runas seam is reachable" diff --git a/tests/boundary/filters.sh b/tests/boundary/filters.sh index 4e4b939e..262e75e8 100644 --- a/tests/boundary/filters.sh +++ b/tests/boundary/filters.sh @@ -13,7 +13,7 @@ # state in the first place. Both halves must hold: the runtime check catches a host someone has # already broken, this catches the agent trying to break it. # -# Probe-only (test -w); nothing is written, created, or unlinked. Run as root via sudo; drops to +# Probe-only (test -w); no file is written, created, or unlinked. Run as root via sudo; drops to # the agent per check. set -euo pipefail diff --git a/tests/boundary/providers.sh b/tests/boundary/providers.sh index 035dba1d..1c5b8cf2 100644 --- a/tests/boundary/providers.sh +++ b/tests/boundary/providers.sh @@ -13,7 +13,7 @@ # cannot put any of them into that state in the first place. Both halves must hold: the runtime # check catches a host someone has already broken, this catches the agent trying to break it. # -# Probe-only (test -w / test -x); nothing is written, created, or unlinked. Run as root via sudo; +# Probe-only (test -w / test -x); no file is written, created, or unlinked. Run as root via sudo; # drops to the agent per check. set -euo pipefail diff --git a/tests/boundary/sudo.sh b/tests/boundary/sudo.sh index d41a6c32..22e8ac2b 100644 --- a/tests/boundary/sudo.sh +++ b/tests/boundary/sudo.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # SPDX-License-Identifier: AGPL-3.0-only # tests/boundary/sudo.sh -# Boundary: the sandbox account holds NO sudo rights -- the first security-model invariant in +# Boundary: the sandbox account is granted NO sudo rights -- the first security-model invariant in # CLAUDE.md. The three NOPASSWD rules in sudoers.d/ai-tools all belong to the PROJECTS # user -- one dropping privilege to the sandbox account, two running a fixed-path helper as root; # the agent runs AS the sandbox account and can invoke none of them. Asserts that at runtime @@ -18,7 +18,7 @@ readonly SUDOERS="/etc/sudoers.d/ai-tools" section "Agent sudo rights (the sandbox account has none)" # (1) Runtime: what sudo would let the sandbox account run. The invariant is that the agent can -# run NOTHING via sudo -- so assert the canonical "not allowed to run sudo" message positively, +# run NO COMMAND via sudo -- so assert the canonical "not allowed to run sudo" message positively, # not merely the absence of the two known targets. A negative check (no ai-tools-run / no relabel) # would pass a rogue drop-in granting the agent some OTHER command (e.g. ALL=(ALL) NOPASSWD:ALL); # the positive form fails on any grant at all. (-n: never prompt.) @@ -42,7 +42,7 @@ fi # (3) Static: the privilege-lowering grant uses the operators group (%ai-ops) as principal and # drops to the sandbox account. Exactly one such drop rule exists (ai-tools-run); the other rule # targets root (the relabel helper), not the sandbox account. Confirms the rule lowers privilege -# (never raises the agent's), so even invoked it hands the caller nothing it does not already have. +# (never raises the agent's), so even invoked it hands the caller no capability it does not already have. if [[ -r "${SUDOERS}" ]]; then n="$(grep -cE "^[[:space:]]*%ai-ops[[:space:]]+ALL=\(${SANDBOX_USER}:" "${SUDOERS}" || true)" if [[ "${n}" -eq 1 ]]; then diff --git a/tests/integration/ai-tools-run.sh b/tests/integration/ai-tools-run.sh index e5eea5dd..c8492ab0 100644 --- a/tests/integration/ai-tools-run.sh +++ b/tests/integration/ai-tools-run.sh @@ -38,7 +38,7 @@ fi # The claude-code session env. These pins are agent-specific, so they live in that agent's # session-env fragment rather than in the agent-agnostic shim; ai-tools-run sources it last, -# after every enabled integration, so nothing can override them. Each one is load-bearing: +# after every enabled integration, so no fragment can override them. Each one is load-bearing: # DISABLE_AUTOUPDATER the node tree is read-only to the agent, so the in-session auto-updater # would fail every launch (+ AVC); updates are the timer's job # CLAUDE_CONFIG_DIR unpinned, the state file lands under the 2751 home root where the agent @@ -238,9 +238,9 @@ else fi # The complementary property -- an UNPINNED entrypoint must NOT be refused, or an air-gapped - # host would stop launching -- is deliberately NOT driven here. Nothing else about that run is + # host would stop launching -- is deliberately NOT driven here. No other part of that run is # invalid, so the shim would go on to start a real session, which this file's design forbids. - # It is covered where it costs nothing: the pure verdict returns `unpinned` rather than + # It is covered where it does not cost a session: the pure verdict returns `unpinned` rather than # `mismatch` (tests/unit/entrypoint-verify.sh), and only `mismatch` reaches the refusal above. fi @@ -258,7 +258,7 @@ fi # The re-check must sit AFTER the session-env fragments, not with the earlier validation -- its # whole value is the width of the window it leaves (launch.rule.md). Asserted by line order, -# because nothing about the code's behaviour reveals where it runs. +# because no behaviour of the code reveals where it runs. crun_recheck_line="$(grep -n 'entrypoint_identity' "${CRUN}" | tail -n1 | cut -d: -f1)" crun_launch_line="$(grep -n '^systemd-run --user --pty --quiet' "${CRUN}" | head -n1 | cut -d: -f1)" if [[ -z "${crun_recheck_line}" || -z "${crun_launch_line}" ]]; then diff --git a/tests/integration/cli.sh b/tests/integration/cli.sh index 5c43aba7..eb187209 100644 --- a/tests/integration/cli.sh +++ b/tests/integration/cli.sh @@ -39,7 +39,7 @@ done # (1b) The verbs that write no operator state are the carve-out: --audit needs root by # construction (its trail is 700 root:root), and refusing it left the verb unreachable from BOTH -# sides on a host whose only operator holds no general sudo grant. Asserted on the refusal text +# sides on a host whose only operator does not hold a general sudo grant. Asserted on the refusal text # rather than the exit status: --audit and --status both exit non-zero to REPORT something, which # is not a refusal. for verb in --audit --status --list --providers; do @@ -139,9 +139,9 @@ if command -v runuser >/dev/null 2>&1; then fi # (6) --project-unclaim classifies its target against allowed-projects: a directory that no - # entry covers AND that carries no ai-tools ownership or group is REFUSED, before any + # entry covers AND that has no ai-tools ownership or group is REFUSED, before any # registry/filesystem change. The testdir path is not in the (real) allowlist and is freshly - # created, so it classifies as unrelated-and-clean -- the one outcome with nothing to offer + # created, so it classifies as unrelated-and-clean -- the one outcome with no remedy to offer # (a tree carrying the fingerprint is instead pointed at --force). # Runs as an OPERATOR (conf lists the projects user) so classification runs past the operator # gate; under setsid so any prompt takes its non-interactive default rather than blocking. @@ -203,7 +203,7 @@ if command -v runuser >/dev/null 2>&1; then } # (6c) --project-create is a real verb, not an alias, so its refusals are asserted where the - # old alias had none. Every one of them must leave NOTHING behind -- no directory, no registry + # old alias had none. Every one of them must leave NO RESIDUE behind -- no directory, no registry # entry -- which is what makes "recover with --project-claim" the only recovery path it needs. : > "${emptyal}" create_cli() { @@ -246,7 +246,7 @@ if command -v runuser >/dev/null 2>&1; then # The protected-paths backstop on the target: a create must not be able to MANUFACTURE a # protected directory. Exits 3, the backstop's own code. Driven against a protected path this - # host does not have; if it has all of them there is nothing to assert and the case skips. + # host does not have; if it has all of them there is no path to assert and the case skips. protected="" for p in /efi /lost+found /libx32 /lib32 /srv /media; do [[ -e "${p}" ]] || { protected="${p}"; break; } @@ -271,7 +271,7 @@ if command -v runuser >/dev/null 2>&1; then fi # THE HAPPY PATH. Every assertion above is a refusal, which together can be satisfied by a - # verb that does nothing at all -- so the one run that has to actually work is asserted too, + # verb that acts on no path at all -- so the one run that has to actually work is asserted too, # end to end and unattended. It runs under setsid with NO -y (the verb has none): a create # that still asked something would block here and be killed by the file timeout, which is the # regression this also guards. @@ -281,7 +281,7 @@ if command -v runuser >/dev/null 2>&1; then newproj="${crwork}/newproject" # The exit status depends on whether this environment can authenticate for the claim's root # helpers. Under setsid there is no terminal to type a password at, so the claim reports that - # it could not finish and exits 1; where the suite's sudo needs no password it exits 0. Both + # it could not finish and exits 1; where the suite's sudo does not require a password it exits 0. Both # are correct, and both are asserted -- what must never happen is the third outcome this # replaced, where every root step failed and the flow still closed on a success mark. out="$(create_cli "${newproj}")" && rc=0 || rc=$? @@ -309,13 +309,13 @@ if command -v runuser >/dev/null 2>&1; then fail "--project-create did not register the project: $(cat "${emptyal}")" fi # The tree it makes is owned by the operator, which is what the claim's own owner guard - # requires -- a create that produced a root-owned tree would claim nothing and say so. + # requires -- a create that produced a root-owned tree would claim no path and say so. if [[ "$(stat -c '%U' "${newproj}")" == "${PROJECTS_USER}" ]]; then pass "--project-create leaves the tree owned by the operator it acts for" else fail "the created tree is owned by $(stat -c '%U' "${newproj}"), not ${PROJECTS_USER}" fi - # The secret scan is skipped on a tree with nothing in it, so a create must never reach + # The secret scan is skipped on an empty tree, so a create must never reach # ai-tools-lockdown. Asserted on that helper specifically, NOT on the absence of any password # prompt: the claim legitimately sudo's for safedir, setgid, setfacl and relabel, since group # ownership cannot be changed to a group the operator is not in without root. @@ -326,7 +326,7 @@ if command -v runuser >/dev/null 2>&1; then fail "--project-create ran the secret scan on a tree it had just created: $(brief "${out}" 'lockdown|secret-named')" fi - # Nothing it seeds may be owner-only. Under a umask of 077 the directory would be born 0700, + # No path it seeds may be owner-only. Under a umask of 077 the directory would be born 0700, # README.md 0600, and .git 0700/0600 -- and an owner-only path is one the claim's helpers # honour as a seal and skip, so the verb would produce a registered project whose README the # agent cannot read and whose git it cannot use, having reported that it normalized both. @@ -351,7 +351,7 @@ if command -v runuser >/dev/null 2>&1; then fi # (6d) --project-remove deletes, so every assertion here is that it did NOT. Its authorization - # is an exact allowlist entry and nothing else: there is no --force, and an unattended run + # is an exact allowlist entry alone: there is no --force, and an unattended run # never reaches the deletion because both the default-NO confirm and the typed-name challenge # decline with no terminal (these run under setsid, so that is the path being driven). remove_cli() { @@ -378,7 +378,7 @@ if command -v runuser >/dev/null 2>&1; then rmother="${rmwork}/rm-other"; mkdir -p "${rmother}" chown -R "${PROJECTS_USER}:${PROJECTS_USER}" "${rmproj}" "${rmother}" - # An unregistered path is refused: the registry entry is the authorization, so a tree nothing + # An unregistered path is refused: the registry entry is the authorization, so a tree no registry names # claimed is not this verb's to delete. printf '%s\n' "${rmproj}" > "${rmal}"; chown "${PROJECTS_USER}" "${rmal}" out="$(remove_cli "${rmother}")" && rc=0 || rc=$? @@ -507,7 +507,7 @@ if command -v runuser >/dev/null 2>&1; then done # -y must not bypass the classification. The scripted-removal mistake is a -y run pointed at a - # path that is not a claimed project; the flag pre-answers the two prompts and nothing else. + # path that is not a claimed project; the flag pre-answers the two prompts alone. printf '%s\n' "${rmproj}" > "${rmal}"; chown "${PROJECTS_USER}" "${rmal}" out="$(remove_cli -y "${rmother}")" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && [[ -d "${rmother}" ]]; then @@ -660,7 +660,7 @@ EOF # (9) --sandbox-remove refuses a target that is not a real clone, BEFORE any rm -rf: # the shared clone-area root itself (require_sandbox_clone: not a direct-child clone) and a - # path outside SANDBOX_ROOT. The refusal precedes the removal, so nothing is deleted. + # path outside SANDBOX_ROOT. The refusal precedes the removal, so no path is deleted. sroot="/var/opt/ai-tools/sandbox-projects" out="$(runuser -u "${PROJECTS_USER}" -- env HOME="${PROJECTS_HOME}" \ AI_TOOLS_OPERATOR_CONF="${oconf}" setsid "${CLI}" --sandbox-remove "${sroot}" 2>&1)" && rc=0 || rc=$? @@ -748,7 +748,7 @@ if command -v runuser >/dev/null 2>&1; then setsid -w "${CLI}" "$@" 2>&1 } - # (1) An unenrolled target is refused, naming the enrolment command. Nothing may be written for + # (1) An unenrolled target is refused, naming the enrolment command. No entry may be written for # a name the ownership helpers cannot later resolve to an owner. out="$(run_for --project-claim --for definitely-not-an-operator "${fproj}")" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && grep -qi 'not a configured ai-tools operator' <<<"${out}"; then @@ -771,7 +771,7 @@ if command -v runuser >/dev/null 2>&1; then fi # (3) Refused, not ignored, on a verb it does not apply to -- a --sandbox-create that silently - # cloned as the invoker would leave the tree owned by the wrong operator with nothing to show. + # cloned as the invoker would leave the tree owned by the wrong operator with no output to show. out="$(run_for --sandbox-create --for "${PROJECTS_USER}" "${fproj}")" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && grep -qi 'for is not accepted on' <<<"${out}"; then pass "--for is refused on a verb that does not accept it (not silently ignored)" @@ -798,7 +798,7 @@ if command -v runuser >/dev/null 2>&1; then 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 +# The pair edits ONE line of the operator's own allowlist and does not reach a 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 (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 @@ -910,7 +910,7 @@ else fi # (7) The other half of keeping a '!' unambiguous: parking a NESTED project would write a line - # nothing could later tell apart from that carve-out, so no verb writes one. + # no reader could later tell apart from that carve-out, so no verb writes one. pd_seed "# projects" "${pd_proj}" "${pd_nested}" out="$(pd_cli --project-disable "${pd_nested}")" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && grep -qi 'nested inside another claimed project' <<<"${out}"; then @@ -925,7 +925,7 @@ else fi # (8) The per-project verbs must not call a parked project unclaimed: on an excluded path the - # root helpers resolve no owner and do NOTHING, so a run that got that far would report steps + # root helpers resolve no owner and act on NO PATH, so a run that got that far would report steps # it never applied. (A host may also refuse earlier for a missing sudo grant -- that is a # different, correct refusal; what must not appear is "not a claimed project".) pd_seed "# projects" "!${pd_proj}" @@ -942,13 +942,13 @@ else # records "not right now", not "not mine", and making the operator re-enable a tree they mean # to delete would make it launchable on the way out. So the verb must get past classification # -- reaching its own disabled confirm -- rather than refusing as unregistered. It must also - # delete NOTHING here: with no terminal the confirm and the typed-name challenge both decline, + # delete NO PATH here: with no terminal the confirm and the typed-name challenge both decline, # which is the property that keeps a destructive verb out of an unattended run. out="$(pd_cli --project-remove "${pd_proj}")" && rc=0 || rc=$? if grep -qi 'not a claimed project' <<<"${out}"; then fail "--project-remove refused a parked project as unregistered: $(brief "${out}")" elif grep -qi 'holds no sudo grant' <<<"${out}"; then - # A host whose operator holds no general sudo grant refuses ahead of classification. That + # A host whose operator does not hold a general sudo grant refuses ahead of classification. That # is a different, correct refusal, and it is not what this case is about. skip "--project-remove over a parked project" "no sudo grant for the removal helper here" elif [[ ${rc} -ne 0 ]] && grep -qi 'disabled' <<<"${out}"; then @@ -964,7 +964,7 @@ else # (10) --keep-entry is about what becomes of an ENTRY, so it is refused where there is none to # keep: --force is the mode that reaches a tree the allowlist does not name. Refused rather - # than ignored, since a flag silently doing nothing is how an operator learns the wrong model. + # than ignored, since a flag that silently skips its work is how an operator learns the wrong model. out="$(pd_cli --project-unclaim --keep-entry --force "${pd_proj}")" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && grep -qi 'keep-entry cannot be combined with --force' <<<"${out}"; then pass "--keep-entry with --force is refused (there is no entry to keep)" @@ -1091,7 +1091,7 @@ else # (5) An unparseable --since is REFUSED, never widened to "everything": a typo that silently # reported all of history would read as a catastrophe, and one that silently reported - # nothing would read as all-clear. Both are worse than an error. + # an empty result would read as all-clear. Both are worse than an error. out="$(AI_TOOLS_LOG_DIR="${audit_dir}" "${audit_bin}" --since 'not a date' 2>&1)" && rc=0 || rc=$? if [[ ${rc} -ne 0 ]] && grep -qi 'not understood' <<<"${out}"; then pass "--audit refuses a --since value date(1) cannot parse" @@ -1099,7 +1099,7 @@ else fail "--audit accepted an unparseable --since (rc=${rc}): ${out}" fi - # (6) The DEPLOYED CLI reaches the helper. (1)-(5) drive the helper directly, so nothing so + # (6) The DEPLOYED CLI reaches the helper. (1)-(5) drive the helper directly, so no case so # far would notice a verb that was never wired into the dispatch. Asserted through the # help text rather than by running the verb, which sudo-prompts (no NOPASSWD rule). # The usage()/man-page pairing itself is covered from source in unit/man.sh; what this diff --git a/tests/integration/handback.sh b/tests/integration/handback.sh index 837dfa57..445ae63c 100644 --- a/tests/integration/handback.sh +++ b/tests/integration/handback.sh @@ -140,7 +140,7 @@ fi # # The whole privilege bridge rests on the daemon rejecting bad input and the helper # re-validating the allowlist. Drive the real client AS the sandbox account and prove a -# request it must NOT honour changes nothing. The client exits non-zero and relays the +# request it must NOT honour does not change a path. The client exits non-zero and relays the # daemon's ERR reason on any rejection. section "Handback bridge: input validation + allowlist boundary (negative)" diff --git a/tests/integration/hooks.sh b/tests/integration/hooks.sh index 5fede8a1..bbf3d455 100644 --- a/tests/integration/hooks.sh +++ b/tests/integration/hooks.sh @@ -24,7 +24,7 @@ readonly SOCK="/run/ai-tools/handback.sock" # ── settings.json declares the hooks + Bash deny rules ─────────────────────────── # perms.sh pins settings.json's owner/mode and access.sh pins that the agent cannot write it, -# but nothing asserts the file still DECLARES the handback hooks and the deny rules -- an install +# but no case asserts the file still DECLARES the handback hooks and the deny rules -- an install # that shipped an empty or stale settings.json would disable handback + secret quarantine with # every permission check still green. Pin the security-load-bearing content here. This runs # independently of the live daemon below (it needs only the file), so a socket-down host still @@ -287,7 +287,7 @@ fi # for -- it is unbounded and routinely carries file content -- so the fixture writes a # recognisable secret through one and the assertion is that it never reaches the journal. # Driven as the agent, since that is the account that writes these lines and the uid they must -# file under. A host without journald skips: an absent line proves nothing either way. +# file under. A host without journald skips: an absent line is evidence either way. section "PostToolUse tool-call record (content bound)" if ! command -v journalctl >/dev/null 2>&1; then skip "tool-call record" "journalctl not available to read the trail back" diff --git a/tests/integration/perms.sh b/tests/integration/perms.sh index 6bab9edd..6a0b9b08 100644 --- a/tests/integration/perms.sh +++ b/tests/integration/perms.sh @@ -80,16 +80,16 @@ check_file /usr/local/lib/ai-tools/npm-verify.lib.sh root r check_file /usr/local/lib/ai-tools/entrypoint-verify.lib.sh root root 644 check_file /usr/local/lib/ai-tools/keys/claude-code.asc root root 644 # Shared KEY=value grammar + the trust predicate: 644 root:root -- world-readable, sourced by -# operator.lib.sh, skip-dirs.lib.sh and providers.lib.sh; carries no secrets. +# operator.lib.sh, skip-dirs.lib.sh and providers.lib.sh; does not carry secrets. check_file /usr/local/lib/ai-tools/conf.lib.sh root root 644 # Provider/agent resolver: 644 root:root -- world-readable, sourced by ai-tools-bootstrap and -# nvm-update (both run as the sandbox account) to read the agent manifests; carries no secrets. +# nvm-update (both run as the sandbox account) to read the agent manifests; does not carry secrets. check_file /usr/local/lib/ai-tools/providers.lib.sh root root 644 # Optional SELinux policy-group registry: 644 root:root -- world-readable, sourced by -# ai-tools-admin and selinux/install-selinux.sh (both root); read-only data, carries no secrets. +# ai-tools-admin and selinux/install-selinux.sh (both root); read-only data, does not carry secrets. check_file /usr/local/lib/ai-tools/selinux-groups.lib.sh root root 644 # Command-filter engine: 644 root:root -- world-readable, sourced by an agent's filter hook, which -# runs AS the agent on every Bash call; read-only data plus pure logic, carries no secrets. +# runs AS the agent on every Bash call; read-only data plus pure logic, does not carry secrets. check_file /usr/local/lib/ai-tools/filters.lib.sh root root 644 # Service-health registry: 644 root:root -- world-readable, sourced by the operator launch wrapper # and the CLI (--status); read-only data, no secrets. @@ -166,7 +166,7 @@ check_file /opt/ai-tools/.claude/settings.json root " # ai-tools is a group-writer for its own state but cannot unlink/replace the root-owned control # files above. Owned by ai-tools, or without the sticky bit, the agent could delete and recreate # them. The set of directories comes from the manifests (control-plane.lib.sh), so a second agent -# is covered here without editing this list; a host with none asserts nothing and says so. +# is covered here without editing this list; a host with none skips and says so. # The SHARED asset roots: base-owned, agent-readable, NOT agent-writable. Every agent symlinks # into them, so a writable root here would let one session rewrite the instructions -- or the # delegate definitions -- every agent and every future session reads. @@ -283,12 +283,12 @@ check_file /var/opt/ai-tools/README.md root check_file /var/opt/ai-tools/state root "${SANDBOX_GROUP}" 750 check_file_optional /var/opt/ai-tools/state/nvm-update.status "${SANDBOX_USER}" ai-ops 640 # The entrypoint pins. root:root and not group-writable, unlike the stamp beside them: a stamp -# reports and gates nothing, while a pin is what the launch compares the agent binary against, so +# reports and does not gate a launch, while a pin is what the launch compares the agent binary against, so # the account it constrains must not be able to write it. check_file /var/opt/ai-tools/state/entrypoint-pin.d root root 755 check_file /var/opt/ai-tools/state/entrypoint-label.d root root 755 # Sandbox-area operator ACL: ai-ops reaches the area without SANDBOX_GROUP membership -- traverse -# on the outer dir, rwX + default on sandbox-projects. The agent (not in ai-ops) gains nothing. +# on the outer dir, rwX + default on sandbox-projects. The agent (not in ai-ops) does not gain access. if ! command -v getfacl >/dev/null 2>&1; then skip "sandbox-area ai-ops ACL" "getfacl not available" elif getfacl -p /var/opt/ai-tools 2>/dev/null | grep -qE '^group:ai-ops:r-x' \ @@ -340,7 +340,7 @@ fi # env_keep surface: ai-tools-run re-validates AI_TOOLS_AGENT_EXEC/AI_TOOLS_PROJECT_DIR (ai-tools-run.sh test), # which is the real defense, but the drop-in's per-command env_keep should pass through ONLY # those two -- a widened list would smuggle attacker-influenced env into the launch path. Pin it: -# every env_keep in the file names exactly AI_TOOLS_AGENT_EXEC and AI_TOOLS_PROJECT_DIR, nothing else. +# every env_keep in the file names exactly AI_TOOLS_AGENT_EXEC and AI_TOOLS_PROJECT_DIR, and no other name. if [[ -r /etc/sudoers.d/ai-tools ]]; then ek_extra="$(grep -oE 'env_keep[[:space:]]*\+?=[[:space:]]*"[^"]*"' /etc/sudoers.d/ai-tools \ | grep -oE '"[^"]*"' | tr -d '"' | tr ' ' '\n' \ diff --git a/tests/integration/selinux.sh b/tests/integration/selinux.sh index 221b482b..ba1df581 100644 --- a/tests/integration/selinux.sh +++ b/tests/integration/selinux.sh @@ -7,7 +7,7 @@ # of "temporary debug" that never gets reverted -- would drop that boundary while every DAC test # stays green. This asserts the missing signal: when the ai_tools module is loaded the system is # Enforcing and neither domain is marked permissive, that each agent's declared entrypoint rule -# still covers what its package installed, and that the exec chain carries no type the confined +# still covers what its package installed, and that the exec chain does not carry a type the confined # domain may write. The confinement module is an OPTIONAL layer # (permissive-first bring-up, stock-box installs without it), so when it is not loaded the whole # file SKIPS -- it never demands SELinux on a host that does not ship the policy. Run as root. @@ -143,9 +143,9 @@ else fi fi -# (6) The label primitives on a sandbox clone, the branch that mutates no policy. +# (6) The label primitives on a sandbox clone, the branch that does not mutate policy. # relabel.lib.sh splits on _ai_tools_is_sandbox: a clone is covered by the STATIC ai_tools.fc -# rule, so the helper adds no per-path `semanage fcontext` entry and has none to remove. +# rule, so the helper does not add a per-path `semanage fcontext` entry and has none to remove. # ai_tools_label_project still verifies the achieved label rather than trusting restorecon's exit # status, so a mislabel is a hard failure -- the regression that let a usr_t clone report success. # After an unlabel a clone is still labelled, which is what keeps it reachable by the confined @@ -189,7 +189,7 @@ fi # leaves the entrypoint installed, unlabelled, and every launch fail-closing. This assertion turns # that into a test failure at the next suite run instead of a refused launch for an operator. # -# Read-only: it resolves and compares, and mutates no policy. +# Read-only: it resolves and compares, and does not mutate policy. section "SELinux: each enabled agent's declared entrypoint rule matches what is installed" if ! declare -F ai_tools_entrypoint_reconcile_verdict >/dev/null 2>&1 \ @@ -235,7 +235,7 @@ section "SELinux: the agent's exec chain carries no type the confined domain may readonly AI_TOOLS_MANAGED_TYPES="ai_tools_project_t ai_tools_home_t ai_tools_tmp_t" -# type_of <path> : PRINT the SELinux type, or nothing. +# type_of <path> : PRINT the SELinux type, or an empty string. type_of() { stat -c '%C' -- "$1" 2>/dev/null | awk -F: '{print $3}'; } if ! declare -F ai_tools_enabled_agents >/dev/null 2>&1; then diff --git a/tests/integration/stop.sh b/tests/integration/stop.sh index d9efa7c1..2bab0e66 100644 --- a/tests/integration/stop.sh +++ b/tests/integration/stop.sh @@ -5,7 +5,7 @@ # against REAL processes in a REAL cgroup, on the running kernel. # # WHAT THIS COVERS THAT THE UNIT TEST CANNOT. unit/stop.sh pins enumeration and liveness against a -# fixture tree of ordinary files; nothing there is a process and nothing is signalled. The property +# fixture tree of ordinary files; no entry there is a process and no process is signalled. The property # this helper exists for -- a stop that is reported as done HAS happened, including everything the # session spawned -- is a kernel property, and the three ways a child escapes a process-tree walk # (a plain fork, setsid(2), and the double fork that re-parents the child away from the session) @@ -86,8 +86,8 @@ if ! declare -F end_session >/dev/null 2>&1; then fi # Aim the walk at the fixture. The manager-service special case is pointed at a name that does not -# exist, so nothing in the fixture is descended-into rather than emitted. There is no exemption to -# neutralize: the helper spares no cgroup. +# exist, so no cgroup in the fixture is descended-into rather than emitted. There is no exemption to +# neutralize: the helper does not spare a cgroup. # shellcheck disable=SC2034 # all three are read by the sourced helper SANDBOX_SLICE="${FIXTURE_SLICE}" # shellcheck disable=SC2034 @@ -304,7 +304,7 @@ if grep -q "${TESTDIR}/project" <<< "${MAIN_OUTPUT}"; then else fail "no reclaim guidance for the stopped project: ${MAIN_OUTPUT}" fi -# The manager restore is part of the command, not an optional extra: the sweep spares nothing, so +# The manager restore is part of the command, not an optional extra: the sweep covers every cgroup, so # a run that kills without restoring leaves the host unable to start the next session. if [[ -e "${RESTORE_MARKER}" ]]; then pass "the run restores the user manager it necessarily terminated" diff --git a/tests/integration/symlink-helper.sh b/tests/integration/symlink-helper.sh index 7962fe18..feadcfa3 100644 --- a/tests/integration/symlink-helper.sh +++ b/tests/integration/symlink-helper.sh @@ -9,7 +9,7 @@ # manifest allowlist (without it, any binary sitting in a versioned bin/ could be given a stable # link in the control-plane directory). # -# Refusal cases touch nothing; the happy path targets the symlink's CURRENT target, so it is +# Refusal cases touch no path; the happy path targets the symlink's CURRENT target, so it is # idempotent -- and when no relabel is pending it skips the repoint entirely (reporting "already # current") rather than churning the link. Run as root via sudo. diff --git a/tests/integration/wrapper.sh b/tests/integration/wrapper.sh index c086dd66..ac4732f3 100644 --- a/tests/integration/wrapper.sh +++ b/tests/integration/wrapper.sh @@ -99,7 +99,7 @@ else fi # (1b) The print-and-exit pass-through: a SOLE --version from that same unapproved cwd is -# deliberately NOT gated -- it carries no project surface, so the wrapper launches the +# deliberately NOT gated -- it does not carry a project surface, so the wrapper launches the # confined session with the sandbox home as WorkingDirectory and claude prints its # version. Asserts the refusal is absent and a version string came back. pv_out="$( cd "${unapproved}" && setsid sudo -u "${PROJECTS_USER}" -- env HOME="${home}" \ @@ -139,9 +139,9 @@ fi # the wrapper honours a '!' CWD (2b above) -- so what this asserts is that they agree about # the same file: the CLI's own edit, read back by the deployed wrapper. # -# Driven through the CLI as the operator against this fixture registry, so nothing here +# Driven through the CLI as the operator against this fixture registry, so no step here # touches the operator's real one (see the note on the two lookup routes below). The pair -# edits one line of the caller's own allowlist and reaches no root helper, so there is no +# edits one line of the caller's own allowlist and does not reach a root helper, so there is no # password prompt. cli=/usr/local/bin/ai-tools if [[ ! -x "${cli}" ]]; then @@ -150,7 +150,7 @@ else # The two readers reach the same file by DIFFERENT routes, and a test that steers only one of # them silently drives the operator's real registry: the wrapper keys its allowlist off # ${HOME}, while the CLI resolves the invoking user's home through `getent passwd` -- on - # purpose, so nothing in the environment can redirect a registry write. So the CLI is pointed + # purpose, so no environment variable can redirect a registry write. So the CLI is pointed # at the fixture with AI_TOOLS_ALLOWLIST, the root-only hook the rest of the suite uses, and # HOME is kept as well so both agree on the file. fixture_allowlist="${home}/.config/ai-tools/allowed-projects" diff --git a/tests/lib/harness.sh b/tests/lib/harness.sh index 42c5fdf7..daf311c3 100644 --- a/tests/lib/harness.sh +++ b/tests/lib/harness.sh @@ -93,8 +93,8 @@ require_root() { # root: under sudo it is the operator who invoked it; run DIRECTLY as an unprivileged user (which # the pure library suites support -- they stub what they drive and build fixtures they own) the # invoker is that user; run as root with no sudo context there is no unprivileged identity to -# derive and nothing to guess from, so refuse -- fixtures would be built root-owned and every -# owner guard under test would skip them, passing the suite while proving nothing. +# derive and no default to guess from, so refuse -- fixtures would be built root-owned and every +# owner guard under test would skip them, passing the suite while proving no property. if [[ -n "${SUDO_USER:-}" ]]; then PROJECTS_USER="${SUDO_USER}" elif [[ "${EUID}" -ne 0 ]]; then @@ -140,7 +140,7 @@ trap _teardown EXIT # root-only hook, exactly like AI_TOOLS_ALLOWLIST / AI_TOOLS_OPERATOR_CONF: sudo strips it # and the live handback daemon execs helpers with its own environment, so only a root # caller execing a helper directly (this suite) redirects it. The journald sink still -# carries every line under its per-component tag, so nothing is lost. A helper the LIVE +# carries every line under its per-component tag, so no line is lost. A helper the LIVE # daemon execs (integration/handback.sh) keeps the real dir -- the daemon does not inherit # this -- matching the AI_TOOLS_ALLOWLIST limitation. Registered for teardown. _test_logdir="$(mktemp -d /tmp/ai-tools-testlog.XXXXXX)" diff --git a/tests/manual/verify-live-flows.sh b/tests/manual/verify-live-flows.sh index aea304bf..d62d5ef3 100755 --- a/tests/manual/verify-live-flows.sh +++ b/tests/manual/verify-live-flows.sh @@ -20,7 +20,7 @@ # It never adopts anything that already exists. Every path is one it created inside that fresh # workspace, and removal refuses any path that is not inside it, so there is no input -- a stale # directory, an unset variable, a symlink swapped in -- that can point the cleanup at something -# else. It runs no `sudo rm`: nothing it does needs root to undo. +# else. It runs no `sudo rm`: no step it takes needs root to undo. # # The single exception is --for-drill, which is opt-in for exactly that reason: it creates one # project in the shared clone area, owned by another operator, and deletes it again through @@ -139,7 +139,7 @@ readonly OUTSIDE="${WORKSPACE}/hardlink-target" # safe_rm <path>: remove a path ONLY if every rail holds -- non-empty, absolute, strictly inside # the workspace mktemp created for this run, and not a symlink (which rm -r would follow into by -# name if the path were swapped). Unprivileged, always: nothing here may need root to undo, and a +# name if the path were swapped). Unprivileged, always: no step here may need root to undo, and a # `sudo rm -rf` in a cleanup path is exactly the shape of accident this guards against. A path it # refuses is reported for the operator to look at, never forced. safe_rm() { @@ -212,8 +212,8 @@ trap cleanup EXIT # # EVERY MODE IS PINNED, none inherited from the invoking shell's umask. Under a umask of 077 the # project ROOT is born 700 -- owner-only -- and the claim then correctly seals the whole tree and -# grants nothing, so the ordinary half of this fixture (the half that must be opened up) silently -# stops existing and every later check has nothing to act on. What the fixture is FOR is the +# does not grant access, so the ordinary half of this fixture (the half that must be opened up) silently +# stops existing and every later check has no path to act on. What the fixture is FOR is the # contrast between a sealed path and an ordinary one, so both sides are stated outright. section "Fixture" mkdir "${PROJ}" # fails if it exists; it cannot, mktemp just made ${WORKSPACE} @@ -239,7 +239,7 @@ note "modes pinned: project 755, src 755, files 644; prod-files 700 holding a 60 # A sealed directory whose setgid belongs to a THIRD group -- the one piece of residue a claim # keeps rather than strips, so the Review block has to say so. "Third" is relative to the claim's # rule (neither the sandbox group nor the owner's own primary group), so one of the operator's own -# SECONDARY groups qualifies -- and chgrp to a group you are in needs no privilege, which keeps +# SECONDARY groups qualifies -- and chgrp to a group you are in does not need privilege, which keeps # this script's own sudo use at zero. Without such a group the case is skipped, never faked with # root. THIRD_GROUP="" @@ -261,7 +261,7 @@ note "fixture built" # ── 1. claim ───────────────────────────────────────────────────────────────────────────────── # AI_TOOLS_ASSUME_YES answers the default-YES questions (secret lockdown, .git normalization); # -y answers the claim's own default-NO proceed prompt. The reachability opt-in is default-NO and -# stays declined, which is right here: nothing launches a session. +# stays declined, which is right here: no step launches a session. section "1. ai-tools --project-claim" sudo_why "the claim's root steps: the secret scan and lockdown, then group+setgid, the ACL walk, and the SELinux label" CLAIM_OUT="$(AI_TOOLS_ASSUME_YES=1 "${CLI}" --project-claim -y "${PROJ}" 2>&1)"; CLAIM_RC=$? @@ -335,7 +335,7 @@ BEFORE="$(mode_of "${SEALED_SBX}") $(group_of "${SEALED_SBX}")" BEFORE_ACL="$(getfacl -c -- "${SEALED_SBX}" 2>/dev/null | tr '\n' ',')" BEFORE_OWN="$(mode_of "${SEALED_OWN}") $(group_of "${SEALED_OWN}")" note "sealed fixtures: ${BEFORE} (inherited group + default ACL), ${BEFORE_OWN} (setgid)" -# The residue has to actually be there, or "nothing was stripped" proves nothing about stripping. +# The residue has to actually be there, or "nothing was stripped" does not prove a property of stripping. prose-check: allow [[ "${BEFORE}" == "700 ${SANDBOX_GROUP}" ]] \ || fail "sandbox-grouped seal fixture is '${BEFORE}', want '700 ${SANDBOX_GROUP}' -- the checks below cannot mean anything" [[ "${BEFORE_OWN}" == "2700 ${MY_GROUP}" ]] \ @@ -380,7 +380,7 @@ check " and leaves that dir's group alone (it was never the agent's)" \ fi # ── 2b. disable / enable: the park-and-restore round trip, on the REAL registry ─────────────── -# Runs while the project is still claimed, and needs no sudo at all: the pair edits one line of +# Runs while the project is still claimed, and does not need sudo at all: the pair edits one line of # this operator's own allowlist. That is exactly why it belongs in a live run rather than only in # the hermetic suites -- what it proves is that the edit lands in the FILE the launch gate reads, # at the position the operator left it, and comes back byte-identical. @@ -577,7 +577,7 @@ fi # ── 4. unclaim --force on the unregistered copy ────────────────────────────────────────────── section "4. ai-tools --project-unclaim --force (unregistered copy)" # --force acts on the ai-tools fingerprint, so the copy must carry one. Establish that FIRST: -# without it the command correctly refuses ("nothing to unclaim here"), and asserting anything +# without it the command correctly refuses ("nothing to unclaim here"), and asserting anything prose-check: allow # past that point measures the fixture, not the flag. The two checks after the apply would even # PASS on such a copy -- no abort, group already the operator's -- which is the worst outcome a # check can have. @@ -603,7 +603,7 @@ else check "the copy is normalized to ${MY_GROUP}" test "$(group_of "${COPY}/src")" = "${MY_GROUP}" fi -# ── 5. --sandbox-create flag validation (parses only; nothing is created) ──────────────────── +# ── 5. --sandbox-create flag validation (parses only; no clone is created) ──────────────────── section "5. ai-tools --sandbox-create flag validation" for flag in --from --branch --dir; do OUT="$("${CLI}" --sandbox-create "${flag}" -oops 2>&1)"; RC=$? @@ -755,7 +755,7 @@ else TASKS_BEFORE="$(session_task_count)" note "sandbox slice holds ${TASKS_BEFORE} task(s) outside the user manager's init.scope" - # The dry run changes nothing, and says so. Safe whether or not a session is running. + # The dry run does not change a path, and says so. Safe whether or not a session is running. sudo_why "--stop --dry-run enumerates the sandbox account's cgroups" OUT="$("${CLI}" --stop --dry-run 2>&1)"; RC=$? printf '%s\n' "${OUT}" | sed 's/^/ /' diff --git a/tests/run.sh b/tests/run.sh index 05bfedc5..aa62f18c 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -3,7 +3,7 @@ # tests/run.sh [unit|integration|boundary|all] # Test dispatcher. Runs the chosen category's test files and aggregates pass/fail by exit # status. On any failure it reprints the failing files' FAIL lines as an end-of-run summary, -# so a long run needs no scrolling; an all-green run prints no summary. Run via sudo: every +# so a long run does not need scrolling; an all-green run omits the summary. Run via sudo: every # category needs root (unit/integration set arbitrary ownership and run the deployed helpers; # boundary drops to the agent via `sudo -u`). # @@ -110,7 +110,7 @@ if [[ "${rc}" -ne 0 ]]; then cat "${_summary}" fi -# No-coverage notice: green-by-status files that proved nothing, and empty categories. +# No-coverage notice: green-by-status files with no assertion behind them, and empty categories. # Lenient by default; AI_TOOLS_TEST_STRICT=1 (the full-install CI gate) fails the run, so a # broken prerequisite cannot hide behind skips. if [[ ${#_nocoverage[@]} -gt 0 ]]; then diff --git a/tests/unit/admin-operator-add.sh b/tests/unit/admin-operator-add.sh index b8565bd3..4499cb3c 100644 --- a/tests/unit/admin-operator-add.sh +++ b/tests/unit/admin-operator-add.sh @@ -16,7 +16,7 @@ # readonly. sudo is stubbed as a shell FUNCTION, which overrides the PATH lookup, so no executable # shim is needed (and the test works where /tmp is noexec) and no real sudoers is consulted. The # helper is SOURCED, not run: its root check and its dispatch are guarded for exactly this, so one -# function is driven with no host to administer and nothing written anywhere. +# function is driven with no host to administer and no state written anywhere. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -62,7 +62,7 @@ if [[ "${out}" == *"NO SUCH FUNCTION"* ]]; then finish; exit fi -# 1. The grant is there: sudo answers for the claim helper, and the account needs nothing further. +# 1. The grant is there: sudo answers for the claim helper, and the account does not need a further step. if [[ "${out}" == *"holds a general sudo grant"* && "${out}" != *"--for"* ]]; then pass "a listed claim helper reports the grant, with no --for advice" else @@ -88,7 +88,7 @@ else fail "an unanswering sudo must not read as a missing grant, got: ${out}" fi -# 4. No sudo at all: nothing on the host can claim, which is a statement about the host rather than +# 4. No sudo at all: no account on the host can claim, which is a statement about the host rather than # about this account, and the enrolment it just did still stands. out="$(bash -c ' set -euo pipefail @@ -148,8 +148,8 @@ else fail "the created .bash_profile left a login shell without .bashrc (marker '${marker}')" fi -# An init file the operator already has is appended to, never replaced, and a second run adds -# nothing: `operator add` is accumulating and idempotent, and this runs on every re-enrolment. +# An init file the operator already has is appended to, never replaced, and a second run leaves +# the file as it found it: `operator add` is accumulating and idempotent, and this runs on every re-enrolment. # shellcheck disable=SC2016 # the fixture's ${HOME} is init-file text, expanded by the shell reading it printf '# my own bashrc\nexport NVM_DIR="${HOME}/.nvm"\n' > "${TESTDIR}/.bashrc" wire_file "${TESTDIR}/.bashrc" >/dev/null diff --git a/tests/unit/allowlist-helper.sh b/tests/unit/allowlist-helper.sh index 4245488a..8d34624d 100644 --- a/tests/unit/allowlist-helper.sh +++ b/tests/unit/allowlist-helper.sh @@ -81,7 +81,7 @@ refuses() { fi } -# (1) No sudo context. A direct root call carries no operator identity, so there is nobody to +# (1) No sudo context. A direct root call does not carry an operator identity, so there is nobody to # authorize the edit; defaulting to some operator is exactly the fail-open this refuses. refuses "refuses a bare root call (no SUDO_UID)" "no SUDO_UID" \ "" --operator "${PROJECTS_USER}" --add "${proj}" @@ -91,7 +91,7 @@ refuses "refuses a caller that is not a configured operator" "not a configured a 0 --operator "${PROJECTS_USER}" --add "${proj}" # (3) The target must be enrolled: ai-tools-setfacl and the handback helpers resolve a path's owner -# over OPERATORS, so an entry for an unenrolled name is a launch gate nothing can act on. +# over OPERATORS, so an entry for an unenrolled name is a launch gate no helper can act on. refuses "refuses an unenrolled target operator" "not a configured ai-tools operator" \ "${OPERATOR_UID}" --operator "definitely-not-an-operator" --add "${proj}" @@ -210,7 +210,7 @@ else fi # Neither verb may INVENT an entry: registering a project is a claim, which scans for secrets -# before granting access. --enable reports and succeeds (nothing to lift); --disable refuses, +# before granting access. --enable reports and succeeds (no exclusion to lift); --disable refuses, # since a caller asking to park an unregistered path has the wrong path or the wrong verb. # Compared whole-file, not by substring: every fixture entry lives UNDER ${TESTDIR}, so a # substring test matches the line that is legitimately there and inverts the assertion. diff --git a/tests/unit/check-version.sh b/tests/unit/check-version.sh index 97bb71a2..d62080d7 100644 --- a/tests/unit/check-version.sh +++ b/tests/unit/check-version.sh @@ -7,7 +7,7 @@ # and a final vX.Y.Z tag require the three-way match, a vX.Y.Z-rc.N tag compares its base # and relaxes only the %changelog match (surfacing a note), any other dashed tag is refused, # and a missing %changelog entry stays fatal for every form. Exercises the repo's own copy -# (the script is not a deployed artifact); needs no privilege beyond the suite contract. +# (the script is not a deployed artifact); does not need privilege beyond the suite contract. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" diff --git a/tests/unit/claude-endpoint.sh b/tests/unit/claude-endpoint.sh index 9687019a..89b7f03a 100644 --- a/tests/unit/claude-endpoint.sh +++ b/tests/unit/claude-endpoint.sh @@ -67,7 +67,7 @@ expect() { # <desc> <want-ret> <want-args-substr-or-empty> [want-err-substr] _reset; : > "${conf}"; _resolve expect "unconfigured -> no args" 0 "" -# 2) Inert endpoint file (nothing uncommented) -> no injection, launch. +# 2) Inert endpoint file (no option uncommented) -> no injection, launch. _reset; _resolve expect "inert file -> no args" 0 "" diff --git a/tests/unit/claude-prompt.sh b/tests/unit/claude-prompt.sh index 9666fe8b..f3eda3b9 100644 --- a/tests/unit/claude-prompt.sh +++ b/tests/unit/claude-prompt.sh @@ -5,7 +5,7 @@ # claude.sh applies before it execs a session. The guarantee under test is one instance of "the # sandbox cannot widen its own surface": a prompt file the sandbox account could influence, or a # configured prompt that cannot be honoured, must NOT be silently passed to Claude Code -- it either -# yields no injection (unconfigured) or REFUSES the launch (configured-but-invalid), never a +# leaves the prompt empty (unconfigured) or REFUSES the launch (configured-but-invalid), never a # fall-back to a prompt the operator did not set. This drives the resolver into each bad state and # asserts it moves to no-injection or a refusal, never to injecting an untrusted or wrong prompt. # The agent-side half (the files are not agent-writable) lives in tests/boundary/access.sh. @@ -73,7 +73,7 @@ expect "append default -> --append-system-prompt-file" 0 "--append-system-prompt _reset; _cfg "CLAUDE_SYSTEM_PROMPT_FILE=${prompt}" "CLAUDE_SYSTEM_PROMPT_MODE=replace"; _resolve expect "replace -> --system-prompt-file" 0 "--system-prompt-file ${prompt}" -# 4) An empty inert file is valid (the shipped default): append adds nothing but still applies. +# 4) An empty inert file is valid (the shipped default): append contributes an empty string but still applies. _reset; : > "${prompt}"; _cfg "CLAUDE_SYSTEM_PROMPT_FILE=${prompt}"; _resolve expect "empty inert file -> append" 0 "--append-system-prompt-file ${prompt}" @@ -98,7 +98,7 @@ expect "missing file -> refuse" 1 "" _reset; printf '\x00\x01\x02ELF\x00' > "${prompt}"; _cfg "CLAUDE_SYSTEM_PROMPT_FILE=${prompt}"; _resolve expect "binary file -> refuse" 1 "" "not a text file" -# 10) Flag smuggling via the path value: the value is a single argument to realpath, so it names no +# 10) Flag smuggling via the path value: the value is a single argument to realpath, so it does not name a # file and is refused -- it can never split into a second CLI flag. _reset; _cfg "CLAUDE_SYSTEM_PROMPT_FILE=${prompt} --dangerous-flag"; _resolve expect "flag smuggle in value -> refuse, no extra arg" 1 "" diff --git a/tests/unit/cli-verbs.sh b/tests/unit/cli-verbs.sh index cb2b2af0..1b52eade 100644 --- a/tests/unit/cli-verbs.sh +++ b/tests/unit/cli-verbs.sh @@ -10,7 +10,7 @@ # FOR_ALLOWED_VERBS does --for apply -- elsewhere the flag is refused, not ignored # # The failure this exists for is silent and one-directional: a verb ADDED to the dispatcher and -# forgotten in OPERATOR_VERBS is one an unenrolled user runs, and nothing at runtime says so -- +# forgotten in OPERATOR_VERBS is one an unenrolled user runs, and no runtime message says so -- # the verb simply works, until a root helper refuses it midway. The reverse (a table naming a verb # the dispatcher no longer has) is dead configuration that reads as coverage. So membership is # asserted in both directions, and every dispatched verb must be classified one way or the other: @@ -34,7 +34,7 @@ if [[ ! -r "${CLI}" ]]; then fi # INFORMATIONAL -- the verbs deliberately open to any caller: they read, or (in --stop's case) act -# through a helper that requires root anyway and takes no operator-owned state. This list is the +# through a helper that requires root anyway and does not take operator-owned state. This list is the # test's half of the contract, so a verb added to neither this nor OPERATOR_VERBS fails below with # the choice spelled out. readonly INFORMATIONAL=(--help --version --list --providers --status --audit --stop) diff --git a/tests/unit/confinement.sh b/tests/unit/confinement.sh index e96ccceb..190ea06d 100644 --- a/tests/unit/confinement.sh +++ b/tests/unit/confinement.sh @@ -6,7 +6,7 @@ # truth table over the four probed inputs -- getenforce, the matchpathcon-expected label, the # live label, the manager domain -- with no SELinux host required, so a regression in the gate # (an inverted condition, a swallowed refusal) fails here rather than reaching production as an -# UNCONFINED launch. Sources the deployed library; needs no privilege of its own. Run as root via +# UNCONFINED launch. Sources the deployed library; does not need privilege of its own. Run as root via # sudo (suite contract). set -euo pipefail diff --git a/tests/unit/entrypoint-verify.sh b/tests/unit/entrypoint-verify.sh index a3aff4e9..e981085d 100644 --- a/tests/unit/entrypoint-verify.sh +++ b/tests/unit/entrypoint-verify.sh @@ -19,7 +19,7 @@ # # The impure half (ai_tools_entrypoint_release_verify) needs the vendor's live endpoint, gpgv, and # a 300 MB hash, so it is not driven here; its status contract is exercised where it is wired in. -# Run as root via sudo (the suite's convention), though nothing here needs it. +# Run as root via sudo (the suite's convention), though no case here needs it. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -225,7 +225,7 @@ else fail "the label record did not read back as RESULT=failed with its reason and a LABELLED age" fi - # A reason is a token, never prose: the accessors' charset clamp admits no spaces, so a value + # A reason is a token, never prose: the accessors' charset clamp does not admit spaces, so a value # carrying any would read as absent and the record would lose the field silently. It is dropped # at write time instead, leaving a record whose every field can be read back. if ai_tools_entrypoint_label_write claude-code failed "rule not registered; id" \ @@ -238,14 +238,14 @@ fi # ── Pin reuse: answering from the pin instead of refetching the signed manifest ─────────────── # The unattended callers (the relabel watcher, the agent package's %post) may skip the fetch and -# the gpgv when nothing that decides the verdict has changed. Every assertion below targets a way +# the gpgv when no input that decides the verdict has changed. Every assertion below targets a way # that shortcut could answer a question it was not asked -- which is the only way it can fail # open, since a reused verdict is indistinguishable from a fresh one to everything downstream. section "entrypoint-verify: pin reuse (unit)" # Asserted only when the deployed library actually carries the predicate. Without this guard an # absent function exits 127, which every negative case below would read as a correct refusal -- -# the section would report green while testing nothing at all. +# the section would report green while testing no behaviour at all. if ! declare -F ai_tools_entrypoint_pin_reusable >/dev/null 2>&1 \ || ! declare -F ai_tools_entrypoint_inputs_digest >/dev/null 2>&1; then skip "pin reuse" "the installed ${LIB} carries no pin-reuse predicate -- reinstall to cover it" diff --git a/tests/unit/install-paths.sh b/tests/unit/install-paths.sh index 4f36e9e1..c9ce007f 100644 --- a/tests/unit/install-paths.sh +++ b/tests/unit/install-paths.sh @@ -15,7 +15,7 @@ # A path carrying a shell variable (a loop over asset kinds) cannot be resolved statically; its # longest literal prefix directory is checked instead, which still catches a whole tree moving. # -# Pure text + filesystem: no root, no install, nothing executed. Validates the repo sources; it +# Pure text + filesystem: no root, no install, no command executed. Validates the repo sources; it # skips outside a checkout, where there is no src/ to compare against. set -euo pipefail diff --git a/tests/unit/lockdown.sh b/tests/unit/lockdown.sh index 9b3352db..f1abc0eb 100644 --- a/tests/unit/lockdown.sh +++ b/tests/unit/lockdown.sh @@ -71,7 +71,7 @@ run_ld() { # <cwd> <outfile> [args...] ( cd "${cwd}" && "${HELPER}" "$@" ) < /dev/null > "${out}" 2>&1 && LD_RC=0 || LD_RC=$? } -# (1) Dry-run reports the secret but changes nothing. +# (1) Dry-run reports the secret but does not change a path. out="${TESTDIR}/dry" run_ld "${proj}" "${out}" --dry-run if [[ "$(stat -c '%U:%G' "${proj}/.env")" == "${PROJECTS_USER}:${PROJECTS_GROUP}" && "$(perm "${proj}/.env")" == 644 ]] \ @@ -105,7 +105,7 @@ if [[ "${LD_RC}" -ne 0 ]]; then fi # (2a) Secret file -> <you>:<you> 600. The owner's OWN group, not the sandbox group: at 600 the -# group grants nothing either way, but leaving it as SANDBOX_GROUP would hand the file back +# group does not grant access either way, but leaving it as SANDBOX_GROUP would hand the file back # to the agent the moment the mode was widened. Same target ai-tools-chown uses on write. if [[ "$(stat -c '%U:%G' "${proj}/.env")" == "${PROJECTS_USER}:${PROJECTS_GROUP}" && "$(perm "${proj}/.env")" == 600 ]]; then pass "secret file -> ${PROJECTS_USER}:${PROJECTS_GROUP} 600 (agent read revoked)" @@ -120,7 +120,7 @@ else fail "secret dir ended $(stat -c '%U:%G' "${proj}/secrets") $(perm "${proj}/secrets")" fi -# (2c) A locked secret keeps no sandbox ACL entry: chmod 600 masks the inherited entry but does +# (2c) A locked secret does not keep a sandbox ACL entry: chmod 600 masks the inherited entry but does # not remove it, so widening the mode later would re-expose the secret. if ${residue_acl}; then if getfacl -c -- "${proj}/residue.key" 2>/dev/null | grep -q "^group:${SANDBOX_GROUP}:"; then diff --git a/tests/unit/log.sh b/tests/unit/log.sh index 31c4d802..f1209590 100644 --- a/tests/unit/log.sh +++ b/tests/unit/log.sh @@ -11,7 +11,7 @@ # is passed through unchanged (no false positives on ordinary paths). The daemon is exercised # on the same bytes so the two trails share one contract. The deferred control/bidi *detector* # (retained, unused) is pinned lightly so it does not rot before the quarantine sink is built. -# Run as root via sudo (the suite contract); needs no privilege of its own. +# Run as root via sudo (the suite contract); does not need privilege of its own. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -38,7 +38,7 @@ is_printable_ascii() { local LC_ALL=C; [[ "$1" != *[^[:print:]]* ]]; } # on a regression, still hold the very control/bidi byte the sanitizer was meant to remove; # printing it straight to stderr (which run.sh tees to a terminal) would re-introduce the # terminal injection this test exists to prevent -- and `printf %q` still passes a printable -# bidi code point through raw. od is safe and diagnostic, trusting nothing under test. +# bidi code point through raw. od is safe and diagnostic, trusting no value under test. hx() { printf '%s' "$1" | od -An -tx1 | tr -s ' \n' ' '; } # Vectors from raw bytes so construction is locale-independent. DANGER mixes ASCII controls, diff --git a/tests/unit/man.sh b/tests/unit/man.sh index 705a9992..2fae3f24 100644 --- a/tests/unit/man.sh +++ b/tests/unit/man.sh @@ -231,7 +231,7 @@ check_admin_page() { # splits its dispatch across nested `case` statements -- one per domain and collection -- so # a whole path never appears in a single arm. Each TOKEN of a documented path must therefore # be an arm somewhere in the helper, which catches the rename (`postupgrade` -> `post-upgrade` - # leaves the old token matching nothing) without asserting where in the nesting it sits. + # leaves the old token matching no heading) without asserting where in the nesting it sits. arms="$(grep -oE '^[[:space:]]+[a-z][a-z0-9-]*\)' "${ADMIN}" | tr -d ' )' | sort -u)" if [[ -z "${arms}" || -z "${man_cmds}" ]]; then fail "could not extract the dispatch arms or the man command set" diff --git a/tests/unit/managed-assets.sh b/tests/unit/managed-assets.sh index e3b83354..1b4f5195 100644 --- a/tests/unit/managed-assets.sh +++ b/tests/unit/managed-assets.sh @@ -27,7 +27,7 @@ # counterpart left to compare an operator's edit against. # # Drives the INSTALLED library against fixtures in its own /tmp testdir: every root is an argument, -# so nothing reads or writes /usr/share/ai-tools, /opt/ai-tools, or any live asset. Needs root -- +# so no case reads or writes /usr/share/ai-tools, /opt/ai-tools, or any live asset. Needs root -- # the seeder chowns what it places and the withdrawal creates a 0700 root:root directory. set -euo pipefail diff --git a/tests/unit/msg.sh b/tests/unit/msg.sh index b6a1ede1..b75280bc 100644 --- a/tests/unit/msg.sh +++ b/tests/unit/msg.sh @@ -10,7 +10,7 @@ # NOTHING on stdout and a non-zero status on every path that fails to get an index (no # terminal, closed input, three unanswered attempts), or a caller would read an unanswered # menu as a chosen option. Pure formatting -- no root, no install dependency: the -# library carries no token substitution, so the repo source IS the deployed artifact. The +# library does not carry a token substitution, so the repo source IS the deployed artifact. The # test validates the source of truth directly (so it never reports a false failure against a # not-yet-redeployed installed copy), falling back to the installed path outside a checkout. @@ -167,7 +167,7 @@ fi # valid index must yield NOTHING on stdout and a non-zero status, so a caller cannot mistake # an unanswered menu for a chosen option (the claude wrapper reads that as Cancel). -# (12b) No terminal: non-zero, nothing on stdout -- the caller decides, the lib does not. +# (12b) No terminal: non-zero, empty stdout -- the caller decides, the lib does not. nd_rc=0 nd_sel="$(setsid bash -c 'source "'"${LIB}"'"; ai_tools_msg_pick none a b c' </dev/null 2>/dev/null)" \ || nd_rc=$? diff --git a/tests/unit/npm-verify.sh b/tests/unit/npm-verify.sh index b2ac5390..2bb06db3 100644 --- a/tests/unit/npm-verify.sh +++ b/tests/unit/npm-verify.sh @@ -4,7 +4,7 @@ # Unit test for the npm signature verifier (npm-verify.lib.sh). Drives the PURE decision # ai_tools_npm_verdict over a truth table of `npm audit signatures --json` shapes -- the # fail-closed contract nvm-update.sh and ai-tools-bootstrap gate the stable-launcher repoint -# on. The pure verdict touches no npm, no filesystem, and no privilege, so this runs with no +# on. The pure verdict touches neither npm nor the filesystem, and does not need privilege, so this runs with no # registry, no network, and no root risk: a regression in the verdict (a tamper read as # "unable to verify", an inverted gate, a format change read as a false OK) fails here. # @@ -12,7 +12,7 @@ # that function operates on the SANDBOX-owned (agent-writable) global npm tree and must run as # the sandbox account, never root -- and this suite runs as root. Instead it asserts the # function's fail-closed root-refusal backstop (as root it returns "unable to verify" and -# touches nothing). The real end-to-end audit is covered as the sandbox account, out of this +# does not touch a path). The real end-to-end audit is covered as the sandbox account, out of this # root-run unit suite. `node` (the pure verdict's JSON parser) is real, resolved from the sandbox # toolchain rather than from PATH -- see toolchain_node. Run as root via sudo. @@ -22,7 +22,7 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" readonly LIB="/usr/local/lib/ai-tools/npm-verify.lib.sh" section "npm-verify: signature-verification verdict truth table (unit)" -# toolchain_node: PRINT the path to the sandbox toolchain's node, or nothing. node is the pure +# toolchain_node: PRINT the path to the sandbox toolchain's node, or an empty string. node is the pure # verdict's JSON parser but it lives ONLY in the sandbox account's nvm tree, never on root's PATH -- # and this suite runs as root, so resolving it from PATH alone skips the whole file on a fully # provisioned host and strict mode then flags it as no coverage. Resolve it the way the launch diff --git a/tests/unit/owner-only.sh b/tests/unit/owner-only.sh index 9cde9454..d5a70d13 100644 --- a/tests/unit/owner-only.sh +++ b/tests/unit/owner-only.sh @@ -12,7 +12,7 @@ # # The strip also answers ai-tools-lockdown's --dry-run, so one section pins the two properties # that makes it worth doing here rather than in a caller: the preview names exactly what the -# apply removes, and it touches nothing. +# apply removes, and it does not touch a path. # # It also pins the three platform behaviours the design rests on (see "platform assumptions"), # so a change in coreutils/acl semantics fails here rather than silently unsealing trees. @@ -201,7 +201,7 @@ strip "${TESTDIR}/f2" "regular file" "${SBX}" "${PROJECTS_GROUP}" && pass "a file's setgid bit is left untouched (an sgid binary is not silently altered)" \ || fail "a file's setgid bit was cleared: $(stat -c '%a' "${TESTDIR}/f2")" -# ── idempotence: a clean sealed path has nothing to strip ───────────────────────────────── +# ── idempotence: a clean sealed path has no residue to strip ───────────────────────────────── strip "${TESTDIR}/d" directory "${PROJECTS_GROUP}" "${PROJECTS_GROUP}" [[ "${STRIP_RC}" -eq 1 ]] \ && pass "a already-stripped path reports nothing to do (idempotent, silent on re-runs)" \ diff --git a/tests/unit/postupgrade.sh b/tests/unit/postupgrade.sh index 66350b50..3678ecd2 100644 --- a/tests/unit/postupgrade.sh +++ b/tests/unit/postupgrade.sh @@ -131,8 +131,8 @@ else fail "dropped a .rpmnew that still had a difference to review" fi -# The command claims to be idempotent, and an operator re-runs it: a second pass merges nothing -# and writes no second backup. +# The command claims to be idempotent, and an operator re-runs it: a second pass does not merge a declaration +# and does not write a second backup. out="$(run_pu)" shopt -s nullglob baks_again=( "${SETTINGS}".*.bak ) @@ -156,7 +156,7 @@ else fail "a current file was rewritten, backed up, or lost its .rpmnew" fi -# ── (D) The cleanup prompt defaults to yes only once nothing is left ───────────────────────── +# ── (D) The cleanup prompt defaults to yes only once no difference is left ───────────────────────── reset_root jq . "${SHIPPED_SETTINGS}" > "${SETTINGS}.rpmnew" cp "${SETTINGS}.rpmnew" "${TESTDIR}/canonical.json" diff --git a/tests/unit/providers.sh b/tests/unit/providers.sh index fd0cd9a6..b94da5e5 100644 --- a/tests/unit/providers.sh +++ b/tests/unit/providers.sh @@ -121,7 +121,7 @@ fi # --- Manifest field accessor: what ai-tools-run reads once it has resolved an agent ----------- # The name becomes a path, so it is allowlisted to plain identifiers: anything else must resolve -# nothing rather than address a file outside the manifest directory. +# an empty result rather than address a file outside the manifest directory. printf 'npm_package=@anthropic-ai/claude-code\nlauncher=claude\ndisplay_name=Claude Code\ndefault_enable=yes\n' \ > "${agents_dir}/claude-code.conf" if [[ "$(ai_tools_agent_manifest_field claude-code display_name || true)" == "Claude Code" ]]; then diff --git a/tests/unit/reclaim.sh b/tests/unit/reclaim.sh index 11bb2035..99a8be7e 100644 --- a/tests/unit/reclaim.sh +++ b/tests/unit/reclaim.sh @@ -4,7 +4,7 @@ # Hermetic unit tests for the deployed ai-tools-reclaim helper: it hands agent-owned files under a # project back to the operator via ai-tools-chown, including the .git tree the sweeps skip, while # leaving the heavy/transient trees (node_modules, ...) agent-owned -- and --full reclaims those -# too. Runs the installed helper against a /tmp testdir + dummy allowlist; writes nothing outside. +# too. Runs the installed helper against a /tmp testdir + dummy allowlist; does not write a path outside. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -29,7 +29,7 @@ nm="${proj}/node_modules/pkg/i.js"; : > "${nm}" own() { stat -c '%U' "$1" 2>/dev/null; } -# (0) Two-phase, empty set: nothing agent-owned yet (the fixtures are root-owned), so +# (0) Two-phase, empty set: no path agent-owned yet (the fixtures are root-owned), so # the collect phase reports exactly that and stops before any confirmation or change. noop_out="$(setsid "${HELPER}" "${proj}" < /dev/null 2>&1 > /dev/null || true)" if grep -qF "nothing to reclaim" <<<"${noop_out}"; then diff --git a/tests/unit/relabel.sh b/tests/unit/relabel.sh index e7781bed..2ad6482d 100644 --- a/tests/unit/relabel.sh +++ b/tests/unit/relabel.sh @@ -68,7 +68,7 @@ fi # ── Reconciling the declared rule against the INSTALLED entrypoint ──────────────────────────── # The label is applied from the manifest's declared pattern, but the SELinux transition fires on -# the inode the launcher symlink resolves to -- so the two can disagree, and did nothing about it +# the inode the launcher symlink resolves to -- so the two can disagree, and this file covered neither side of it # the relabel would report success while every launch fail-closed on an unlabelled entrypoint. # ai_tools_entrypoint_reconcile_verdict is the pure decision that closes that: `stale` is the # verdict that must make a relabel FAIL, because it is the one cause a rerun cannot clear. Pinned @@ -140,7 +140,7 @@ fi # (file_contexts.subs_dist `/var/opt /opt`), or a module not loaded -- is a hard failure instead # of a false success. This pins the predicate that gate rests on. A genuinely-labelled path needs # an enforcing SELinux host, so the positive case (label applies AND verifies) lives in -# integration/selinux.sh; here the negative is hermetic -- a plain /tmp dir carries no project +# integration/selinux.sh; here the negative is hermetic -- a plain /tmp dir does not carry a project # type on any host, SELinux or not, so the predicate must report false for it. section "relabel: project-label verification predicate (unit)" if declare -F ai_tools_project_labelled >/dev/null 2>&1; then @@ -162,7 +162,7 @@ fi # ── Reporting WHY a file-context rule was refused ───────────────────────────────────────────── # semanage's stderr is the only account of why a rule did not land, and "could not register its -# entrypoint file-context rule" names no cause on its own -- an operator reading it has nothing to +# entrypoint file-context rule" does not name a cause on its own -- an operator reading it has no next step to # act on, and the condition (a policy store another transaction holds, a type the loaded policy # does not define) needs different remedies. So the reason is collected for the caller to log. # The stream split is the load-bearing part: the caller parses this library's STDOUT as verdict @@ -339,7 +339,7 @@ if [[ ! -x "${RELABEL_BIN}" ]]; then elif [[ "${EUID}" -ne 0 ]]; then skip "relabel allowlist gate" "needs root (the helper refuses a non-root caller first)" elif ! command -v getenforce >/dev/null 2>&1 || [[ "$(getenforce 2>/dev/null)" == Disabled ]]; then - # The helper reports "SELinux inactive" and exits 0 BEFORE the gate, so there is nothing to + # The helper reports "SELinux inactive" and exits 0 BEFORE the gate, so there is no state to # assert here on a DAC-only host. skip "relabel allowlist gate" "SELinux inactive -- the helper exits before the gate" else diff --git a/tests/unit/safe-paths.sh b/tests/unit/safe-paths.sh index 53c207ac..728ab51e 100644 --- a/tests/unit/safe-paths.sh +++ b/tests/unit/safe-paths.sh @@ -9,7 +9,7 @@ # a real project nested under an operator home or the sandbox-clone area passes. Also checks the assert emits a refusal and returns # non-zero on a protected target and is silent + zero on a safe one, and pins the second, # narrower predicate beside it -- ai_tools_traverse_grant_allowed, which admits the acting -# operator's own home root for a traverse-only ACL and nothing else. Run as root via sudo +# operator's own home root for a traverse-only ACL and no other path. Run as root via sudo # (the suite contract); the only case needing privilege (a foreign-owned fixture) skips without # it, so the file also runs directly as an operator. @@ -154,7 +154,7 @@ else skip "own home root" "${PROJECTS_HOME} is not a /home/<user> home root" fi -# (10) Fail closed on inputs that name nothing: a missing path, a file rather than a directory, +# (10) Fail closed on inputs that name no directory: a missing path, a file rather than a directory, # and an empty owner all refuse rather than default to granting. closed_ok=true : > "${TESTDIR}/afile" diff --git a/tests/unit/safedir.sh b/tests/unit/safedir.sh index 384ecf87..b265fe6d 100644 --- a/tests/unit/safedir.sh +++ b/tests/unit/safedir.sh @@ -5,7 +5,7 @@ # adds at project claim and removes at unclaim, its idempotency, the allowlist gate on add, and # the root:SANDBOX_GROUP 644 it leaves behind. Runs the installed helper against a /tmp testdir # with a dummy allowlist (AI_TOOLS_ALLOWLIST) and a fixture gitconfig (AI_TOOLS_GITCONFIG); reads -# and writes nothing outside the testdir. +# and does not write a path outside the testdir. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" diff --git a/tests/unit/sandbox.sh b/tests/unit/sandbox.sh index 17470896..e0d9f38a 100644 --- a/tests/unit/sandbox.sh +++ b/tests/unit/sandbox.sh @@ -58,7 +58,7 @@ fi # ── sandbox_default_branch ──────────────────────────────────────────────────────────────────── # The default is sandbox/<leaf>, leaf = the from-ref's last component, with NO host/operator -# identity -- so it is stable and leaks nothing regardless of who or where runs it. +# identity -- so it is stable whoever runs it and wherever. def_is() { # def_is <from> <expected> local got; got="$(call sandbox_default_branch "$1")" \ && [[ "${got}" == "$2" ]] \ @@ -109,7 +109,7 @@ fi # The predicate --project-create's flow rests on, and the reason it is pinned here rather than # left to the CLI test: what it gates is the SECRET SCAN. A claim skips that scan, the git-history # prompt, and the proceed confirm when this returns 0, so every way it could wrongly say yes is a -# way to grant an agent access to a tree nothing scanned. It must answer for the tree as it is on +# way to grant an agent access to a tree no scan has covered. It must answer for the tree as it is on # disk -- never for what a caller asserts about it -- so the cases below are the states that must # read as NOT pristine. section "tree_is_pristine: the precondition behind --project-create's skipped prompts (unit)" diff --git a/tests/unit/secret-patterns.sh b/tests/unit/secret-patterns.sh index 9334a9e3..c851bf29 100644 --- a/tests/unit/secret-patterns.sh +++ b/tests/unit/secret-patterns.sh @@ -8,7 +8,7 @@ # security-critical properties -- credential names match, matching is case-insensitive, and # environment/name-anchored .NET configs match while plain configs and build artifacts the # toolchain must read do NOT (a false positive quarantines a build input and breaks the -# build). Run as root via sudo (the suite contract); needs no privilege of its own. +# build). Run as root via sudo (the suite contract); does not need privilege of its own. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" diff --git a/tests/unit/selinux-groups.sh b/tests/unit/selinux-groups.sh index 90dc42e8..869e8d00 100644 --- a/tests/unit/selinux-groups.sh +++ b/tests/unit/selinux-groups.sh @@ -22,7 +22,7 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" # Read-only (sources a world-readable lib, reads repo files); no root needed, like man.sh. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# Installed copy first, then the source tree (the lib carries no token substitution, so the two +# Installed copy first, then the source tree (the lib does not carry a token substitution, so the two # are identical); the lockstep half needs the checkout regardless. LIB="/usr/local/lib/ai-tools/selinux-groups.lib.sh" [[ -r "${LIB}" ]] || LIB="${ROOT}/src/usr/local/lib/ai-tools/selinux-groups.lib.sh" @@ -100,7 +100,7 @@ fi # services.sh and `semanage` in relabel.sh), emitting one printf per line the way a C program with # a 4 KiB stdio buffer does -- a single-write listing would deliver everything before any reader # could exit and hide the regression. The probe is driven repeatedly because one passing run -# proves nothing about a race. +# is no evidence about a race. semodule() { [[ "${1:-}" == -l ]] || return 1 printf '%s\n' abrt accountsd acct afs aiccu aide ajaxterm ai_tools ai_tools_tmpmap diff --git a/tests/unit/services.sh b/tests/unit/services.sh index 16554344..427043fa 100644 --- a/tests/unit/services.sh +++ b/tests/unit/services.sh @@ -14,9 +14,9 @@ # stale past max_age, a failed one stays failed at any age, an unknown age never manufactures # staleness, and 'fired' mode reads the recency of a SYSTEMD-STARTED run alone -- letting one # stamp yield two verdicts, a healthy trigger beside the failed run it started, while a run the -# operator did by hand (which proves nothing about a schedule) is declined in both directions; +# operator did by hand (which is no evidence about a schedule) is declined in both directions; # * the 'skipped' verdict for a run that correctly did NOTHING -- the updater finding the -# registry unreachable, where the previous toolchain stays and there is nothing to fix. It must +# registry unreachable, where the previous toolchain stays and there is no fault to fix. It must # not alarm (needs_attention says no, so --status stays green and exits zero) and must not # claim health either, so it stays distinct from active, still ages into 'stale' when the # condition persists, and leaves the TRIGGER's own verdict untouched in 'fired' mode; @@ -30,7 +30,7 @@ # that each reported record still carries its remedy command or the empty remedy whose commands # the consumer composes. # -# systemctl is stubbed as a shell FUNCTION (which overrides the PATH lookup), so the test needs no +# systemctl is stubbed as a shell FUNCTION (which overrides the PATH lookup), so the test does not need an # executable shim -- and works where /tmp is mounted noexec. The stamp fixtures are written with # known content in the test's own /tmp testdir; no real unit, no real stamp, no root. @@ -38,7 +38,7 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# Installed copy first, then the source tree (the lib carries no token substitution, so identical). +# Installed copy first, then the source tree (the lib does not carry a token substitution, so identical). LIB="/usr/local/lib/ai-tools/services.lib.sh" [[ -r "${LIB}" ]] || LIB="${ROOT}/src/usr/local/lib/ai-tools/services.lib.sh" section "services: registry accessors + state mapping + scan filters (unit)" @@ -83,7 +83,7 @@ else fi # A record that omits the trailing stamp field yields the empty string, not an unbound-variable -# abort -- the state resolver keys on that emptiness to mean "publishes no stamp". +# abort -- the state resolver keys on that emptiness to mean "does not publish a stamp". if [[ -z "$(ai_tools_service_field "unit-y|system|critical|none|why|how" 7)" ]]; then pass "an absent trailing field reads as empty" else @@ -102,7 +102,7 @@ else fi # The update service's stamp path is the one the updater writes; a drift between the two would -# leave --status permanently reporting 'unknown' with nothing to say why. +# leave --status permanently reporting 'unknown' with no reason to say why. svc_rec="$(grep '^nvm-update\.service|' <<<"${recs}")" if [[ "$(ai_tools_service_field "${svc_rec}" 7)" == /var/opt/ai-tools/state/nvm-update.status ]]; then pass "nvm-update.service names the updater's stamp path" @@ -154,7 +154,7 @@ fi # Such a unit is 'inactive' whenever it is healthy, so is-active would report every successful run # as DOWN and -- worse -- report a run that FAILED hours ago the same way, hiding it behind a # remedy that does not apply. ai-tools-relabel.service is the case that matters: it is triggered by -# a .path watcher whose own health says nothing about whether the relabel it started succeeded. +# a .path watcher whose own health is no evidence about whether the relabel it started succeeded. ONESHOT=ai-tools-relabel.service _SVC_STATE=( [${ONESHOT}]=down ) @@ -317,7 +317,7 @@ else fail "an old failed run was reported stale" fi -# A run that did nothing because it COULD not (the updater with an unreachable registry) is its own +# A run that made no change because it COULD not (the updater with an unreachable registry) is its own # verdict, between success and fault. Reporting it as FAILED would send an operator after a host # that is fine; reporting it as OK would claim an update that never happened. So it must read # 'skipped', must not count as needing attention -- or a disconnected laptop makes --status exit @@ -333,7 +333,7 @@ else ai_tools_service_needs_attention skipped && echo yes || echo no))" fi -# The escalation is the grace window's job: offline once is nothing to act on, offline for a week +# The escalation is the grace window's job: offline once calls for no action, offline for a week # is a toolchain that has stopped advancing, and only the age can tell those apart. mk_stamp "RESULT=skipped" "EXIT_CODE=3" "FINISHED=$(at_age $(( 13 * DAY )))" "REASON=offline" if [[ "$(ai_tools_service_state u sandbox-user "${STAMP}" result "${GRACE}")" == stale ]]; then @@ -342,7 +342,7 @@ else fail "an old skipped run did not go stale" fi -# 'fired' mode: the trigger's verdict is the recency of a SYSTEMD-STARTED run, and nothing else. A +# 'fired' mode: the trigger's verdict is the recency of a SYSTEMD-STARTED run, and no other input. A # RECENT run that failed still proves the timer fired, so the timer is healthy while the service it # started is not -- the two must not collapse into one verdict, or a failing service would also # condemn a working schedule. @@ -504,7 +504,7 @@ else fail "wrapper scan found nothing down when relabel.path is down" fi -# --- (D) all healthy -> the wrapper scan reports nothing --- +# --- (D) all healthy -> the wrapper scan does not report fault --- _SVC_STATE=( [ai-tools-handback.socket]=active [ai-tools-relabel.path]=active [nvm-update.timer]=active ) if ai_tools_services_scan wrapper; then fail "wrapper scan reported a down service on a healthy host: [${AI_TOOLS_SERVICES_DOWN[*]}]" @@ -514,7 +514,7 @@ fi # --- (E) a FAILED unit is reported by the 'all' scan, and by neither system-scope filter --- # Driven through a fixture registry (the record array is plain data) so the assertion depends on -# this test's own stamp rather than the host's real one. This is the last section; nothing after it +# this test's own stamp rather than the host's real one. This is the last section; no case after it # reads the registry. The wrapper/system filters must stay clean: only a sandbox-user unit can be # 'failed', so the launch wrapper's warning still speaks only of units that are not running. mk_stamp 'RESULT=failed' 'EXIT_CODE=1' 'FINISHED=2026-08-17T05:50:59Z' diff --git a/tests/unit/setfacl.sh b/tests/unit/setfacl.sh index 6df3f2a3..81268a12 100644 --- a/tests/unit/setfacl.sh +++ b/tests/unit/setfacl.sh @@ -4,7 +4,7 @@ # Hermetic unit tests for the deployed ai-tools-setfacl helper: the group-permission ACL it # applies at project claim, the opt-in --with-git .git normalization (group + setgid + ACL), # its owner guard, and its secret/exclusion/skip-list skips. Runs the installed helper against a -# /tmp testdir with a dummy allowlist (AI_TOOLS_ALLOWLIST); reads and writes nothing outside +# /tmp testdir with a dummy allowlist (AI_TOOLS_ALLOWLIST); reads and does not write a path outside # the testdir. set -euo pipefail @@ -35,7 +35,7 @@ if ! setfacl -m g:"${SANDBOX_GROUP}":rwX "${proj}" 2>/dev/null; then fi setfacl -b "${proj}" 2>/dev/null || true # undo the probe entry -# Fixtures (a fresh /tmp dir inherits no setgid and no default ACL, so any ACL afterwards +# Fixtures (a fresh /tmp dir does not inherit setgid and no default ACL, so any ACL afterwards # is attributable to the helper). ( umask 077; : > "${proj}/sub_restricted" ) # 600: group locked out mv "${proj}/sub_restricted" "${proj}/restricted" @@ -133,7 +133,7 @@ fi # (A5) the operator-named grant mirrors the group grant -- the operator's umask-independent # access to agent-written files, so it co-writes without SANDBOX_GROUP membership. # Asserted on a group-accessible file: an owner-only one is skipped outright (A2), so it would -# prove nothing about the operator grant. +# prove no property of the operator grant. if grep -qE "^default:user:${PROJECTS_USER}:rwx" <<<"${droot}" && u "${proj}/world"; then pass "operator gains user:${PROJECTS_USER}:rwX (access + default), no group membership needed" else @@ -164,7 +164,7 @@ else fi # (B5) the owner-guard skip is REPORTED, not silent -- the half the CLI reads. A walk that -# granted nothing must not be indistinguishable from one that had nothing to grant, which is +# granted no path must not be indistinguishable from one with no path to grant, which is # what let a claim over a tree owned by a third party close with a clean ✓. if ${foreign}; then guard_err="$(setsid "${HELPER}" "${proj}" < /dev/null 2>&1 >/dev/null || true)" diff --git a/tests/unit/setgid.sh b/tests/unit/setgid.sh index 84672d8f..42d7ee4d 100644 --- a/tests/unit/setgid.sh +++ b/tests/unit/setgid.sh @@ -3,7 +3,7 @@ # tests/unit/setgid.sh # Hermetic unit tests for the deployed ai-tools-setgid helper: project setgid + group # normalization, the secret-dir skip, and the owner guard. Installed helper against a /tmp -# testdir with a dummy allowlist; nothing outside the testdir is touched. +# testdir with a dummy allowlist; no path outside the testdir is touched. set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -57,7 +57,7 @@ else fi # (A4) the owner-guard skip is REPORTED, not silent. This is the half that matters to the CLI: -# a walk that normalized nothing must not be indistinguishable from one that had nothing to do, +# a walk that normalized no directory must not be indistinguishable from one with no work to do, # which is what let a claim over a tree owned by a third party close with a clean ✓. if ${foreign}; then guard_err="$(setsid "${HELPER}" "${proj}" < /dev/null 2>&1 >/dev/null || true)" @@ -118,7 +118,7 @@ else fail "sealed dir mode is now $(perm "${p2}/sealed")" fi -# (C2) a sealed dir takes its subtree with it -- nothing under it is normalized either. +# (C2) a sealed dir takes its subtree with it -- no path under it is normalized either. if [[ "$(stat -c '%G' "${p2}/sealed/inside")" != "${SANDBOX_GROUP}" ]]; then pass "the subtree of a sealed dir is skipped with it" else @@ -162,7 +162,7 @@ fi # ── The project root itself owned by a third party ─────────────────────────── # (E) The case that decides whether a claim granted anything at all: every directory below an -# unreachable root inherits nothing, so the agent cannot enter the tree. It gets its own wording +# unreachable root inherits neither, so the agent cannot enter the tree. It gets its own wording # rather than folding into the count, because "1 directory skipped" reads as a detail while this # is the whole outcome. p3="${TESTDIR}/proj3" diff --git a/tests/unit/skip-dirs.sh b/tests/unit/skip-dirs.sh index 3d412e0d..5cfddbd3 100644 --- a/tests/unit/skip-dirs.sh +++ b/tests/unit/skip-dirs.sh @@ -5,7 +5,7 @@ # the per-consumer skip sets the lib owns, the optional skip_git override, the operator.conf # category overrides (parsed, not sourced), and the -type d matcher that skips DIRECTORIES # only -- so a file sharing a skipped name is still walked. Sources the deployed library and -# exercises a /tmp testdir; needs no privilege of its own. Run as root via sudo (suite contract). +# exercises a /tmp testdir; does not require privilege of its own. Run as root via sudo (suite contract). set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" @@ -40,7 +40,7 @@ for consumer in sweep setgid setfacl unclaim lockdown; do done ${handback_ok} && pass "sweep/setgid/setfacl/unclaim/lockdown skip .git + heavy trees" -# reclaim WALKS .git but skips the heavy trees; reclaim-full skips nothing. +# reclaim WALKS .git but skips the heavy trees; reclaim-full descends everywhere. if [[ " $(names_for reclaim) " != *" .git "* && " $(names_for reclaim) " == *" node_modules "* ]]; then pass "reclaim walks .git but skips the heavy trees" else diff --git a/tests/unit/source-modes.sh b/tests/unit/source-modes.sh index 093efaa0..4b6e9557 100644 --- a/tests/unit/source-modes.sh +++ b/tests/unit/source-modes.sh @@ -4,13 +4,13 @@ # Hermetic consistency check on the exec bit git TRACKS for every `.sh` in the tree: within a # directory, all of them must agree. A directory here holds one kind of file -- the root helpers # are all commands, the shared libraries are all sourced, the tests are all run as `bash <file>` -- -# so a single file disagreeing with its siblings is drift, not intent, and the rule needs no +# so a single file disagreeing with its siblings is drift, not intent, and the rule does not require a # hand-maintained list of which paths are executable. # # The gap this closes: a mode flip is INVISIBLE in a normal review. `git show` renders it as a # zero-line change, and every install path sets its own mode explicitly (`install_subst 750 root # root`, `%attr(0750, root, root)`, asserted for the installed artifacts by -# tests/integration/perms.sh), so nothing downstream fails to make the drift noticeable. What it +# tests/integration/perms.sh), so no downstream check has to make the drift noticeable. What it # does cause is a file that reads as permanently modified in `git status` once a checkout's mode # and the index disagree -- noise that then hides a real change. # @@ -21,7 +21,7 @@ # # WHY THE TWO NUMBERS NEVER MATCH, since a reader meets them side by side and they look like a # contradiction: git stores only two modes for a regular file, 100644 and 100755. It records the -# OWNER EXECUTE BIT and nothing else -- no group bits, no world bits, no setgid. So this tree's +# OWNER EXECUTE BIT alone -- no group bits, no world bits, no setgid. So this tree's # collaborative 770 is tracked as 100755 and its 660 as 100644, and `ls -l` showing `rwxrwx---` # for a file reported as 100755 is agreement, not drift. The remediation is therefore stated as a # numeric mode to apply on disk, while the assertion it satisfies is about the tracked bit. diff --git a/tests/unit/stop.sh b/tests/unit/stop.sh index 0bcbd02a..db687e14 100644 --- a/tests/unit/stop.sh +++ b/tests/unit/stop.sh @@ -4,7 +4,7 @@ # Unit test for the session-stop helper (ai-tools-stop), the incident ladder's stop rung. It # pins the two things the whole guarantee rests on -- WHICH cgroups are enumerated as sessions, # and whether a cgroup is judged LIVE -- against a synthetic cgroup tree built in TESTDIR, so -# every assertion runs on any host, with no session running, no cgroup privilege, and nothing +# every assertion runs on any host, with no session running, no cgroup privilege, and no host state # signalled. # # WHY A FIXTURE AND NOT A LIVE SESSION. /sys/fs/cgroup is unreadable from a confined session and @@ -12,19 +12,19 @@ # a cgroup.procs that cannot be read) cannot be manufactured on a live host at all. Reading has # repeatedly failed to find defects in this enumeration; a fixture finds them in seconds. # -# The helper is SOURCED, which is inert by construction (it parses nothing and resolves nothing at +# The helper is SOURCED, which is inert by construction (it does not parse an argument and does not resolve host state at # file scope), and the three globals the walk is expressed against are then pointed at the fixture. # Two things are stubbed, both non-decisions here: unit_working_directory, whose real form would -# reach the sandbox account's user manager over sudo, and -- nothing else. Liveness and enumeration +# reach the sandbox account's user manager over sudo, and that one alone. Liveness and enumeration # are exercised as written. # -# NOTHING REAL CAN BE SIGNALLED. Every fixture pid is above the host's pid_max, so it has no /proc +# NO REAL PROCESS CAN BE SIGNALLED. Every fixture pid is above the host's pid_max, so it has no /proc # entry; the helper validates a pid's start time immediately before signalling and skips one it # cannot read, which is asserted here rather than assumed. main() is driven only in the dry run, # which returns before the kill. The kill primitive itself is exercised against real `sleep` # children this test spawns and reaps, and end to end in tests/integration/stop.sh. # -# Run as root via sudo with the rest of the suite; needs no privilege of its own, so it also runs +# Run as root via sudo with the rest of the suite; does not require privilege of its own, so it also runs # directly as an unprivileged user during development. Two assertions about an UNREADABLE file skip # under root, which reads everything regardless of mode. @@ -45,8 +45,8 @@ if [[ ! -r "${STOP_HELPER}" ]]; then fi mktestdir -# Fixture project directories only. This helper reads no allowlist and resolves no operator: it -# takes no target, so there is nothing to authorize. These paths exist purely as the working +# Fixture project directories only. This helper does not read an allowlist and does not resolve an operator: it +# does not take a target, so there is no input to authorize. These paths exist purely as the working # directories the fixture attribution map hands back for the table. mkdir -p "${TESTDIR}/proj/alpha" "${TESTDIR}/proj/alpha-extra" "${TESTDIR}/proj/beta" @@ -60,7 +60,7 @@ source "${STOP_HELPER}" 2>"${source_errors}" || true # The real attribution function, saved under a second name BEFORE the fixture stub below replaces # it. `unset -f` cannot get it back: overriding a function discards the original outright, so a -# test that stubbed first and unset later would drive nothing. +# test that stubbed first and unset later would drive the real function. eval "helper_unit_working_directory() $(declare -f unit_working_directory 2>/dev/null | tail -n +2)" \ 2>/dev/null || true @@ -392,7 +392,7 @@ declare -A FIXTURE_WORKING_DIR=() unit_working_directory() { printf '%s' "${FIXTURE_WORKING_DIR[$1]:-}"; } # A tree with a mix of attributable and unattributable sessions. Attribution is DISPLAY ONLY -- it -# selects nothing -- so what these assert is that every session is selected regardless of it, and +# does not select a target -- so what these assert is that every session is selected regardless of it, and # that an unreadable working directory costs a label rather than a target. CG2="${TESTDIR}/cgroup2b/user.slice/user-4242.slice" point_at "${CG2}" 4242 @@ -410,8 +410,8 @@ FIXTURE_WORKING_DIR=( [ai-tools-claude-code-23.service]="${TESTDIR}/proj/alpha-extra" ) -# The caller identity recorded in the trail. In production it comes from sudo; it authorizes -# nothing -- this command takes no authorization input. +# The caller identity recorded in the trail. In production it comes from sudo, and is recorded rather than +# consulted -- this command does not take an authorization input. # shellcheck disable=SC2034 # CALLER/SANDBOX_UID are read by the sourced helper CALLER="${PROJECTS_USER}" # shellcheck disable=SC2034 # as above @@ -448,7 +448,7 @@ else fi # THE PROPERTY THAT REPLACED SCOPING. Every one of these is selected, including the session whose -# project merely shares a path prefix with another and the manager's own init.scope. Nothing here +# project merely shares a path prefix with another and the manager's own init.scope. No cgroup here # is a target to be matched, so there is no prefix trap and no exemption to get wrong. for expect_unit in ai-tools-claude-code-21.service ai-tools-claude-code-22.service \ ai-tools-claude-code-23.service; do @@ -461,7 +461,7 @@ done # ATTRIBUTION CANNOT COST A SESSION ITS STOP. A unit whose working directory cannot be read is # still selected and simply shows as `unknown`; the old behaviour refused the whole run. This is -# the assertion that a misreporting session gains nothing by lying. +# the assertion that a misreporting session does not gain an exemption by lying. FIXTURE_WORKING_DIR[ai-tools-claude-code-22.service]="" run_main true if (( MAIN_STATUS == 0 )) \ @@ -473,7 +473,7 @@ else fi FIXTURE_WORKING_DIR[ai-tools-claude-code-22.service]="${TESTDIR}/proj/alpha/sub/dir" -# A WORKING DIRECTORY THAT IS NOT AN ABSOLUTE PATH YIELDS NOTHING. systemd renders the +# A WORKING DIRECTORY THAT IS NOT AN ABSOLUTE PATH YIELDS AN EMPTY VALUE. systemd renders the # "missing is ok" flag as a `!` prefix over d-bus (`WorkingDirectory=!/opt/ai-tools`), and an # unstripped one reached the operator inside a `--reclaim` command that will not run -- and that, # pasted into an interactive bash, is not even inert. Driven through the real function, with the @@ -491,7 +491,7 @@ for stub_case in "!/srv/p:/srv/p" "-/srv/p:/srv/p" "/srv/p:/srv/p" "~:" "!~:" "r done unset -f systemctl timeout -# An empty slice is not an error: nothing running is a successful stop. +# An empty slice is not an error: no session running is a successful stop. point_at "${TESTDIR}/cgroup2c/user.slice/user-4242.slice" 4242 mkcg "${SANDBOX_SLICE}" run_main true @@ -510,7 +510,7 @@ section "confirmation" # # unattended_confirm <arg...> -- drive confirm_stop under `setsid`, which removes the controlling # terminal: that is the shape of every unattended run, and the one a default-NO prompt would -# silently turn into "nothing was stopped". The sub-shell's STDERR IS CAPTURED, not discarded -- +# silently turn into "nothing was stopped". The sub-shell's STDERR IS CAPTURED, not discarded -- prose-check: allow # discarding it once turned a shell that aborted outright under `set -u` into a result line reading # "the confirmation declined", which named neither the abort nor the line it happened on. unattended_confirm() { @@ -559,7 +559,7 @@ check_question 0 3 "Terminate the 3 unit(s) of the ${SANDBOX_USER} account's own check_question 2 3 "the 2 agent session(s) listed above, and 3 unit(s) of the ${SANDBOX_USER} account's own plumbing with them?" unset -f ai_tools_msg_confirm check_question -# And a deliberate decline stops the stop, at exit 4, with nothing signalled. The renderer's answer +# And a deliberate decline stops the stop, at exit 4, with no process signalled. The renderer's answer # is stubbed because a real `n` needs a terminal to type it into; what is under test is that the # answer is honoured, which is the wiring between the two. point_at "${CG2}" 4242 @@ -596,8 +596,8 @@ section "usage" # to YES. In a suite that `install.sh` runs as its verification phase, that is an install that # hangs on a terminal read and one keystroke away from ending every session on the host. # -# So acceptance is asserted at the PARSER, which is the thing actually being claimed about: it -# touches no host state and needs no privilege. The live command belongs to +# So acceptance is asserted at the PARSER, which is the thing being claimed about: it +# does not touch host state and does not require privilege. The live command belongs to # tests/manual/verify-live-flows.sh, behind its opt-in drill flag, which is the only place a real # stop is ever issued. # diff --git a/tests/unit/unclaim.sh b/tests/unit/unclaim.sh index 49978f83..5ccf81eb 100644 --- a/tests/unit/unclaim.sh +++ b/tests/unit/unclaim.sh @@ -10,7 +10,7 @@ # A closing section covers the CLI-side decision that feeds this helper, # ai-tools.sh's resolve_handback_group, because it has TWO results (the group, and the hint that # no hand-back can run) and therefore returns both as globals in its caller's shell rather than on -# stdout. Nothing about that is visible from a `$(...)` capture -- which is how it regressed: the +# stdout. No part of that is visible from a `$(...)` capture -- which is how it regressed: the # capture's subshell dropped the second result, and reading it back under `set -u` aborted every # unclaim before it touched anything. So the assertion is made from a real caller. @@ -115,7 +115,7 @@ else skip "owner guard" "user 'nobody' not present" fi -# (F) an unknown target group is rejected (helper exits non-zero, nothing changed). +# (F) an unknown target group is rejected (helper exits non-zero, no path changed). if ! "${HELPER}" "${proj}" "no_such_group_$$" < /dev/null > /dev/null 2>&1; then pass "an unknown target group is rejected" else @@ -225,7 +225,7 @@ if [[ -r "${operator_conf}" ]] && grep -qE "^[[:space:]]*OPERATORS=.*\b${PROJECT fi # (O) fails closed with no invoking operator: the identity that bounds the walk cannot be - # resolved, so nothing is touched. env -u SUDO_UID reproduces a direct root call. + # resolved, so no path is touched. env -u SUDO_UID reproduces a direct root call. noop="${TESTDIR}/noop" mkdir -p "${noop}"; : > "${noop}/f"; chmod 0660 "${noop}/f" chown -R "${PROJECTS_USER}:${SANDBOX_GROUP}" "${noop}" @@ -297,7 +297,7 @@ else if ! out="$(resolve_hb "${PROJECTS_GROUP}")"; then skip "resolve_handback_group" "CLI not sourceable or helper absent (partial install?)" else - # --group names the group outright: it is published as-is, with no hint (nothing is wrong). + # --group names the group outright: it is published as-is, with no hint (the state is correct). if [[ "${out}" == "${PROJECTS_GROUP}|" ]]; then pass "an explicit --group reaches the caller as the hand-back group, with no hint" else