Conversation
The dagnode-release bootstrap RPM is signed by the org key, so dnf verifies its signature at install time; on a fresh host without the key the package install fails the signature check, since the package that installs the key has not run yet. Add the `rpm --import` step ahead of the install, point readers at the repository README for the out-of-band fingerprint check, and wrap the long install URLs across lines so they don't scroll off-screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntrol plane The control-plane snapshot's ignore file re-includes every dot-directory so any agent's config plane is captured generically. .nvm is the one dot-directory that is itself a git clone (nvm installs Node from git), so admitting its directory entry made `git add -A` record it as an embedded-repo gitlink and emit "warning: adding embedded git repository: .nvm" on every fresh bootstrap. The gitlink captures only a stray SHA, never .nvm's contents, so it is pure noise. Exclude .nvm outright -- it holds no durable control-plane asset. Other dot-directories (.npm, .cache, .local) carry no nested .git and are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The nodejs subpackage enabled the relabel watcher via %systemd_post but never started it, unlike ai-tools-base which starts the handback socket in its %posttrans. %systemd_post applies the preset (enable) but does not activate a .path unit, so on a fresh install without a reboot the watcher showed DOWN in `ai-tools --status` and a Node auto-upgrade that repointed the launcher went unwatched -- leaving the next launch to fail-close on a bin_t entrypoint. Add a %posttrans that starts the unit, mirroring the socket's start, so the watcher is live without a reboot. Guarded and idempotent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-and-nvm-ignore
A service account that runs a coding agent has no password, so it cannot authenticate the no-NOPASSWD root helpers a claim invokes -- and a claim performed by a human registers the project in the HUMAN's allowed-projects, which is not the file that account's launch wrapper gates on. Such an account could therefore never be given a project: it hit a sudo password prompt it had no way to answer. `ai-tools --for <operator>` performs the command on behalf of another enrolled operator, so the allowlist entry lands in THEIR registry. Everything downstream already keys off which operator's allowlist covers a path, so nothing else had to change: ai-tools-setfacl grants user:<them>, the ownership handback restores to them, and their next launch finds the project claimed. The human claims once, with their own password; the account never meets a prompt. Accepted on --project-claim/-create, --project-unclaim/-remove, --lockdown, --reclaim and --list -- the verbs whose effect is decided by allowlist coverage. Refused elsewhere rather than ignored, since 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 dropped. Also refused with --project-unclaim --force: that mode reaches a tree no allowlist names, so ai-tools-unclaim binds the walk to the invoking uid precisely so one operator cannot rewrite another's files, and --for must not reach past it. The new ai-tools-allowlist root helper backs the flag. Root is needed for the READ as much as the write -- an allowlist is 0600 inside a 0700 .config/ai-tools, so one operator cannot see another's at all -- hence a --print verb the CLI snapshots for its own decisions, with the mutations routed back through the helper against the real file. It carries no NOPASSWD grant, like the other project helpers, authorizes against the uid sudo sets rather than the spoofable SUDO_USER name, and refuses a bare root call, an unenrolled caller or target, the sandbox account, and a protected system directory -- leaving the registry byte-identical on every refusal. The flag widens one thing and the docs say so: an allowlist is an operator's own launch gate, and this lets one operator write into another's. That sits inside the standing "ai-ops operators are trusted" boundary, and every mutation is logged with both caller and target. Covered from both ends, per the project's paired-test rule: the helper's gates are driven into firing in tests/unit/allowlist-helper.sh, and tests/boundary /access.sh asserts as the agent that neither the helper nor any operator's allowed-projects is reachable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump VERSION and add the matching %changelog entry for the --for feature. check-version.sh couples the two, so they move together. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header still said files are handed back to ${ME}, which --for made wrong:
the hand-back target is the project's resolved owner, not whoever ran the
command. Reconciles the comment with the code it documents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three faults the test run surfaced, all in the --for gate. A refused command asked for a password first. require_for_target runs before dispatch and ends in snapshot_allowlist, which is the run's first sudo, but the --for/--force incompatibility was checked in cmd_project_unclaim -- which runs after the gate. So `--project-unclaim --force --for X` prompted, then refused. Move the check into the gate, reading the verb's own arguments, and make the ordering explicit: every refusal now precedes the snapshot. The copy in cmd_project_unclaim is removed rather than kept as a second line, since the gate always runs first and it could no longer be reached. An unenrolled target was refused with the wrong reason. OWNER_GROUP was resolved eagerly during the argument pre-parse, so a name that is neither an operator nor a user on the host died at `id -gn` with "cannot resolve the primary group" instead of the actionable "not a configured ai-tools operator -- enrol it with ...". Resolve the group in the gate, after enrollment is confirmed; without --for the owner is the invoker and it still resolves up front. The unit test asserted the wrong thing. It expected --add to leave the fixture owned by the projects user, but the harness writes it as root, so the helper was correctly preserving root. Chown the fixture to the operator first, which is what a real allowlist looks like -- otherwise the "does not take the file over as root" assertion passes trivially. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every --for assertion expects a refusal, and each must land before the gate's snapshot step -- a --for run's only sudo. When that ordering broke, the run did not fail: it stopped on an interactive password prompt, because sudo opens /dev/tty directly and a stdin redirect does not stop it. A container with no tty would have failed outright, so the regression was visible only where it hurt most, on a developer's terminal. Run the CLI under setsid, the idiom the sandbox cases in this file already use: with no controlling terminal sudo cannot prompt and exits immediately, turning that class of regression into a failed assertion. -w because setsid forks when it is already a process-group leader and the bare form then reports its own status, not the command's, which would quietly pass every rc-based check here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four .sh files disagreed with their directory siblings on the exec bit git tracks: ai-tools-run.sh, filter-hook.sh and ai-tools-dotnet.sh were recorded non-executable among siblings that are executable, and settings-merge.sh was the one executable among thirty-one tests run.sh invokes as `bash <file>`. This is repository hygiene, not a security fix, and nothing was exposed or mislabelled on any host. Installed modes are set explicitly at install time (`install_subst 750 root root`, `%attr(0750, root, root)`) and asserted for the deployed artifacts by tests/integration/perms.sh, so what the source tree records never reaches a deployed file. The cost is only that a file reads as permanently modified in `git status` once a checkout's mode and the index disagree, which then hides real changes. Gate it with tests/unit/source-modes.sh: within a directory, every tracked .sh must agree. A directory here holds one kind of file, so a lone dissenter is drift rather than intent and the rule needs no hand-maintained list of what is executable. It checks only the TRACKED mode -- a working tree's own modes are collaborative state that varies per checkout and is deliberately not what the repository records -- and where a directory has no strict majority it says so and names both sides rather than picking an outlier by coin flip. The remediation it prints is a numeric `chmod 770`/`chmod 660`, not `chmod +x`: with no who-clause that means a+x masked by umask, and umask does not mask execute bits, so under the common 0022 it turns 660 into 771 -- a world bit this tree does not carry. This is the drift class a review cannot catch: `git show` renders a mode flip as a zero-line change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release 0.12.0: ai-tools --for <operator>, claiming a project on behalf of another enrolled operator.
The generic rules had accumulated Claude-Code-specific prose, which made it
hard to tell which guarantees belong to every agent and which belong to the
one agent shipped today. That distinction matters now that a second agent
runtime is being planned: a fact stated in launch.rule.md reads as binding on
every wrapper, and one stated in providers.rule.md as binding on every
fragment.
Adds agent-claude-code.rule.md as the single home for that agent's wrapper,
manifest, entrypoint chain, custom system prompt, custom API endpoint and
session pins, and trims launch/providers/cli to the contract every provider
owes, each side linking the other. authoring.rule.md records the naming
precedent (agent-<name>, matching the ai-tools-agents-* package taxonomy) so
the next provider rule lands in the same shape.
Reconciles three stale claims in launch.rule.md against ai-tools-run.sh,
found while separating the generic from the specific:
- the env allowlist is FORWARDED_ENVIRONMENT_VARIABLES, not _ENV_ALLOW, and
it also carries XDG_RUNTIME_DIR
- the shim pins HOME and SHELL; it does not pin CLAUDE_CONFIG_DIR or
NODE_COMPILE_CACHE, which the agent's own session-env fragment sets
- PATH is assembled once, after the fragments run
Also records two agent-agnosticism gaps found while reading, both current
behaviour and neither fixed here: ai-tools-relabel-agent labels a declared
path pattern while the launch preflight checks the resolved inode, so an
upstream repackaging refuses the launch with a remedy that reports success;
and the management CLI's bootstrap gate hardcodes the claude launcher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… does not say `ai-tools --relabel` applied each agent's declared file-context pattern but never checked the binary the SELinux transition actually fires on. A package that installs its executable outside that pattern left the run reporting "its entrypoint is not installed" and exiting 0, while every launch stayed refused -- the wrong cause, and a remedy loop no rerun could clear. It now post-conditions on the resolved entrypoint, so exit 0 means the next launch will not fail closed on the label. Mechanism, the verdicts, and why the divergent path is reported rather than labelled are in .claude/rules/agent-claude-code.rule.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verdict is pure, so its truth table is hermetic; the agreement between what a manifest declares and what its package installs needs a provisioned host. The live half has a real trigger rather than a hypothetical one: the executable arrives in a per-platform optional dependency and is hardlinked into the declared path by a postinstall step, so a release that stops creating that link becomes a test failure instead of a refused launch for an operator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rade Reconcile the coupled prose with the code, record why npm stays the default channel, and correct the second-runtime direction: a host-packaged runtime needs an exact-path containment rule, not one more exec root, because its binary lands in a shared system directory. Drops the wip pointers -- a rule states current behaviour, and a path into gitignored scratch is a memory pointer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ai-tools-run checked one path and started another. AI_TOOLS_AGENT_EXEC names the versioned launcher symlink, so the SELinux label preflight resolved it to the real entrypoint while ExecStart handed systemd the symlink to re-resolve at exec time -- leaving every check in the preflight (label probe, version reads, session-env fragments, banner) as a window in which the link could point somewhere else by the time the manager ran it. The launcher is now resolved once, contained to the same semver version directory it was accepted at, and that single path is both what is checked and what is exec'd. Immediately before the launch it is re-resolved and re-compared against a device/inode/size/ctime identity, so a repoint (new inode), a rename-over (same path, new inode) and an in-place write (same inode, new ctime) are all refused. This narrows the race; only an exec root the agent cannot write closes it, and under SELinux that already holds -- the nvm tree keeps its default usr_t/bin_t/lib_t types, which ai_tools_t carries no manage rule for. The re-check is therefore the DAC-only deployment's observer, which also corrects agent-claude-code.rule.md: it stated the exec root is agent-writable without that qualification. The shim names no agent, so every agent launching through it inherits this; the containment root is the nodejs runtime's and becomes per-runtime when a second runtime lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d release npm's integrity hash and registry signature attest to what was DELIVERED, not to what is on disk afterwards -- and an unchanged version is never reinstalled, so a modified entrypoint would persist across sessions and operators unnoticed. Verification runs as root where the toolchain changes and reaches the launch as a pin the sandbox account cannot write, so a launch needs no network, no key and no vendor. Mechanism and its three outcomes: .claude/rules/updater.rule.md. The fields an agent declares: providers.rule.md. Operator view, including air-gapped behaviour: docs/entrypoint-verification.md. --relabel keeps its name and gains the verification; nothing here hard-fails offline. Requires gnupg2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verification had no operator-visible state. Its only signals were a warning in the sandbox account's journal, which an operator session cannot read, and eventually a refused launch -- so a host that had silently stopped verifying looked identical to one that never did. The pin is a root-owned record placed where the operator CAN read it (unlike the entrypoint itself, which is why its label stays unreportable), and it is written in the same stamp grammar as the updater's last-run record -- so it is read through the same accessors rather than a second reader that could drift. That made the age helper take the key it reads; FINISHED stays its default. Unverified counts toward the exit status only where the operator required verification, since that is exactly when it refuses a launch. Detail: .claude/rules/cli.rule.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mismatch refusal is what the whole verification buys, and it had no integration coverage -- only the pure verdict was driven. It is also the last gate the shim runs, so reaching it needs a valid executable: every other refusal case exits before it. Redirected at a throwaway pin directory rather than the production one, which here matters more than for the other root-only hooks: a wrong checksum written into the real pin would refuse every launch on the host until the next reconcile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ative-packaging
Gives every sandboxed agent a shared standard for designing, reviewing, and operating capable systems: technical constraints bound what a system can do, monitoring planes detect what it is doing, operational doctrine says what humans do when a threshold is crossed -- and a control the system itself can decline counts as none of them. Obligations scale with blast radius, so a bounded tool gets a credential scope and an audit log rather than a monitoring plane, and the same rules bind the agent's own conduct inside the sandbox, where this repo's trust chain is their enforced counterpart. The long normative text sits in references/framework.md so the guidance an agent loads on invocation stays short -- the first shipped asset that is a tree rather than a single file, which the seeder and linker already carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skill and its normative reference carried different version numbers, so a reader could not tell which draft a clause number belonged to. Both documents now say v0.1 and say what that buys: the numbering is provisional, so a consumer applies an obligation rather than citing it. x-ai-tools-version stays 1 -- that field is the seeder's monotonic integer, not the framework's draft version, and the asset has not shipped yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
settings.json ships four irreversible-VCS denies -- force-push, hard reset, forced clean -- and the rule said the deny list had two groups. The four fit neither documented criterion (they are neither structurally impossible nor reconnaissance), so a reader checking the rule against the file found entries nothing accounted for, and an operator tuning the permission arrays had no statement of what they were tuning away. The criterion is destruction with no undo, which is why these are denied rather than left to prompt: a prompt approves a command string, while what has to be weighed is what is about to be lost. Stated with the bound that goes with it -- this is a gate on the habitual spellings, not a boundary; the same destruction stays reachable through a spelling the pattern does not match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four denies that gate force-push, hard reset and forced clean had no assertion, so the two paths that preserve a host's tuning across an install -- keep-existing and %config(noreplace) -- could drop them with the whole suite still green and the rule still reading as complete. Pinned strictly rather than reported like the host-survey group: a relaxed host-survey entry costs visibility on a read, while a missing entry here costs work that cannot be recovered. Its own result variable, so a failure in the categorical group does not swallow this group's pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule said the env block ships one entry while the file shipped two, and neither the rule nor the options catalog's "set by the sandbox" table named the output cap or the two observability keys. The count is the part a reader relies on, so a reader auditing the file against the rule found three keys nothing accounted for. showThinkingSummaries and verbose get their own section rather than a mention, because they are the operator-side complement to disableAutoMode: that key decides whether a human is asked, these decide how much that human is shown -- the difference between approving a command string and approving what it did. The catalog's MAY-add rows for the three now say they are already shipped, so a project entry reads as the override it is rather than as an addition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ions Both lists read as exhaustive and neither was: ai-tools-allowlist is a root helper the CLI invokes through sudo, and claude-prompt/claude-endpoint are libraries the launch path sources. A reader checking sudo surface against the enumeration would have concluded the helper does not exist. ai-tools-handback stays absent by the same criterion it always was -- it is a socket daemon, not a sudo-helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts tag A syslog tag is chosen by whoever writes the line and the sandbox account can write /dev/log, so a session can emit `logger -t ai-tools-chown ...` and have it land in the trail an operator reads. Every documented recipe filtered by tag alone -- including one whose legitimate writer IS the agent -- so the query most likely to be run was the poisonable one, while ai-tools-run already printed the correct form. Each recipe now names the uid of that tag's legitimate writer: _UID=0 for the root helpers, the handback daemon and the installer, the sandbox account's uid for ai-tools-run and the hooks, the operator's for the CLI. The reason has one home, in logging.rule.md; the README and the log.lib.sh header point at it. Stated with the residual it does not close: ai-tools-hook and ai-tools-run are written BY the sandbox account, so no uid filter separates a forged line there. Those lines are the session's own account of what happened, and the root-only file sink is what settles a disagreement. The boundary half writes the forged line as the agent and asserts journald files it under the sandbox uid and never under _UID=0 -- so the separation is proven rather than inferred from the design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Decision-free corrections from auditing the tree against the governance skill: the deny group nothing documented or pinned, the settings.json defaults the rule miscounted, two incomplete enumerations in CLAUDE.md, and a journald query form that filtered on a field the agent can set.
…layer CLAUDE.md was a complete statement of what the system enforces against the agent and said nothing about what is expected of it. Every enforced control has a space beside it the kernel and the filesystem do not decide, and an agent that reaches one had been told nothing about which way to resolve it. Four expectations, in the router because a path-scoped rule does not load in the session where they bind: accept a stop, report a gap rather than use it, do not misrepresent what happened, do not work to widen the grant. The reasoning goes in a new governance.rule.md, scoped to the shipped standard it applies. Each expectation is paired there with the enforced control it sits beside, read in that direction on purpose -- the disposition is what the agent owes, and the pairing is why the host's safety does not rest on it. The rule also records the proportionality judgement, so the controls this deployment's tier does NOT warrant read as bounded scope rather than as omissions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
States what is expected of the agent where an enforced control leaves a choice, in the always-loaded router, with the reasoning and the enforced counterpart of each expectation in a new governance rule.
…ogger 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 value needs no escaping and cannot forge a sibling field -- which is what a record read by machine as well as by a human needs. ai_tools_log_structured writes both renderings in one entry: the human-readable MESSAGE for `journalctl -t <tag>`, and AI_TOOLS_* fields for `journalctl -o json` or a journal ingester. It is opt-in 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, decided by attempting the write rather than by a capability probe that could go stale. Field names are validated against [A-Z][A-Z0-9_]*, excluding the leading-underscore namespace journald reserves for the trusted fields it stamps itself: a sender cannot set those regardless, and refusing them here means a caller never believes it did. Values pass the existing display allowlist, which also removes the newline that would truncate a field early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit surface recorded privileged operations performed on the operator's behalf and the fact that a session started, and nothing about what the session itself did. The only artifact holding that was the transcript under the agent's own config directory -- agent-owned, so a session can rewrite its own history. After a suspected incident an operator could establish which files changed owner and not what the agent ran, with no independent record to reconcile the agent's account against. The PostToolUse hook now emits one INFO record per tool call to journald, a sink the agent can append to but neither edit nor delete. What it carries is bounded deliberately: for Bash, the first two words of the command's FIRST LINE plus that line's word count, never the command line itself. Taking only the first line excludes a here-doc body by construction rather than by a length cap, so a credential written through `cat > f <<'EOF'` records `cmd="cat >" argc=4` and nothing of the payload. Every value is agent-supplied, so the two renderings are reduced for what each one is read as. The MESSAGE is narrowed to printable ASCII minus space, quote and equals -- the characters that delimit it -- because 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. The structured fields need no such narrowing, the protocol delimiting them, so they carry the faithful value. A record that cannot be built is logged at WARNING naming the reason rather than skipped: an empty trail must not read the same as a session that ran no tools, which is the reading that manufactures confidence. The failure line replaces the record it could not write, so a degraded host's volume is unchanged. Declared as its own matcher group rather than by widening Write|Edit to include Bash: the settings merge keys on the command string, so a widened matcher would never reach a host whose settings.json is kept across an upgrade, and the Bash records would be emitted on a fresh install and silently nowhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two-ended, as a security guarantee requires. The runtime half drives a Bash event through the deployed hook as the agent and reads the trail back: that the call is recorded with its leading words and argument count, that the native AI_TOOLS_* fields accompany the MESSAGE, and -- the assertion that carries the weight -- that a here-doc body written through that command never reaches the journal in any field. The bound is the whole reason the record stops where it does, so it is pinned against the full entry via `-o export` rather than against the rendered message, and a future field carrying the full command line fails here rather than shipping. The boundary half asserts the asymmetry the trail rests on: the agent writes it (the hooks run as the sandbox account) but cannot unmake it -- journal storage refuses a new file and none of its files is agent-writable, and journalctl's destructive verbs fail for that account. Without that, a record of the agent's own actions would be worth no more than the transcript it already owns. Also pins the `post-tool-hook.sh record` declaration itself, since that exact command string is the mechanism carrying the record onto an upgraded host. The two remedy hints in this file named `./install.sh install`, which an RPM host has no checkout to run; both now name `sudo ai-tools-admin postupgrade`, the documented path that runs the same merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Real detections were already implemented and already recorded -- ai-tools-run's REFUSED lines, the handback daemon's rejected peers and helper timeouts, ai-tools-chown's breach NOTICE, stranded handback counts -- and not one of them had a reader. There was no summary, no digest, no threshold and no owner, so each landed in a root-only file or a journald tag someone had to think to query. A detection nobody reads is decoration. `ai-tools --audit [--since <when>]` is the lightest thing that qualifies as a reader: not an alerting stack, one command with a consumer. It exits non-zero when anything is reported, so it runs from cron or a login banner without parsing its output -- the contract --status already offers. 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; a helper that adds a warning is reported the day it ships, with no pattern here to update. NOTICE is in scope deliberately, being the level at which a breached secret is recorded. The two sources are not equal and the report says so. /var/log/ai-tools/*.log is 700 root:root, root writers only, so those lines are evidence. Launch refusals are the exception -- ai-tools-run runs as the sandbox account and reaches only journald, under a tag whose legitimate writer is that same account -- so they are reported in a section of their own, to reconcile against the first rather than be relied on alone. That split is what keeps an agent-writable trail from being presented as proof. A --since value date(1) cannot parse is refused rather than treated as "everything": a typo must not silently become a reassuring wall of old findings, nor a silent all-clear. The test pair drives the helper against a seeded throwaway trail through the existing root-only AI_TOOLS_LOG_DIR hook (findings reported and exit non-zero, INFO churn excluded, the window honoured, a clean window quiet, a bad --since refused), and asserts from the agent's side that it can neither read the trail nor run or alter the reader. Also corrects the root-only log file list in the logging rule and README, which omitted setfacl, unclaim, safedir, allowlist, relabel and dotnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part of the trail is written BY the agent: the lifecycle hooks run as the sandbox account, so the tool-call record is the session's own account of itself. That is inherent rather than fixable -- no record written from inside a monitored system can be more trustworthy than the system -- but it is a line that has to be drawn and shown rather than left for a reader to discover, and presenting an agent-writable trail as proof would be the actual failure. A monitoring plane that manufactures confidence is worse than none. Records where the line falls and how each layer keeps the two trails distinguishable: the root-only file sink as evidence, journald under the agent's own tags as its account, the _UID pairing in every documented query, --audit's separate sections, and the boundary assertion that the agent can append but not unmake. And states what the agent-written half is for -- reconciliation, where a disagreement between the two is itself the finding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1 of the governance-audit remediation: the audit trail. Converts a system that enforces well and observes incidentally into one that can answer what happened. Three parts: every tool call a session makes is now recorded to a sink the agent can append to but not unmake, bounded so a here-doc body can never ride into the trail; the shared logger gained opt-in native journald fields so the record is machine-consumable without re-parsing; and 'ai-tools --audit' gives the existing detections their first reader, reporting root-written evidence separately from the session's own account of itself.
… check Two failures from the suite run. The new helper was tracked 100644 while every sibling in that directory is 100755, so source-modes.sh flagged it. The --audit help assertion tested the DEPLOYED CLI and failed outright when it predated the verb -- which is the normal state between building this branch and installing it, not a defect. It also duplicated unit/man.sh, which already pins usage() against the man page from source in both directions. It now skips in that state, with its remaining value stated: (1)-(5) drive the helper directly, so this is the only check that would notice a verb never wired into the CLI's dispatch on the host actually running it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tool-call record cut its words at 32 characters, which covers little more than a path's leading components -- so the trail showed that a session ran `cd` but not where, and every project on the host looked alike at that length. Paths are now recorded whole, while a single pathological word is still bounded. Also corrects what the rule promised of the structured fields, which claimed more fidelity than AI_TOOLS_CMD offers, and de-identifies its example records. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first live run reported 211 findings, of which two mattered. A condition that recurs writes one line every time it happens, so the entrypoint ERROR that needed acting on sat at the bottom under two hundred lines of the handback daemon refusing the test suite's deliberately malformed requests -- and a report nobody finishes reading reports nothing, which is the same reason routine INFO churn was excluded from the start. Repeats now fold into one line carrying how many times the finding occurred and the most recent example in full, and findings lead with the most severe. Nothing is hidden: the count states what was folded and the underlying files are named. The same run now reads as six findings with the ERROR first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dated ERROR in the report reads as a standing fault, and acting on one already resolved is the fastest way to stop trusting a trail. The first live run made exactly that mistake: an entrypoint finding from the previous day was read as a live fail-closed condition, when the entrypoint was correctly labelled and every launch was working. The report now says plainly that each line is something that happened, points at the commands that answer what is true now, and leads with the date it was last seen. It still does not re-verify a finding -- knowing how to re-check each condition is the per-detection knowledge this command exists without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ms-governance-skill
Until now every operator control changed only what the NEXT launch gets: unclaiming a project, disabling a provider, revoking an operator. Nothing acted on a session already running, so the answer to "stop what it is doing, now" was to find the unit by hand in an account whose systemd manager an operator cannot reach. This adds the helper that does it. An operator stops the sessions in a project they have claimed, and any operator can stop every session at once when it matters more that the agent halts than whose work is interrupted. Both record who asked, what was ended, and whether it exited on its own or had to be killed -- so an incident leaves a trail rather than an operator's memory of what they typed. Two properties are worth knowing before relying on it. A stop cannot run the agent's own session-end handback, so the last turn's writes may still be sandbox-owned; the command names the reclaim for each project it stopped. And stopping every session is the form that holds against a session actively avoiding being stopped -- the per-project form is for routine use. Not yet wired into the CLI, packaged, or covered by tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two ways a live session escaped the stop, both found by putting the enumeration under test rather than by reading it again. A cgroup whose name begins with a dot was invisible to the walk: default globbing skips it, and every name inside the delegated subtree is the delegatee's to choose, so a session could sit in `.hidden` and survive even `--all` -- the form that has to hold against a hostile session. A threaded cgroup read as empty for a different reason: its `cgroup.procs` fails the read while holding live threads, and bash cannot tell that from a clean EOF, so the liveness predicate answered "no tasks" where the header promised it would fail closed. It now corroborates against `cgroup.threads`, which the kernel keeps readable in exactly that case. The helper's entry point moves into three named functions so the file can be sourced with nothing parsed, resolved, armed or signalled. That is what lets the unit suite drive enumeration and liveness against a fixture cgroup tree on any host, with no session running and no privilege -- and it is what found both defects above within a minute of existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not stall it Attribution asks the sandbox account's own user manager for a unit's working directory, and a d-bus call to a hung manager blocks with no bound. "The user manager is wedged" is not a hypothetical for this command -- it is one of the states an operator reaches for it in -- so the one thing the helper must never do is hang there. Both calls now run under a short timeout, and every way that can fail yields no attribution: the scoped form refuses and names --all, and --all needs no attribution at all, so the undeclinable form cannot be delayed by this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ace at it An operator asking what a stop guarantees, a reviewer asking which kernel invariants it rests on, and a contributor asking why the code inverts three project conventions were all being answered in the same prose, repeated in the file header and across four rules. docs/session-stop.md is now the one home for that reasoning: the operator manual, the invariants and residual failure modes, the implementation walkthrough, and the decisions that must not be reverted. It opens with the map of which surface holds which fact, so each of the four audiences follows a pointer instead of meeting the same paragraph three times. The file header keeps only what a reader of the code needs -- the contract and the local mechanism -- and each rule keeps a one-line note that this component is its exception, with the why linked rather than restated. It also answers the question the size of the helper provokes: almost none of that code is the turning off. `systemctl --user stop` asks the monitored system's own manager, reports on unit state rather than processes, and is least available exactly when an incident needs it. The rest is discovery across a delegated subtree, authorization on a shared account, attribution, consent, verification against the kernel, and the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ai-tools --stop [path]` ends the sessions running in one project; `--stop --all` ends every session on the host. Until now the helper existed but nothing installed or dispatched it, so an operator had no way to end a running session short of finding its unit by hand. The CLI half stays thin -- option grammar, and defaulting the target to the working directory -- because every remaining decision is a security decision the helper must make in one place: it authorizes a scoped stop against the caller's own allowed-projects, read as root, and `--all` takes no authorization input at all so that no session can put itself outside the stop path. The man page documents both forms, what each exit code means, and points at the operator guide; perms.sh gains the deployed helper, so its 750 root:root mode is asserted with every other installed artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd by hand Three vantages, because no one of them can show what the others do. The integration suite drives the kill path against real processes in a real cgroup it creates and owns: a plain child, a setsid(2) child and a double-forked grandchild are all reaped, a payload that ignores SIGTERM escalates and is reported as killed, --force never reports `terminated`, and every success is checked against /proc rather than against the mechanism that made the claim. No session need be running and nothing it does can reach the sandbox account's own slice. The boundary suite asserts the other half of the pair, as the agent: the account a session runs as can neither run, read nor alter the helper. A control the monitored system can disarm is not a control, and that claim is only worth making from the vantage it is about. The `--all` form can only be proven by ending every session on the host, so it stays out of the automated run and becomes an opt-in drill in the manual flows script -- which is also the point: an escalation ladder nobody has climbed is a document, not a control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll an install A test file that blocks -- on a terminal read, a wedged daemon, or a fixture process holding a pipe open -- hung the whole run with no output and no exit. That is worse than a failure: `install.sh` runs this suite as its verification phase, so an unbounded file stalls an install and leaves the operator with a frozen terminal and nothing to read. Each file now runs under a per-file budget and a file that exceeds it is killed and reported as failed, with its transcript intact. source-modes.sh also states why the two numbers it prints never match: git records only the owner execute bit, so this tree's collaborative 770 is tracked as 100755, and `ls -l` showing rwxrwx--- beside a reported 100755 is agreement rather than drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live `--stop-all-drill` was run for the first time and found two things this branch had reasoned its way past. `--all` took the user manager down with the sessions. Sweeping the account's whole slice reached its `dbus-broker.service` and a login `session-*.scope`, and killing those emptied `init.scope` -- the account's own `systemd --user`. The design note called that blast radius harmless; it is not, because SIGKILL leaves `user@<uid>.service` failed rather than restarting it, so the next launch has no --user instance to start a session in. And the scoped form was refusing on every real host. A login session scope has no WorkingDirectory, so it landed in the unattributable set, which is a hard refusal before anything is stopped. `ai-tools --stop <project>` exited 3 always. The fixture could not show this: it was built so that everything is attributable. Sparing the manager by name was the obvious repair and is the wrong one. Every name inside the delegated subtree is the delegatee's to choose, and on a host without the SELinux module a session can reach its own user manager and pick one -- so a spare list is an exemption the monitored system can claim. An exemption expressed as an exact path is no better: it is still a destination to move tasks into. So nothing is exempt, `init.scope` included, and the manager is restarted afterwards instead of being protected. restore_user_manager runs after the kill AND after the verification, so it cannot affect either; a failure warns, names the fix, and never changes what the command reports about the stop. No unit name appears in this file any more, which is also why nothing here needs to differ between EL9, EL10 and Fedora. The per-project form is removed rather than repaired. Every way to attribute a session to a project is written by the account being stopped -- the unit's WorkingDirectory, its name, its launch line -- so scoping by any of them lets a session influence what a stop reaches. Attribution stays, as something the operator READS: it labels a row and names a --reclaim, and selects nothing. A session that misreports its project now costs itself a label instead of costing the stop a target. Sound targeting needs a session-to-project mapping recorded by root at launch, and that is a registry and a handback verb, not a positional argument. What goes with it: the caller's allowlist (it authorized only the scoped form), operator.lib.sh and safe-paths.lib.sh (both loaded only to vet or authorize a caller-supplied path), realpath, the unattributable refusal, and exit code 3. No project library is load-bearing here any more -- with no input deciding which sessions to stop, there is nothing left for one to gate. A path is refused, not ignored, in the CLI before sudo and in the helper as the last line. Someone typing one believes they are narrowing the command, and proceeding would do the opposite of that belief while the confirmation defaults to yes. It also keeps the door open: if targeting is ever built, `--stop <path>` moves from error to accepted, and no existing command line quietly changes meaning. `--all` is kept as an accepted, inert synonym so a script that spells the intent out is not refused for being explicit. Also fixed here because the drill printed it: systemd renders a unit's "missing is ok" working directory with a `!` prefix over d-bus, not the `-` of the unit file, so an unstripped value reached the operator as `ai-tools --reclaim !/opt/ai-tools` -- a command that will not run and, pasted into an interactive bash, is not even inert. Both markers are stripped and the result must be an absolute path or it yields nothing. Terminate and end-cleanly are now distinguished throughout the user-facing text. This command kills a process tree and no session-end handback runs; `/exit` inside a session is how one is finished with, and it is not the same act. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every surface that described --stop described two forms, a scoped one authorized by the caller's allowlist and an undeclinable --all. There is one form now, and the reasoning behind the change is the part worth carrying: the design note argued the scoped/--all strength gap away as a property to respect, and it was really a sign that scoping had no sound input. docs/session-stop.md is the single source, so it takes the weight. "The two forms" becomes "one form" and says what a path is refused for. "Who may stop what" no longer describes an authorization mechanism, because there is none; it states the operational consequence instead -- on a shared host one operator's stop ends every operator's sessions -- rather than leaving it to be discovered. "Where containment ends" now names both DAC-only escapes and what closes each: SELinux grants ai_tools_t nothing on cgroup_t and no connectto on the user runtime socket, so neither cgroup migration nor asking the user manager for a unit is reachable there; on a DAC-only host both are, which is exactly why no cgroup is spared. The decision table loses the rows that are no longer decisions and gains the ones that are, including that the restart never changes the exit status. The deferred list is the reversal made explicit: a dedicated ai-tools.slice moves from queued work to REJECTED, because bounding --all to a slice would put every cgroup outside it out of reach on precisely the hosts where a session can create one. A root-recorded session registry takes its place as the real prerequisite for any targeting. safe-paths no longer names this helper as its one advisory consumer -- it is not a consumer at all, since a helper enters that rule's scope by taking an argument that names a path. messaging keeps the default-YES inversion and adds its bound: defaulting toward action covers a known intent with something in the way, not an ambiguous request whose most destructive reading is to terminate everything. logging records what the sanitizer is protecting, which is a --reclaim command the operator is invited to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he real slice The enumeration assertions said init.scope is spared. They now say it is enumerated, and that inversion is the guarantee rather than an incidental edit: an exemption is a cgroup a session can move into, so what has to be proven is that no name and no path is treated as special anywhere. Selection is asserted the other way round too. The old fixture was built so every session was attributable, which is precisely why it could not catch a scoped stop refusing on a real host; the assertions now drive a session whose working directory cannot be read and require it to be selected like any other, shown as `unknown`. That is the property that makes attribution safe to take from the account being stopped -- lying about a project costs a label, never a target. The systemd `!` marker gets a table over the real function, including the `~` and relative forms that must yield nothing. Two harness bugs found while doing it, both mine, both from a value having to cross a boundary it cannot: The usage section drove the helper as a command. That was safe while a bare invocation was a usage error, because it exited inside the parser; once no argument became the documented form, the same line resolved the real host, enumerated the sandbox account's live slice and prompted to terminate every session on it -- defaulting to yes, inside the suite `install.sh` runs as its verification phase. Refusals are still driven end to end, since each exits in the parser at any uid; acceptance is asserted at parse_command_line, which is what the claim is actually about and touches no host. The one accepted form still run as a command is guarded on EUID and says why the guard cannot be removed to "also cover root". And the integration stub recorded into a variable while main() is captured through `$(...)`, so the flag died with the subshell and the assertion failed against correct code. It records into a file now. The manual drill drops the cases that tested removed behaviour and asserts what replaced them: a path refused before any sudo prompt, and the user manager back after a stop that necessarily took it down. That last one is the assertion the whole no-exemptions design rests on, and only the drill can make it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
say_error, say_warn and say_notice each passed a leading file descriptor to ai_tools_msg_error/_warn/_notice, which take LINES ONLY and bake their own descriptor in. The digit therefore became the message's first line, so every refusal this helper prints -- including the ones an operator reads during an incident, "no cgroup v2 hierarchy", "cannot resolve the uid" and "N session(s) survived SIGKILL" -- rendered a bare 2 above itself. It survived review because ai_tools_msg_headline, called three lines below, genuinely does take <title> <fd> <line...>, so the two adjacent call shapes read as consistent. It is also invisible in the boxed path and shows only when the output is captured, which is why the drill never surfaced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A live drill reported "4 agent session(s) will be terminated" where one agent was running. The other three were the sandbox account's own init.scope, its dbus broker, and a login session scope left by a sudo -u that crossed pam_systemd. All of them are terminated -- nothing is exempt, and that does not change -- but the count an operator reads first during an incident, and answers the confirmation against, overstated the agent work running by four to one. Sessions are now reported apart from that plumbing: agent rows list first, account rows follow marked "(account plumbing)", and the headline and the confirmation carry the two figures separately. The class comes from session_is_agent matching this project's own launch prefix -- ai-tools-run names every session <SANDBOX_USER>-<agent>-<pid>.service -- so no distro unit name enters the file and it still differs in nothing between EL9, EL10 and Fedora. It is ADVISORY in exactly the sense the WorkingDirectory attribution beside it already is: a unit name inside a delegated subtree is the delegatee's to choose, so a session can name itself out of the agent class and gains nothing by it, both classes being enumerated, listed and killed identically. It labels a row and splits a count; it selects nothing. One marker rather than naming the user manager separately: a path test against MANAGER_SERVICE is wrong, since every unit that manager starts is inside its subtree -- agent sessions included -- and it labelled the dbus broker as the manager; the alternative puts an init.scope literal back in a file that holds no unit names. Reclaim guidance now names agent sessions only. The plumbing has no project to hand back, and the dbus broker reports WorkingDirectory=/opt/ai-tools -- the control plane, which the protected-paths backstop refuses -- so the run had been offering "ai-tools --reclaim /opt/ai-tools", a remedy that cannot run, beside one that can and with nothing to tell them apart. Also documents what the rebuild made true and had not been written down: a second --stop is not silent. The manager the previous run restored is itself inside the swept slice, so a rerun finds it, stops it and restarts it again. The command is idempotent in end state, not in what it reports, and buying a silent rerun costs either an exemption -- a cgroup a session can move into -- or letting a name decide what is swept. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd_stop refused a path, and an unknown option, through the CLI's own die, which exits 1. Both ai-tools(1) and docs/session-stop.md publish 2 for usage, and 1 for "a process survived SIGKILL" -- so a mistyped command told a caller its kill had failed. Because cmd_stop propagates the helper's exit status, the codes are one contract; which side refused is an implementation detail of doing it before the sudo prompt, not something the caller asked about. A local die_stop_usage carries the refusal in that shared code space. The suite asserted only rc != 0, which 1 satisfies, so it stayed green while the live drill -- which pins the exact code -- caught it. All three --stop grammar assertions now pin 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… cgroupfs The drill asserted the user manager came back with [[ -s init.scope/cgroup.procs ]]. Every cgroupfs file stats as zero bytes however many tasks it holds -- the root cgroup.procs stats 0 with 379 pids in it -- so the test could never pass, and it reported a healthy restore as a failure while the trail recorded "restarted user@978.service" from the same run. That assertion is the one the whole no-exemptions design rests on: nothing else proves the manager the sweep necessarily terminated is put back. A check that always fails is worse than none, because it trains the reader to discount it. manager_is_up reads the file instead, the way session_task_count beside it already did, with the stderr redirect ahead of the input redirect per the rule ai-tools-stop.sh's cgroup_pids states -- otherwise a missing init.scope, exactly the case being asserted against, writes its open failure into the report. Also corrects the rerun assertion's wording. Its predicate still holds and is unchanged, but "a second --stop is a clean no-op" described the pre-rebuild design: the restored manager is inside the swept slice, so a rerun stops and restarts it. What must hold is that no AGENT session is found and the run still exits 0 -- a rerun reporting agent sessions means the first stop did not take. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
confirm_stop read a second positional parameter, which this file's `set -u` turns into an abort when a caller passes one argument -- ending the run mid-question, after the table is printed and before anything is signalled. A stop that was asked for and did not happen is the one outcome this helper exists to prevent, so both counts are now defaulted where they are read: a caller's slip costs the wording of the question and never the answer to it. The rule is about abandonment rather than about `set -e` specifically, which the header now says. The unit suite had been left behind by the commit that counted agent sessions apart from the account's plumbing: it still expected the summed figure and still called the confirmation with one argument, so every container selftest failed twice -- one of them reported as "the confirmation declined", because the sub-shell's stderr was discarded. Stderr is captured now, the two counts are asserted apart, and the three question shapes and the short call are pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le ones The rebuild that made --stop take no target left its reasoning copied across four surfaces, and two copies had gone stale: cmd_stop still described authorizing a scoped stop against the caller's allowed-projects and listed an exit code the helper does not have, and session-stop.md still claimed the user manager is "spared by exact path" -- contradicting *nothing is exempt* three paragraphs above it. What is matched by exact path is the manager unit, which is descended into rather than emitted, so init.scope is killed with everything else. Each fact now sits where its reader is and is referenced from the others: why there is no per-project form in session-stop.md, which externals the kill path touches in the helper's header, the exit codes in ai-tools(1) alone. The man page keeps what an operator acts on and drops the design argument behind it. The two path refusals cannot be single-sourced -- different processes, and the helper is 750 root:root -- so they are aligned on the same four commands and each names the other as its twin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.