Skip to content

Merge Develop - #51

Merged
p4nda merged 46 commits into
mainfrom
develop
Aug 31, 2026
Merged

Merge Develop#51
p4nda merged 46 commits into
mainfrom
develop

Conversation

@p4nda

@p4nda p4nda commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

No description provided.

p4nda and others added 30 commits August 29, 2026 03:12
…sset

Cosmetic: no host ends an upgrade in a different state for any of this. What
changes is what the operator is shown while it happens -- and one latent
dependency the reporting was hiding.

Three defects, all visible in a single `dnf upgrade`:

- The update confirm is written to /dev/tty, which SUCCEEDS when dnf runs on a
  terminal, so an upgrade drew a question nothing could answer and then decided
  it without the operator. base's %post pre-answers it with AI_TOOLS_ASSUME_YES,
  so nothing is drawn; the outcome is unchanged, since the default was already
  yes, and the decision now audits as `assume-yes` rather than `default`, which
  is what actually happened. It widens nothing -- that variable fast-tracks a
  default-yes question and never flips a default-NO one.

- The four withdrawn documentation skills were reported "up to date (v1)" and
  retired seconds later in the same run. rpm installs the new package's files
  first and removes the old package's only at the end of the transaction, so the
  seeder -- running in %post -- reads a source root still holding the previous
  version's copy of an asset this version withdrew. The withdrawn list now gates
  both passes, so a withdrawn name is skipped whatever that root holds. That also
  removes the ordering dependency the correct outcome rested on: seeding happened
  to run before withdrawal, and swapping the two would have resurrected the asset
  the withdrawal exists to remove.

- "seeded (vN)" reported the previous iteration's version. The shipped version
  was read only on the update branch and reported on both, so a fresh seed
  carried whatever the asset before it had -- correct often enough to look fine,
  and wrong for the first asset of a run.

Also reconciles the seeder's header, which still described the confirm as
defaulting to keep after the default became update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both passes run unattended in a package scriptlet with their output scrolling
past in a dnf transaction, so every way either can go wrong is quiet and nothing
pinned any of it. Five properties, each one an operator would otherwise discover
much later:

- The marker is the claim. An asset without x-ai-tools-managed is the operator's
  own: never overwritten by the seeder, never moved by the withdrawal. Both
  passes gate on it, so both are driven against an unmanaged fixture.
- The update default is UPDATE, including with no terminal -- driven under setsid
  so a regression to "keep", which turns every packaged upgrade into a silent
  no-op, fails here rather than on a host months later.
- A withdrawn name is never seeded, whatever the source root holds, with the
  fixtures reproducing the state that actually occurs: rpm has installed the new
  package's files and not yet removed the old package's, so the source root still
  carries the withdrawn asset.
- A reported version is the asset's own. Asserted with an updated asset sorting
  before a freshly seeded one, which is the order that reproduces a version read
  on one branch leaking into the other.
- Withdrawal preserves: it moves rather than deletes, into a 0700 root-owned
  directory the sandbox account cannot reach.

Every root is an argument to the functions under test, so the fixtures live in
the file's own testdir and no live asset, datadir, or /opt tree is read or
written. Needs root: the seeder chowns what it places and the withdrawal creates
that directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/run.sh invokes each file through bash, so the bit does nothing, and every
other .sh in tests/unit/ is tracked 100644. source-modes.sh fails a directory
whose files disagree, which is how this surfaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ai_tools_msg_pick takes `none` in place of a default index: empty or
out-of-range input re-asks (three attempts, each miss saying what is
expected) and every path that fails to get an index -- no terminal,
closed input, three unanswered attempts -- returns non-zero with nothing
on stdout. A screen whose options are not equivalent has no honest
default to fall back on, and answering one for the user is worse than
asking again.

The safe-default rule is unchanged; it moves to the caller, which is the
only place that knows what "no answer" should mean. Callers passing a
default index keep today's behaviour exactly, so nothing existing shifts.

An option may now carry its consequence after a tab, aligned and dimmed
beside the label, so a menu can state a choice and its cost on one line
rather than repeating a block above it. ai_tools_cmd_display renders a
command the way a user should type it -- the bare name where PATH
resolves it to that same path, the absolute path otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The not-accessible screen listed both options as commands inside its
block and then again as menu items, which is what made it read as a wall
of text, and nothing on it said that option 1 does not start a session
here -- the user is choosing between a session now and a directory to cd
into. The block now carries one line of prose and no commands; the menu
carries the options, each with the consequence that distinguishes it.

Both screens lead with the action rather than a refusal, and print
commands as bare names, so `ai-tools --project-claim` no longer reads as
a second, unrelated tool beside the agent the operator just ran. The
menu has no default: an unattended or piped run still takes Cancel, now
decided in the wrapper's own have_tty branch, and the cancel path -- the
one place the commands appear once the block drops them -- names both of
them plain, below the frame.

Guidance text says "your agent", not "claude": the wrapper is
claude-code's, the advice is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setgid and ACL walks act only on paths held by the resolved operator or the
sandbox account, and until now a path failing that guard was indistinguishable
from a stat failure: both returned 1, neither was counted, nothing was printed.
A claim over a tree owned by a third party therefore granted nothing on every
directory while the registries, the label and the closing checkmark all applied,
leaving an operator with a project the agent cannot enter and no sign of why.

The guard now returns its own status, the walks count it, and each closes with
the count on stderr. The project root gets its own wording: every directory
below an unreachable root inherits nothing, so that case is the whole outcome of
the claim rather than one skipped path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oes not own

A claim over a tree owned by a third party applies its registries and its
SELinux label, grants nothing, and closes with a checkmark. The commonest way
to reach that state 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 helpers' owner guard and svc's session cannot enter the project.

The claim now checks the root's owner before its first registry write and
refuses, naming the chown that makes the tree claimable -- transferring a tree
recursively needs an authority this CLI does not hold. The no-sudo-grant
refusal's two-step sandbox route gains that chown for the same reason: the
clone belongs to whoever ran the create, so the claim after it would have been
refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reachability reused the target backstop to vet an ancestor, so an operator's own
home root was refused and every project at /home/<user>/<proj> reported
permanently unreachable with a sandbox clone the only way in. The two decisions
are not the same size: a claim rewrites group, mode and ACLs across a tree, while
this grants one `--x` entry on one directory -- search permission conveying no
listing of it and nothing about the files inside, whose own modes still decide.

ai_tools_traverse_grant_allowed states that rule once, beside the backstop rather
than in place of it: a directory the acting operator owns, permitted as a
protected path only when it is that operator's own home. Every system directory,
/home itself, and any other account's home root stay refused, and the target
backstop is untouched.

The grant now applies through a runas seam, so a claim made for another operator
can set an ACL on ancestors that belong to them instead of warning on each one,
and the prompt states the condition that decides what becomes reachable --
naming the find that answers it for this host rather than asserting exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A destructive verb needs a decision a reflex cannot supply, which a yes/no
confirm cannot express: a confirm exists so that Enter can mean something.
ai_tools_msg_challenge asks the user to re-type a name and returns success only
on an exact match, with no default at all -- so a mismatch, an empty answer,
closed input and an absent terminal are all "no". That settles the unattended
case without a rule of its own, and makes a command behind one unreachable from
cron by construction rather than by convention.

The typed answer is untrusted input reaching a log sink, so a mismatch is
recorded through the shared allowlist sanitizer and a length clamp, the same
treatment every other untrusted string gets: a crafted answer cannot inject a
terminal escape into the root-owned trail an operator later reads. Without the
sanitizer the answer is omitted rather than recorded raw; the decision is
recorded either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--project-create was an alias for --project-claim, so the one thing its name
promises was the one thing it could not do: `realpath -e` refused a path that
did not exist, and the documented example in the install guide named a new
project it would have died on.

It now creates: one mkdir, an empty git repository, a README.md carrying the
directory's own name, then the ordinary claim flow on the result -- one
implementation of what claiming means, not a second. Every filesystem step runs
as the operator the run acts for, so a create for a service account produces a
tree that account owns and the claim's helpers can act on.

Two refusals define the verb. A path that already exists is refused, naming
--project-claim: the operation that grants an agent access to an existing tree
should not be reachable by a typo, and a half-finished create is recovered with
a claim rather than a re-run. A parent that does not exist is refused rather
than created, so a mistyped path surfaces instead of becoming a manufactured
tree with a claimed project inside it -- which also settles what a failure part
way through would have had to clean up, since only one directory is ever made.

Refusals leave nothing behind, the reachability pre-flight included: it declines
a location the agent could never enter and names an alternative only after
checking that one on this host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--project-remove was an alias for --project-unclaim, so the verb named after
removal was the one that removed nothing. It now unclaims and deletes, and
--project-unclaim stays the non-destructive reversal its refusals point at.

Its authorization is an exact allowlist entry and nothing else. There is no
--force: that flag exists on unclaim to reach a tree the allowlist does not
name, and "delete a tree nothing registered" is not an operation to offer -- it
is an unclaim plus an rm the operator types themselves. An ancestor, a path
inside a project, and an unregistered path are each refused with the command
that does apply. So is an exact entry that contains another claimed project,
which rm -rf would take with it and leave registered, git-trusted and labelled
at a path that no longer exists.

Two properties carry the rest. A read-only pre-flight refuses up front when any
directory is not deletable by the acting owner, so the one failure a destructive
verb must not have -- a tree deleted down to the first directory it could not
enter, with no registry entry left to find the remains by -- cannot happen. And
teardown runs registries first, deletion last, so a failure at the end leaves an
unregistered tree: less access, not more.

Nothing is deleted without a terminal. The confirmation defaults to No and the
typed-name challenge has no default at all, so both decline independently; only
this command's own -y pre-answers them, and with -y a path argument is required
so an unattended run can never delete the directory it happened to start in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--help had grown longer than the command summary it introduced: every per-verb
option was listed there and again in the man page, so the two drifted and the
sync test enforced the drift by requiring their option sets to be equal in both
directions -- which made moving anything out of the help fail as a stale man
entry.

--help is now orientation: the verbs grouped by what they are for, one line
each, the three flags that cross verbs, and a pointer to the page. The page is
the one reference for options, and gains real COMMANDS entries for the two verbs
that were documented as aliases, an example for each, and the runas grant a --for
create or remove needs.

The sync test's contract changes to match. Verb sets must match in both
directions; every option the help names must be documented; and every option the
page documents must be one a CLI parser accepts -- the direction that catches
what actually goes stale, an option outliving its parser, rather than one the
help deliberately no longer mentions.

Per-verb options stay nested under their verb in the page rather than moving to
a flat OPTIONS list, which would separate --branch or --dir from the only command
they mean anything for; --stop's two flags are nested to match, having been the
one set sitting as siblings of the verbs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference layers still described --project-create and --project-remove as
aliases, and the invariants the two new verbs rest on were stated nowhere.

cli.rule.md splits the four project verbs into four entries and gains the two
mechanisms they introduced: the claim's owner rule, and the runas seam with why
`sudo -u <target>` is not a new grant but is a distinct sudoers question from
the helper grants. safe-paths.rule.md gains the traverse-grant predicate beside
the target backstop, stating what separates them -- one vets a whole tree as an
elevated target, the other one `--x` entry on one directory -- so a reader meets
the home-root carve-out as a bounded second rule rather than a hole in the
first. ownership-and-hooks.rule.md records that the owner-guard skip is now
counted and reported.

CLAUDE.md places --project-remove outside the "every refusal moves to less
access" table explicitly: it decides what is destroyed rather than what a session
may reach, so its safe direction is inaction and its authorization is a registry
entry plus a typed confirmation. Left implicit it would read as an exception to
a stated invariant.

The lifecycle guide gains a section per verb, the install guide's example now
describes what --project-create does rather than what it did not, and the README
names the new-project one-liner beside the claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four assertions the verbs shipped without. Two runtime: the deletability
pre-flight refuses an undeletable tree up front with the tree intact and still
registered -- driven with -y, so it also pins that the pre-flight is not
something -y skips -- and a completed removal deregisters before it deletes.

Two boundary, run as the agent, because CLAUDE.md asks each guarantee to be
asserted from both ends: the sandbox account is refused both verbs by the
principal guard, and cannot reach the `sudo -u` seam they use under --for. The
runtime half catches a host someone has already broken; this half catches the
agent trying to break it, and --project-remove is the verb where that matters
most, since reaching it would let a session 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dropped

unreg_allow rewrote the allowlist with `sed -i` and trusted it. `sed -i` writes
its temporary file into the file's own directory, so on a config directory the
operator cannot write it fails while the allowlist itself is writable -- and
under `set -e` that aborted the whole command with sed's bare I/O error and its
exit status, telling the operator nothing about what was left registered.

That mattered little while only --project-unclaim reached it. For
--project-remove it is the step the teardown order is built around: the entry is
the agent's launch gate, so deleting the tree past a failed de-registration
strands exactly the entry the ordering exists to drop.

The removal is now verified by re-reading the file rather than inferred from an
exit status, and a failure refuses with the line to delete by hand. The
integration fixture that surfaced this is corrected -- its registry now lives
where the operator can write, as it does on a real host -- and the failure it
was accidentally reproducing is pinned as a test of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deletability pre-flight walked the project and missed the one directory that
decides whether the last step can run. `rm -rf <d>` finishes by unlinking <d>
from the directory containing it, which needs write and execute on the PARENT --
not on the project, and the parent is not part of the tree the walk inspects.

That produced precisely the outcome the pre-flight exists to prevent, and worse
than the case it did cover: rm descends, deletes every file successfully, and
fails only on the top directory, leaving an empty husk whose registry entries
have already been dropped. The parent is now checked first and separately, with
a refusal of its own -- the remedy is not --reclaim, the parent never having been
the project's to reclaim, so it names --project-unclaim instead.

The integration fixtures move under a directory the operator owns, since removing
a project needs write permission on its parent; the old layout put them straight
in the root-owned testdir, which is the refusal above rather than the case they
were written for. That shape is now asserted on its own, contents included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wers

A create refuses a path that exists, so the tree it claims is empty by
construction -- and three of the claim's questions were being asked about it
anyway.

The proceed confirm is gone, along with the warnings that justified it: every
sentence in them ("MODIFIES group, permissions and ACLs throughout this tree",
"NOT reversible", "Back up first") is false for a directory that 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 was costing a sudo PASSWORD prompt to search a
directory the tool had just created. 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 offered a choice between one real option and one that costs
something for nothing.

The verb therefore takes no -y either; it would pre-answer nothing. The one
prompt that remains is the traverse grant, which widens access ABOVE the project
on directories that do exist, and stays answerable only by a person.

What licenses the skips is tree_is_pristine, which the claim re-derives from the
tree itself -- no file outside .git but README.md, and no commits -- rather than
trusting the caller's hint, because what it gates is the secret scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cases

Every --project-create assertion so far was a refusal, and a verb that does
nothing satisfies all of them at once. The happy path is now driven end to end
and unattended: the directory, the git repository, the README naming it, the
allowlist entry, and operator ownership of the tree -- plus that no sudo
password prompt appears, which is what the skipped secret scan buys. It runs
under setsid with no -y, so a create that started asking something again would
block and be caught rather than quietly regress.

Three removal cases are the ones a careless or scripted operator actually
reaches. AI_TOOLS_ASSUME_YES must not delete anything: it is a legitimate export
for unattended runs of every other verb, and if it reached either of this one's
prompts it would silently destroy projects on a host where nobody typed -y. A
protected path must exit 3 even when an allowlist entry names it, since that
file is hand-editable and the backstop has to stand on its own -- driven against
/etc, and against the operator's home only in the /home/<user> shape the
home-root rule covers by construction, since the case plants an entry and passes
-y. And -y must pre-answer the two prompts and nothing else, so a scripted
removal pointed at an unregistered path is still refused.

tree_is_pristine gets a unit test of its own, because what it gates is the
secret scan: the cases driven are the states that must read as NOT pristine --
any file beyond the README, one nested deeper, and any commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…7 host

Everything --project-create seeds was born under the caller's umask, so on a
host whose umask is 077 -- the /etc/login.defs default on many -- the directory
came out 0700, README.md 0600, and git init's .git 0700/0600. 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, so the claim that follows
granted nothing and said so in a NOTICE. The verb registered a project whose
README the agent cannot read and whose .git it cannot use, having just reported
that it was normalizing both.

The modes are now set rather than inherited: mkdir -m 0750, chmod 0640 on the
README, and chmod -R g+rX on .git -- group read and traverse only, since write
comes from the claim's ACL exactly as it does for the work tree. 0750/0640
rather than 0770/0660 because group write 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 directory created a moment ago by a command whose
purpose is to give the agent somewhere to work -- and for anyone who typed
--project-create the answer would be the same every time. Where the umask would
have sealed it, the create says what it did instead of asking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t the code

The create happy path asserted that no password prompt appeared anywhere,
meaning to check that the secret scan was skipped. But a claim legitimately
sudo's for safedir, setgid, setfacl and relabel -- group ownership cannot be
changed to a group the operator is not in without root -- so the assertion
failed on the four prompts it was never about. It now names ai-tools-lockdown
and the scan's own pending line, which is the property in question.

The owner-only check had the same shape of error in reverse: it grepped for the
bare phrase, which the create itself prints when it explains the modes it set on
a umask-077 host, so it asserted the opposite of its intent on exactly the hosts
it exists for. It now matches the claim NOTICE's own title.

It also asserts what the umask fix guarantees: nothing the verb seeds is
owner-only, and the claim does not report the new project as out of the agent's
reach. That is what fails on a umask-077 host if a mode is ever inherited again.

Failure messages no longer paste the whole run. These flows print thirty-odd
lines of headline blocks and per-step results, which buries the one line that
explains the failure -- worst of all in the runner's end-of-run summary, which
reprints FAIL lines. A `brief` helper shows the lines the assertion is about
when it has a pattern for them, and the last three non-empty lines otherwise.

The tree_is_pristine fixtures are rebuilt as root and handed over once, the way
the repo fixture above them already is. Driving each mkdir and git through
runuser made every fixture line a command that could abort the file under set -e
for reasons unrelated to the predicate -- which is what happened: the chown named
the operator's name as the group rather than their primary group, and the file
died with no FAIL line to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A live run exposed three faults in one flow. Its worst: with a mistyped
password, all four root steps failed, each printed a warning naming the command
to run by hand, and the claim still closed with "✓ claimed" -- over a project
with no group, no ACL and no label, which the agent cannot enter. The four
helpers each ended their failure branch in `say`, returning 0, so the claim had
no way to know anything had gone wrong. They now signal, the Apply block counts,
and a non-zero count closes with what is still pending and exit 1. This is the
owner guard's rule at the other end of the same flow: no success mark over a
project the agent cannot work in.

Second, the prompt storm. Every step authenticates separately and nothing can be
pre-authenticated -- a hardened sudoers may set timestamp_timeout=0, where a
credential is never cached and every invocation prompts -- so one wrong password
cost three attempts per step: nine prompts and three warnings, the last arriving
long after the flow had already reported two failures. The first failure now
asks once whether to try the rest, default NO and the no-terminal answer.
Stopping applies fewer steps, which is the safe direction, and costs nothing
because a re-run is idempotent. It is asked rather than inferred because a
mistyped password and an absent grant are indistinguishable at that point.

Third, --project-create printed a pending block and then the claim printed
another, so one command showed the same kind of block twice. The create takes no
confirmation, so announcing three steps whose results follow immediately
underneath was the same information twice; its review block is gone and the
result lines stand alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claim was not the only flow that swallowed a root helper's failure and then
reported success. Auditing every sudo site found the same shape in three more,
and one of them is worse than the original.

unclaim_one printed "✓ unclaimed" whether or not the filesystem hand-back ran.
That step is what revokes the agent's access to the FILES; everything else it
does is registry work, which stops a session launching there but leaves the tree
group-owned by the sandbox account. So the misreport runs the dangerous way
round: a claim that under-applies leaves the agent too little access, which is
inconvenient, while an unclaim that under-applies leaves it access the operator
has just been told was removed. It now reports the gap and returns non-zero, the
batch loop counts such targets, and the verb exits non-zero. It is also the
worst prompt storm -- called once per target, so an ancestor unclaim over three
nested projects asked twenty-seven times.

sandbox_finalize closed with "sandbox ready" when safe.directory could not be
added. A clone exists to run git in, and without that entry the agent's git
refuses the tree as someone else's -- ready for everything except its purpose.

--project-remove keeps its ✓, since the tree really is gone, but now names the
cleanup that did not run.

The reaction itself is single-sourced as note_root_failure and asks once per RUN
rather than per step or per project. What a partial result MEANS stays with each
caller, because the safe direction differs: a claim stops, an unclaim drops its
registries anyway, a removal deletes anyway.

Two call sites needed guarding rather than fixing: reg_safedir and unreg_safedir
now signal failure, and both were called bare, where set -e would have turned a
warning into an aborted create or removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things a live run with a wrong password showed.

The modes note read as an unfinished sentence ("...which the claim honours as a
seal and grants nothing on") and explained a mechanism the operator has no
reason to care about. It now states what was set and why in one plain line.

ai-tools-relabel narrates its own success to stdout, which landed unindented in
the middle of a flow block whose result lines are all indented, immediately
before the caller said the same thing. Its stdout is suppressed; stderr stays,
since that is where a failure explains itself.

Three warnings embedded a sudo command in the wrapped emitter, which breaks it
across box lines and makes it uncopyable -- the exact case messaging.rule.md
reserves a plain line below the frame for. Each command moves there.

--project-remove closed with a green check mark over a run in which two cleanup
steps failed. The tree was gone, so the mark was defensible, but no reading of a
✓ covers "and two steps did not happen": it is now reserved for a clean run,
while a run with failures states both facts and exits non-zero, which is also
what lets a script tell them apart.

That closing line also read as though "reports" were part of the command, and
implied --list was scoped to the project just removed. The command is on its own
line now, and the text says the listing covers every project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The .TH check enumerated the shapes it would accept -- the raw
@AI_TOOLS_VERSION@ token or a version number -- and so rejected `dev`, which is
what a source install of an unstamped tree substitutes, what ai-tools --version
reports there, and what ai_tools_msg_version passes through deliberately.

The check reads the repo source when there is one and the installed copy
otherwise, so whether it saw a token, a number or `dev` depended on how the host
was provisioned rather than on anything about the page: it passed on a container
that kept its checkout and failed on one that did not. Neither outcome said
anything about the man page.

It now asserts what it always meant -- the field is non-empty -- and a failure
names which of the two files it read, since that is the first thing worth
knowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cmd | grep -q pattern` under `set -o pipefail` reports the pipeline as failed
when grep matches EARLY: grep exits on the match, the producer is still writing,
takes SIGPIPE, and pipefail returns its 141 for a grep that succeeded. It needs
the producer to outrun one stdio buffer, so it fires on the large listings and
never on the small ones -- which is why it reads as a flaky host rather than as a
bug in the code.

It cost the container selftests a red run: unit/man.sh's .TH check piped a 24 KB
man page into `grep -q` for a line-5 match, so the verdict was a coin flip
(~50% on the dev host, near-always on the EL9/EL10 runners, never on Fedora).
605f5d3 read that as a version-stamp shape and fixed something else; the page was
always fine.

The same shape was live in five `semodule -l | grep -q` probes. A real module
listing is several hundred names and every ai_tools* name sorts early, so those
report a LOADED module as ABSENT at random -- silently costing an entrypoint
relabel its file-context registration, or the dotnet integration its label step.
Each now captures the listing and matches it from a here-string, which is fully
written before grep starts.

tests/unit/selinux-groups.sh pins the probe against a 600-line stubbed listing,
driven 25 times because one green run says nothing about a race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file is the agent's launch gate, and three components wrote it: the CLI
appended and ran a hand-escaped `sed -i`, the ai-tools-allowlist root helper did
a read-transform-rename, and install.sh matched with `grep -qxF` and deleted with
a `sed` of its own. Three matchers against a file whose readers share one grammar
-- so a line carrying a comment or quotes was already invisible to install.sh,
and a writer that matches differently from the reader is a project that stays
reachable after a "removal".

conf.lib.sh now owns the edit: _state, _add, _remove, _enable, _disable, beside
the matchers those readers already share. Each verifies by re-reading the file
and separates applied (0) from could-not-write (1) from does-not-apply-from-this-
state (2), so a caller can tell "not listed" from "the write failed" -- which
send an operator to different places.

Two rules live there rather than in any caller, so no writer can skip them:
_add REFUSES a disabled path, because appending under a winning '!' leaves both
lines present and the exclusion still deciding, and _remove takes BOTH line kinds,
so de-registering a parked project leaves no '!' to park whatever is claimed at
that path next. _enable additionally collapses an existing duplicate pair to one
live entry, in the earliest position it held.

_enable and _disable edit the line IN PLACE -- position, indentation and
end-of-line comment survive -- which is why they are not an add+remove pair: an
operator whose allowed-projects is an ordered, commented document gets it back
byte-identical after a park and a restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ai-tools-allowlist and install.sh now call conf.lib.sh's editing functions
instead of carrying their own. The helper keeps what is actually its job --
deciding who may edit whose registry, and recording it -- and gains --enable and
--disable, the privileged half of the CLI's new verbs: a --for target's
allowed-projects is as much a curated document as the invoker's, so root editing
it must leave the line where the operator put it.

install.sh's de-registration of its own checkout was the narrowest of the three
matchers: `grep -qxF` saw only a line spelled exactly as the install directory,
so an entry carrying a comment read as absent and the operator was never asked.

The helper also inherits the library's refusal to add over an exclusion, which it
had the same way the CLI did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prefixing an allowed-projects line with '!' to take a project out of service is
a workflow that predates any verb for it, and the CLI could not read it: a path
was listed or absent, so a parked project was indistinguishable from one that was
never claimed. A claim appended a duplicate over an exclusion that still won and
reported success; --project-unclaim and --project-remove refused a parked project
as "not a claimed project"; --lockdown and --reclaim said the same while the root
helpers, which resolve an owner through the same matcher, would have exited 0
having done nothing.

The CLI now reads three states, and reports entry state apart from effective
reachability -- a project whose own line is clean can still be parked by an
ancestor or a glob, and calling that one "enabled" sends an operator hunting
through their own file.

--project-disable and --project-enable make the edit a verb. Both are
registry-only: group, ACLs, setgid and the label are untouched, so re-enabling
grants nothing that was not already granted and neither runs the secret gate.
What disabling costs is stated where the operator will read it -- the ownership
handback stops restoring files written under that path.

--project-unclaim --keep-entry serves the release cycle: hand the files back with
clean permissions before a release, keep the project's line parked in place, and
claim it again for the next stage without it moving to the end of the file.

A '!' line means two things -- a parked project, or a carve-out withholding a
subtree from an enclosing project -- and after the edit they are the same text.
Rather than guess, no verb writes an ambiguous one: --project-disable refuses a
project nested inside another, so --project-enable can refuse every exclusion
inside a listed project as the carve-out it must be. Lifting one is the only
registry edit here that WIDENS what the agent reaches, so it stays with the
editor it was written in. Parking or restoring a nested project by hand is
unaffected.

A parked project is answered up front in the claim, ahead of the proceed confirm
-- a run with no terminal answers that first, so a check behind it would never be
reached by exactly the runs that most need telling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
p4nda and others added 16 commits August 29, 2026 19:02
Both flows asked their own question first and met the exclusion afterwards, at
the registry write. A run with no terminal answers the proceed confirm first and
takes its default NO, so the check behind it was never reached by exactly the runs
that most needed telling -- and an operator at a terminal was asked to approve a
claim before being told the project could not be launched in.

The claim now answers it beside require_claimable_owner, and the unclaim before
its own confirm. Both are preconditions of the run rather than steps inside it:
while the '!' stands the root helpers resolve no owner for the path and do
nothing.

Declining on the unclaim no longer aborts. That was the one wrong answer
available: the registry reversal is what moves to less access, so a decline keeps
it -- the entry dropped, or parked under --keep-entry -- and gives up only the
hand-back, reported as not having run with the two commands that complete it and
a non-zero exit, exactly as a hand-back that was wanted and failed already is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four tables decide who may run each verb, whether root may, whether it runs on an
unprovisioned host, and whether --for applies. Two of them were case blocks
embedded in the gates, so the whole set could only be checked by reading it --
and the failure is silent and one-directional: a verb added to the dispatcher and
forgotten in the operator gate runs for an unenrolled caller, with nothing to say
so until a root helper refuses it midway.

The two case blocks become named arrays beside the two that already were, and the
test asserts membership in both directions: every dispatched verb is classified
(operator-acting or informational), no verb is both operator-acting and
root-allowed, no table names a verb that no longer exists, and the help lists
exactly what the dispatcher accepts -- which, with man.sh's usage-to-page check,
makes the three surfaces agree transitively.

Driven against two mutations to confirm it fails for the right reasons: a verb
wired into dispatch and left out of every gate, and an operator-acting verb added
to the root carve-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The launch gate is where --project-disable's promise is kept, and nothing
asserted the two halves agree about the same file: the CLI writes the line and
the wrapper reads it, each covered alone. wrapper.sh now drives the verb against
its hermetic HOME and then the deployed wrapper -- refused while parked, launching
again after --project-enable, so an edit that left the line subtly different shows
up as a project that no longer launches.

cli.sh gains the destructive verb's changed authorization (a parked entry
authorizes a removal, and the run still deletes nothing without a terminal), the
scope of --keep-entry (unclaim only; the removal refuses it, since an entry kept
for a deleted tree parks a path that no longer exists), and the case where an
entry is clean but an ancestor exclusion still parks the project.

Two assertions I wrote were wrong rather than the code: the unwritable-directory
case cannot be driven as root, which ignores the write bit, so it runs as the
projects user; and a substring match against the testdir matched the fixture's own
entry.

verify-live-flows.sh gains the park/restore round trip against the operator's real
allowlist -- no sudo, and it proves the line comes back byte-identical at the same
position -- plus an opt-in --for-drill for the two verbs that act on the
filesystem AS another operator. That one needs a second enrolled operator and a
Runas grant, neither of which this script may manufacture, so it uses one that
already exists and skips with the reason otherwise.

The drill builds its project in the shared clone area rather than the workspace,
which is not a detail: --project-create --for runs its mkdir AS the target, and
the target cannot write inside the invoking operator's home. It is the one
section that writes outside the workspace, and the header says so where the
promise is made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapper refused both with "excluded by '!' rule", which is true and useless:
one is a subtree an operator withheld from a project, where the remedy is to edit
that line, and the other is a project they parked, where it is one command. An
operator standing in the second was left to work out which they had -- and the
screen they would otherwise reach offers to CLAIM a project that is already
claimed.

Exact-match alone cannot separate them: a carve-out names its own path too. So the
gate applies the same test the CLI does -- an approved project strictly above it
makes the line a carve-out, none makes it a parked project -- and names
--project-enable only in the second case.

The wrapper test drove this out. Its park assertion was a substring match, which
the fixture's own carve-out line satisfied, so it passed while the verb had not
run; it is anchored to a whole line now and prints the CLI's output when it fails.
And the classifier's first draft read "${allowed[@]:-}", which expands an EMPTY
array to one empty element and makes the ancestor pattern /* -- matching every
absolute path, so a parked project on a host with no approved entries at all would
have reported as carved out of nothing.

Also drops the executable bit tests/unit/cli-verbs.sh was created with;
unit/source-modes.sh is what noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapper keys its allowlist off ${HOME}; the CLI resolves the invoking user's
home through `getent passwd`, deliberately, so that nothing in the environment can
redirect a registry write. Setting HOME alone therefore steered one of the two:
the wrapper read the fixture while the CLI read -- and would have edited -- the
real allowlist, where the temp project is of course absent, so --project-disable
refused it as unregistered.

Both are now pointed at the same file, with AI_TOOLS_ALLOWLIST alongside HOME. The
asymmetry is recorded in tests.rule.md beside the existing ${HOME} note, since a
test that steers only one of them writes the operator's own registry while
appearing hermetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…up front

ai-tools-relabel read a single allowlist -- the PRIMARY operator's, via
ai_tools_load_operator -- and refused anything not in it. Every other per-project
helper resolves the owner per path (ai_tools_resolve_owner, over every operator's
registry); this one was the exception, and the exception is wrong: the entry that
authorizes a label lives in whichever operator's allowlist holds the project.

So on a multi-operator host the label step failed for a secondary operator's own
claim, and for every `ai-tools --project-claim --for <op>` -- the flagship reason
--for exists. The rest of the claim succeeded, leaving a project registered,
git-trusted and ACL'd but unlabelled, which under enforcing SELinux is a project
the agent cannot work in. The claim reported it correctly ("1 step(s) that grant
the agent access did not apply"), so nothing was silent; it simply could not
succeed.

Two conditions hid it: CI containers have SELinux disabled, where the helper exits
before the gate, and a single-operator host is always its own primary. It took a
live enforcing host with two enrolled operators -- the --for drill in
verify-live-flows.sh, run for the first time -- to reach it.

The gate keeps both of its stages: the resolver says which registry covers the
path (honouring exclusions, covering subtrees), then an exact-entry check requires
a registered project ROOT in that registry rather than something under one.

Covered from the refusal side in unit/relabel.sh against the deployed helper; the
accepting branch registers a semanage rule, which this suite does not do to the
host's policy store, so the multi-operator positive stays with the live drill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page had grown by insertion: create was documented before the model choice
that decides which command to run, the reversal material sat four headings deep
under one "Recovery and reversal", --force appeared as an aside on the first
screen, and unclaim had no section of its own while its classification table,
its flags and its scripting notes each had one.

It now follows the lifecycle: choose a model, create or claim, work (prompts,
re-claim, sealing), clone, park, release, delete, act for another operator, then
the permission reference and the boundary. Each section opens with a runnable
command and headings stop at two levels. A state diagram and one table replace
the two overlapping summaries at the top.

The content is the same, with three additions: --for, which the guide had never
covered though it is where --project-create and --project-remove differ from
every other verb (they act on the filesystem as that operator, so they need a
Runas grant, and the tree has to go where that account can write); the parked
state and its launch refusal; and --keep-entry.

README leads with --project-create -- one command, no prompts, nothing
pre-existing to review -- and keeps --project-claim as the next paragraph, where
saying what it reviews is the point rather than a digression.

multi-operator.md records per-path owner resolution as a property of the model,
and tests.rule.md enumerates unit/cli-verbs.sh with the failure it exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es access

Two things a new reader has to get right, and the page left both to inference.

"re-claim it" as the reversal of an unclaim reads as --reclaim, which is a
different command that reverses nothing: --project-claim run again is what brings
a project back. The table now names the command, the two instructions that said
"re-claim" name it too, and one callout states the difference where a reader first
meets both.

--reclaim is also easier to place once it is tied to the mechanism it belongs to:
it is the on-demand form of the per-turn handback the agent's hooks already run
through the socket, over the same root helper, catching what a killed session left
behind and the .git tree the per-turn passes skip.

And the release section now says which routes into the tree actually come off:
the group owner (the one that closes the standing route -- clearing the ACL alone
would leave the agent the group bits of a tree still owned by group ai-tools), the
ACLs including the default one, group write and directory setgid, and the SELinux
label with the allowlist entry. The order is stated because it is load-bearing:
the hand-back runs while the entry is still present, since ai-tools-unclaim
refuses a target the allowlist does not name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reg_filemode ran git as the invoking user, so a claim for another
operator probed a tree it may not traverse -- reporting "not a git work
tree" -- or was refused the write into a .git/config the target owns.
Every call now goes through run_as_owner, the seam reg_reach already
uses. Why --project-claim stays out of require_runas_target is in
cli.rule.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provisioning gate refused every verb but the three diagnostics, so a
host whose install never finished answered `ai-tools --help` with a
refusal naming ai-tools-bootstrap -- the only place the command could
still be found. Usage and version read no installed state, so they join
the exempt set along with the bare invocation. cli-verbs.sh pins the
membership; the reasoning for the set is in cli.rule.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
install.sh runs under IFS=$'\n\t', where "$*" joins on a NEWLINE, so any
call passing more than one word would break its message across lines.
Every current call site passes a single argument, so this closes the
class before it is hit rather than fixing a visible defect. Same local
IFS guard as the CLI's join_words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full run prints hundreds of result lines and the state word is what a
reader scans for, so harness.sh colours that word alone -- green, yellow
and red. Messages stay plain, which leaves a grep on a result message
unaffected.

run.sh and install.sh each pipe the suite through tee, making the
harness's own tty test read false while a terminal is still watching, so
both hand their answer down through AI_TOOLS_TEST_COLOR. run.sh's
failure summary now allows the escape ahead of FAIL: anchored without
it, the summary comes back empty on a coloured run while the suite still
reports the failure. The coupling is in tests.rule.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pin_agent_entrypoint re-hashed the entrypoint and refetched the vendor's
signed manifest on every run, so one upgrade paid for it several times
-- the .path watcher can fire repeatedly for a single change, and the
agent package's %post runs on every update. On an air-gapped host each
of those spent two connection timeouts per agent only to leave the pin
as it was.

A run may now answer from the pin when the installed version, the
entrypoint's bytes, and an INPUTS digest over the manifest URL, the
signing key's path and content, and the declared fingerprints are all
unchanged. A pin recording no digest is never reused, so every way the
digest can fail costs a re-verification rather than granting a
shortcut.

The reuse is opt-in and the operator's route cannot inherit it: sudo
scrubs the environment, so `ai-tools --relabel` re-checks the signature
every time, which is what it documents and what an operator runs it
for. What the shortcut gives up -- a vendor republishing a release it
already signed -- is in updater.rule.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@p4nda
p4nda merged commit 19ec190 into main Aug 31, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant