Commit Graph
12 Commits
Author SHA1 Message Date
Hermes Agent 1bfd0ddd71 wrapper-guard: an arm may only claim the span its wrapper actually covers
ci/woodpecker/pr/ci Pipeline was successful
Round eight replaced an absence-driven allow with an endpoint inventory, and
review found the inventory answered the wrong question. It recorded which
wrapper TOUCHES an endpoint, when the sound question is whether the wrapper
SPANS it. A PATCH to a numbered milestone blocked with "use milestone-close.sh".
That wrapper takes only -t <title> and sends state=closed on both the gh and tea
paths, so it cannot express a title, description or due-date edit. The block was
correct and the advice was not — the same remediation-accuracy defect as the
round-seven subresource arm, one step quieter, because a wrapper was rounded up
from owning a slice to owning the endpoint.

The treatment already existed two arms away: /pulls/{n} named pr-close.sh for
state and said in the message that a PR title/body edit is a real wrapper gap.
So this was a consistency failure rather than a missing idea, which is why the
fix is not just the reported arm. Auditing every arm for span against the flags
each script accepts found a second bad one that review had not reached:
/pulls/{n}/requested_reviewers was mapped to pr-review.sh, and pr-review.sh
takes -a <action> -c <comment> and files a verdict. Nothing in this tree adds a
requested reviewer, so that arm was advertising a wrapper that cannot make the
call. It is unowned and now flows through, like a comment edit.

Changes:
  - /milestones/{n} keeps blocking, and the message states that milestone-close.sh
    owns the close only while title/description/due-date is a wrapper gap.
  - /pulls/{n}/requested_reviewers becomes residue, above the reviews arm so it
    cannot be refused with "use pr-review.sh".
  - /issues/{n} now also names issue-assign.sh, which owns the assignee field;
    issue-edit.sh has no assignee flag, so the old advice was short by one wrapper
    for a PATCH that sets one.
  - The map comment carries a span column, so a future arm has to state what its
    wrapper covers rather than imply all of it.

Fixtures assert the span language, not just the wrapper name: the milestone edit
must say it owns the close only, and a PATCH setting an assignee must name
issue-assign.sh. Negative-controlled — reverting each of the three behaviours
fails that fixture and only that fixture.

92/92 (was 89), locally and in ci-base. shellcheck clean at warning+. The
18-command ordinary sweep blocks the same three round-six flips and nothing new.

Gates: fixtures 92/92 in ci-base, shellcheck clean at warning+.
2026-08-12 18:36:23 -05:00
Hermes Agent a3cacac7fb wrapper-guard: make subresource ownership an inventory, and check the advice
ci/woodpecker/pr/ci Pipeline was canceled
Round seven fixed "wrong wrapper advice" by letting every path under a numbered
issue or PR flow through, on the stated reasoning that no wrapper owned any of
them. Review checked that reasoning against the directory and it was false:

  gh api -X PATCH repos/a/b/issues/1 -f title=x
  curl -X PATCH -d @b https://host/api/v1/repos/a/b/issues/1
  gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug
  gh api -X POST  repos/a/b/issues/1/assignees -f assignees[]=u

issue-edit.sh takes --title/--body/--labels/--milestone and issue-assign.sh
takes assignee/labels/milestone, so all four are wrapped calls and all four
returned 0. The guard answered "allow" because wrapper ownership had been
ASSUMED absent rather than looked up — the same absence-driven allow this file
exists to remove, committed inside the fix for it. I withdraw the round-seven
departure: the reviewer's position was right on the evidence, and my argument
for it was sound reasoning applied to a fact I never checked.

The endpoint map is now an inventory read off tools/git/*.sh and their flags:
assignees to issue-assign.sh, labels to issue-edit.sh (naming issue-assign.sh
alongside it, since both set them), a numbered issue to issue-edit.sh (naming
issue-close.sh/issue-reopen.sh for state), a numbered PR to pr-close.sh (with
the PR title/body gap stated in the message rather than papered over), and
/milestones/{n} to milestone-close.sh instead of the create wrapper.

The residue is defined by SUBTRACTION, not by listing provider API surface:
everything a wrapper owns is consumed by an arm above, so a numbered path that
reaches the end is owned by nothing and still flows through — times, stopwatch,
reactions, a comment edit at /issues/comments/{id}. A list would rot the moment
a provider adds an endpoint, and rot in the blocking direction with wrong advice.

That residue test is a regex, deliberately. `case` globs cannot express a path
SEGMENT, so the natural allow arm *"/issues/"[0-9]*"/"* clears

  gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"

on the strength of a slash inside the body. An allow decided by a glob over the
whole command is the fail-open shape again; the regex pins the segment to the
number, and that command is pinned as a fixture.

Also: `-f labels[]=bug` was not read as a body at all, because the key class
stopped at the bracket. The array spelling is what the provider CLIs use for
repeated fields, so an implicit POST carrying only array fields was invisible.

And the reason six rounds of this were invisible: the harness read the exit code
and nothing else, so a block naming the WRONG wrapper passed every run. Fixtures
may now state the wrapper the message must name, and the wrapped ones do. The
assertion was negative-controlled — pointing one fixture at the wrong wrapper
fails that fixture and only that fixture.

89/89 (was 79), locally and in ci-base. All eight sanitization commands green
in-image. The 18-command ordinary sweep blocks the same three round-six flips
and nothing new, so the tighter map cost nothing on ordinary work.

Gates: sanitization (all eight green in ci-base), shellcheck clean at warning+.
2026-08-12 18:25:13 -05:00
Hermes Agent b4578dcd0a wrapper-guard: close the absence shape at the new boundary; stop giving wrong advice
ci/woodpecker/pr/ci Pipeline was successful
Round six deleted the code/data parser and scoped what remained on `https?://`.
Review found the failure class had not been eliminated, only relocated: a raw
provider CLI carries no scheme, so the scope gate answered "not my business"
because the URL was ABSENT — the same shape, at the new boundary.

  gh api -X POST repos/a/b/issues -f title=x -f body=y
  gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE
  tea api -X POST repos/a/b/issues/1/comments -f body=x
  curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues

All four were real writes to endpoints a wrapper owns, and all four passed.
Constitution gate 7 names raw provider CLIs explicitly, so they are in scope
rather than something to narrow the docs around. The scope gate now also
triggers on `/api/v{n}` and on the `api` subcommand of the provider CLIs, and
`-f key=value` joins curl's `-d` as an implicit POST. Adding triggers to a scope
gate can only make it stricter — it cannot open a new hole — which is why this
is a list of shapes rather than a model of any one caller.

The boundary is stated in the file rather than left to be discovered: provider
PORCELAIN (`tea pulls create`) is NOT covered, because catching it means
modelling every CLI's verb grammar, which is the parser mistake wearing a new
costume. That is a wrapper-and-review gap, not a thing this hook can hold.

Second finding, and the one I had flagged as my own worry: the endpoint `case`
was prefix-greedy, so `/issues/1/labels` blocked with "use issue-create.sh" —
the wrong wrapper for that call. A block an agent cannot comply with is worse
than no block, because it teaches that the hook is broken and the override is
routine, and an override that is routine is a guard that is off. Issue and PR
subresources now flow through, exactly as /releases and every other endpoint no
wrapper owns already does. This hook enforces "use the wrapper"; where there is
no wrapper it has nothing to enforce, and the gap belongs in the wrapper set.

I am departing from the review on that one deliberately: the review held that
blocking is correct there and only the remediation wrong. Naming a wrapper gap
in a refusal keeps gate-7 pressure, but it makes the override the normal path
for every labels and assignees call, which spends the override's meaning on the
cases where it is least needed.

Also: the APPROVE trap now catches the provider-CLI spelling `-f event=APPROVE`
alongside the JSON body, and still never matches the correct value APPROVED.

79/79 fixtures, locally and inside the CI image, with both blockers pinned in
both directions — the four repros block and name the right wrapper, while a
provider-CLI read, an unwrapped endpoint reached through one, porcelain, and the
`rm -f`/`grep -f` collisions all still pass. The 18-command ordinary sweep
blocks the same three round-six flips and nothing new, so the broader gate cost
nothing on ordinary work. Second over-block documented rather than found: prose
carrying `.post(` near a wrapped URL is refused, which follows from judging the
payload and is now stated next to the quoted-curl cost.

Gates: sanitization (all eight commands green in ci-base), shellcheck clean.
2026-08-12 18:10:19 -05:00
Hermes Agent b1254f52f3 wrapper-guard: judge the payload, not the caller — delete the code/data parser
ci/woodpecker/pr/ci Pipeline was canceled
Round-five review found command substitution executing inside the very quoted
spans the skeleton was discarding as prose:

  echo "$(curl -d@b .../issues/1/comments)"
  msg="$(curl -d@b .../issues/1/comments)"

The unquoted and process-substitution forms already blocked, so the same call
was refused or allowed depending on a quote character. That makes it a
classification defect rather than another spelling, and it is the nineteenth
write to reach execution through this file by the same route: the client was
ABSENT from the skeleton, so the guard allowed.

The reviewer's judgement, which I asked for and accept: this is fitting to the
test set. Answering "code or data" from shell text with sed and awk is not a
hard problem, it is the wrong problem.

It was also not portable. CI has been red at `sanitization` since round four,
and the log says why: under the image's busybox awk the octal escape in the
quote-stripping regex does not bite, every quoted span survives into the
skeleton, and the guard began refusing ordinary prose. Five allow-direction
fixtures failed in CI that pass under GNU awk. A control that reverses its
verdict with the awk on the host is not a control.

So the client detection is gone — the skeleton, the invoker list, the prefix
list, the option-value skipping, all of it. What remains asks two questions of
the text: is this a write, and does it name an endpoint a wrapper owns. It
cannot fail open by hiding the caller because it never looks for one, and it
now catches clients it was never taught: `python -c ... requests.post(...)` and
`wget --post-data` are both fixtures.

The cost is stated in the file and pinned in both directions: QUOTING one of
these calls on a Bash command line is refused as well. Ten fixtures that used
to assert "discussing a call is not making one" now assert the opposite, and
the boundary that stops this becoming block-everything is asserted just as
hard — a quoted READ, an endpoint named without a body flag, a quoted write to
an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The
18-command ordinary-work sweep blocks none.

The rule an agent can hold without a parser: do not put a raw write to a
wrapped forge endpoint on a Bash command line, not even inside quotes. Write
the example with a file-writing tool.

60/60 fixtures, verified inside the CI image (busybox) as well as locally.
Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), issue-close, prettier.
2026-08-12 17:59:14 -05:00
Hermes Agent 2a2a87251a wrapper-guard: read the command the shell will run, and stop losing the client behind option values
ci/woodpecker/pr/ci Pipeline failed
Round-four review, three more absence-driven allows.

1. The guard read the command as TYPED. A backslash before a newline is removed
   before anything else happens, so an endpoint token split across the join
   (`.../iss\` + newline + `ues/1/comments`) executed the comments endpoint while
   the literal token never appeared in the text. Continuations are now joined
   before every check, because the joined form IS the command. This is the same
   defect as the split-across-variables case, minus the excuse: there the token
   genuinely does not exist until the shell expands it, here it was sitting in
   the input the whole time and the guard chose the wrong reading of it.

2. Transparent prefixes take option VALUES. `sudo -u root curl` hid a live write
   because `root` was a word the prefix list did not know. Enumerating option
   grammars per prefix is the wrong game, so what is skipped is an option and at
   most one value for it, plus a bare duration for `timeout` — never an
   arbitrary word. `xargs echo curl ...` therefore stays ALLOWED, because there
   the command is echo and the client is its argument.

3. `find -exec` runs the client. It opens command position the same way an
   operator does, and now reads that way.

All seven reviewer repros are fixtures, each with its counter-case in the
allowed direction: a continuation inside a heredoc document stays a document,
`xargs echo curl` stays allowed, `sudo apt-get install curl` stays allowed, a
prefixed READ stays allowed. 48/48, and the 18-command ordinary sweep still
blocks none.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
2026-08-12 17:46:25 -05:00
Hermes Agent e6a881a795 wrapper-guard: judge command position on prefixes and on what a shell will execute
ci/woodpecker/pr/ci Pipeline failed
Round-three review found two more absence-driven allows, both in the skeleton
introduced by round two, and fixing them exposed a third the reviewer had not
reached yet.

1. A word in front of a command does not displace the command. `env VAR=v curl`,
   `command curl`, `timeout 10 curl` and `/usr/bin/curl` were all real writes at
   execution position that a bare-name match could not see. The `env` form is the
   one that matters: it is what an agent reaches for to keep a credential out of
   the global environment, so the careful spelling was the invisible one.

2. Quoted data stops being data when a shell is about to execute it, and the
   first version knew only `bash -c`, `sh <<` and `eval`. It did not know the
   pipe, which is the form people actually use: `printf ... | sh`, `cat <<EOF | sh`,
   `sh -s <<EOF` each made a live call vanish from the skeleton while still
   running.

3. Found while testing the fix: that decision was made for the WHOLE command, so
   a single unrelated `docker run ... sh -c 'echo hi'` promoted every other
   quoted span on every other line to code. It blocked its own author for the
   second time in a day. A shell on one line does not execute a string on another
   line, and over-blocking is not the safe direction — a guard that blocks
   ordinary work gets switched off, and a guard that is off permits everything.
   The skeleton is now built per line, and a heredoc body is code only when the
   line that opened it fed a shell.

All seven reviewer repros are pinned as fixtures, each with a counter-fixture in
the allowed direction: `echo timeout 10 curl ...` is not a call, a pipe to `wc`
is not execution, an unrelated shell on another line changes nothing. Fixtures
40/40, and a sweep of 18 ordinary commands blocks none of them.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
2026-08-12 17:39:05 -05:00
Hermes Agent 7962e4302f guard: judge command position on the code, not on the text
ci/woodpecker/pr/ci Pipeline was canceled
The position test added an hour ago blocked its own author. The message being
sent quoted one of the fixtures, so the quoted text contained an operator
followed by a client, and an operator inside a string is not an operator. That
is the reported over-blocking defect one level in, and it landed within an hour
of shipping the fix for the reported one — which is the argument for pinning
both directions as fixtures rather than reasoning about them.

Position is now judged against a SKELETON: the command with its data spans
(quoted strings, heredoc bodies) removed. Endpoint, URL and body detection keep
running against the full text, because real calls quote their URLs and a
skeleton would be blind to them.

The exception is what makes quotes data in the first place. If something is
about to EXECUTE the quoted text — `bash -c`, `sh <<EOF`, `eval` — the quotes
hold code, and the skeleton keeps them as command separators so the client
inside is still at command position, one interpreter down.

Three fixtures: an operator inside a quoted string, a heredoc body, and
`bash -c` making the same text code again. 30/30.
2026-08-12 17:29:33 -05:00
Hermes Agent 8a901cc19a guard: read command position, refuse unreadable URLs, survive pipefail
ci/woodpecker/pr/ci Pipeline was canceled
Round two of the same independent review. Three findings, all real, and the
first two share a root cause: the guard was reading command TEXT as though it
were a command.

1. Splitting the endpoint token itself defeats fragment matching outright —
   `a=/api/v1/repos/o/r/iss; b=ues/1/comments` leaves no fragment contiguous.
   Round one fixed one spelling of this and the reviewer produced the general
   form immediately. It is not winnable by more fragments: the endpoint does
   not exist until the shell expands it, and this hook runs first. So the guard
   stops pretending to read it. A write whose URL contains an expansion, on a
   visibly forge-shaped command, is now BLOCKED as unreadable — because "I
   could not find an endpoint" must not mean "there is no endpoint". Opaque
   URLs that are not forge-shaped (webhooks, artifact stores) still pass.

2. The broadened body detection false-blocked ordinary work: `grep -R "curl -d
   https://.../issues" docs/`, `echo "curl -d ..." > note.txt`, printing an
   example from python. Talking about a call is not making one, and this is the
   direction that actually kills a control — an over-blocking hook gets turned
   off, and an off hook permits everything. The client must now appear at
   COMMAND POSITION: line start or after a shell operator, optionally behind
   VAR=value. In every false positive it sat behind a quote instead. Quotes are
   deliberately NOT stripped before matching; real calls quote their URLs.

3. `wt_precious()` aborted `cmd_rm` under `set -euo pipefail`: `grep -v` exits 1
   when it filters everything out, which is exactly the disposable-only case,
   so a SAFE worktree failed to remove with no message. Fixed, and the same
   defect was latent one step upstream in `wt_dirty()`, where `head -200`
   SIGPIPEs git on any worktree with 201 changed files. The cap is gone —
   counting is cheap and the cap only ever truncated output that is no longer
   printed.

Seven new fixtures pin all of it, in both directions. 27/27.
2026-08-12 17:26:09 -05:00
Hermes Agent 8b7ac5b51e guard: close four fail-open holes found by independent review
ci/woodpecker/pr/ci Pipeline was canceled
An independent reviewer broke all three new controls before they shipped.
Every finding is reproduced as a fixture or a repro, because the class is
recurring rather than incidental: each hole was a case where the answer was
"allow" because something was ABSENT rather than because it was CHECKED.

1. wrapper-guard read only the spellings it knew. `curl -d@body` (no space),
   `--request=POST` (equals form), and a URL path assembled from shell
   variables each carried a real provider write straight through. Write
   detection now covers every body and method form curl accepts, and the
   endpoint match no longer anchors on a literal host path that a variable
   can dissolve.

2. wrapper-guard blocked only when the wrapper FILE existed. A host with a
   broken or partial install therefore permitted exactly the raw writes the
   guard exists to stop. Blocking is now on the endpoint; a missing wrapper
   changes the remedy text, not the verdict — a broken install is not
   permission to bypass gate 7.

3. mosaic-worktree read a worktree's safety from two questions, and a clean,
   fully-pushed tree holding a gitignored `local.secret` answered both with
   zero. `git worktree remove` then deleted the one copy in existence. A file
   is gitignored precisely so nothing else holds it, so ignored-but-not-
   disposable files are now a third evidence question. Build junk
   (node_modules, .venv, dist, caches, *.pyc) stays disposable, so the common
   case still reads SAFE.

4. check-tools-index counted a documented tool as discoverable at mode 0644.
   Every caller tests `[ -x ]`, so a non-executable tool is a missing tool;
   it now fails the gate with its own message.

Local gates green: sanitization, resident budget, test enumeration,
tools-index (4/4 self-test, 100% on the enforced git suite), and
wrapper-guard 20/20.
2026-08-12 17:20:11 -05:00
Hermes Agent 96609bdade framework: prove the wrapper guard both ways, and resolve its wrappers relatively
ci/woodpecker/pr/ci Pipeline failed
Two defects found by running the guard rather than reading it.

1. The guard resolved its sibling wrappers through a hardcoded
   $HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a
   CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed,
   and the guard fell through allowing the raw API write it exists to block. It
   failed OPEN, silently, in exactly the environment least likely to notice.
   It now resolves relative to its own path, so it names the wrappers from the
   install it was launched from, with $HOME as the fallback.

2. There was no test. Adding one surfaced the guard's other sharp edge
   immediately: it matches the literal text of the Bash command, so a harness
   that embeds a blocked pattern inline trips the guard on itself rather than on
   the fixture. That is the correct fail-closed posture and it is now recorded in
   the test's own comments, because the next person will hit it too.

test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as
hard as the blocked ones. A guard that over-blocks gets routed around and a guard
that under-blocks is decoration; only pinning both edges keeps it useful. It is
hermetic — no network, no credentials, no repository — so it joins the CI
sanitization step directly rather than the exclusions file.
2026-08-12 16:54:18 -05:00
Hermes Agent e3a0ee87b3 framework: make tool discoverability, workspace placement and model tiering mechanical
An undocumented tool is, from inside an agent session, indistinguishable from a
tool that was never written. The framework shipped 26 git wrappers and named 6 of
them in its resident index docs — 23% discoverability, with pr-review.sh among the
missing. The observable consequence was an agent obeying Constitution gate 7 as
best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea
host, and getting HTTP 200 with the review silently filed PENDING. Three times.
That is not a discipline failure and no amount of prose fixes it.

Four changes, each converting a rule that decayed into a mechanism that cannot:

- check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be
  named in a resident index doc, and every tool an index names must exist. The git
  suite is enforced now; other suites report coverage without failing, so the
  ratchet tightens one reviewed PR at a time instead of landing as one sweep. The
  enforced list is framework-owned rather than a marker inside operator-owned
  TOOLS.md — a doc marker would let an operator silence the gate on exactly the
  host where it matters most. Carries --self-test, because a checker that only
  ever passes is indistinguishable from one that is not running.

- TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE
  dialect note that explains why pr-review.sh is not a formality.

- mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a
  work filesystem" already existed in prose, and 255 GB accumulated in $HOME across
  842 directories anyway, under five simultaneous placement conventions on one
  host. The helper therefore exposes no placement decision — given a branch name,
  every path is derived from `git worktree list --porcelain`. Worktrees rather than
  clones because enumerability is the only thing that makes reclaim safe, and
  reclaim is by evidence (clean tree + no unpushed commits), never by size or age.
  The guard blocks three mechanically-detectable mistakes and nothing else:
  a checkout into $HOME, a raw provider-API write to an endpoint that has a
  wrapper, and the literal APPROVE event. Reads pass untouched.

- STANDARDS.md: model tiering as a standard, named by capability class so it
  survives a model generation. Start cheapest, escalate on evidence, benchmark
  before demoting a task class, and keep the class->model binding in operator
  config with the DB-backed config service as the end state.

Registering the guard in runtime/claude/settings.json is the point of upstreaming
it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there
is destroyed by the next upgrade. In the template it survives, and it reaches every
host instead of one.
2026-08-12 16:51:17 -05:00
Hermes Agent 880c28b191 docs(glpi-skills): genericize operator-specific content per review
ci/woodpecker/pr/ci Pipeline was successful
2026-07-20 19:45:50 -05:00