Compare commits

..

31 Commits

Author SHA1 Message Date
Hermes Agent
8ac7e70f0d fix(git): fail closed on YAML anchors in tea token fallback (#865 round 12)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
The PyYAML-absent fallback's _strip_properties consumed a plain anchor
('&name') per-scalar, with no document-scoped registry of declared anchor
names. Real PyYAML's Composer rejects a duplicate anchor NAME declared
anywhere in the document (ComposerError, whole-document fail), but the
fallback would still emit the later token: fail-open (HIGH). Fix: reject
every '&'-anchor property unconditionally instead of building a hand-rolled
duplicate registry, which guarantees zero fail-open by construction at the
cost of a deliberate, endorsed over-reject on a single non-duplicated
anchor. The '!' non-specific-tag branch is unchanged ('! x' -> 'x' still
resolves).
2026-07-22 03:07:56 -05:00
Hermes Agent
2d821324fe fix(git): PyYAML-absent fallback parity for quoted-scalar breaks and tag/anchor properties (#865 round 11)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Two residual over-rejections in the PyYAML-absent fallback of
get_gitea_token_for_login, both fail-closed today but diverging from
real PyYAML 6.0.3 for valid inputs. Neither is a fail-open; the fix
loosens ONLY to exact PyYAML parity and keeps failing closed elsewhere.

Blocker A - embedded printable line break inside a quoted scalar.
The fallback split the raw document with str.splitlines(), which breaks
at NEL(U+0085), LS(U+2028) and PS(U+2029) -- all PRINTABLE to PyYAML's
Reader. Inside a flow (quoted) scalar PyYAML does not break at these: it
line-folds a double/single-quoted scalar (NEL/LF/CR -> one space; LS/PS
verbatim), resolving one token, while splitlines() cut mid-quote and
failed the whole document closed. Replace splitlines() with
_split_logical_lines, a quote-aware splitter that reproduces PyYAML's
flow folding (scan_flow_scalar_spaces/breaks) and raises on a col-0
doc marker (---/...) in a folded continuation. The same code points
UNQUOTED or in a comment still make PyYAML raise, so those stay closed.

Blocker B - leading non-specific tag / anchor property. The recognizer
blanket-rejected any scalar starting with '!' or '&'. But '! ' (bang +
space) and '&name ' are transparent node properties: PyYAML strips them
and applies normal implicit typing, so '! x'/'&a x' resolve the string
'x'. Add _strip_properties, consuming <=1 non-specific tag and <=1 anchor
(either order) and recursing on the remainder. '!x' (tag handle),
'!foo x', non-string nodes ('! 123'), duplicate properties and explicit
tags ('!!str x') still fail closed.

Fallback now matches real PyYAML 6.0.3 (oracle) on a two-direction
differential fuzz of the full Unicode line-break set x {double, single,
plain, comment, token-position} and the full leading tag/anchor space:
833 cases, 0 fail-opens, 0 present-mismatches, 52 endorsed fail-closed
over-rejects (blank-line-in-quote \n folds; explicit !!str/verbose tags;
property+quoted; anchor-without-space). Wrapper scripts pr-review.sh and
issue-comment.sh unchanged (0-line diff). Tests: additions-only sections
23 (quoted-scalar breaks) and 24 (tag/anchor properties).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 02:24:33 -05:00
Hermes Agent
0905cdc292 fix(git): match PyYAML Reader.NON_PRINTABLE and block-context plain rules in tea token fallback (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round-10 auditor blockers in the PyYAML-absent fallback of
get_gitea_token_for_login (detect-platform.sh):

Blocker 1 (fail-open): the round-9 control-char guard used a hand-rolled
C0/DEL subset that missed code points PyYAML's Reader also rejects -- the
C1 block (U+0080-0084, U+0086-009F), the surrogate range, and the BMP
noncharacters U+FFFE/U+FFFF -- so the fallback still emitted a token from
documents PyYAML rejects whole. _FORBIDDEN_CONTROL now uses PyYAML 6.0.3's
EXACT Reader.NON_PRINTABLE character class (negated), verified 0-mismatch
against real PyYAML across the full BMP + astral range.

Blocker 2 (over-reject): the recognizer blanket-rejected any plain scalar
containing a flow indicator, but in BLOCK context (where a tea config
value always sits) PyYAML treats ',[]{}' as ordinary content and treats
'!&*|>#%@`' and quotes as significant only at the first non-space char.
The blanket scan dropped tokens PyYAML emits verbatim (internal '!', ','
'[' ']' '{' '}' '#'-not-space-preceded, ':'-not-space-followed). Removed
it; the leading-char guard and the ' #' / ': ' / trailing-':' guards keep
the fail-open direction shut.

Tests: additions-only sections 21 (printable-boundary fail-close for C1 +
noncharacters; NEL/U+00A0/astral still resolve) and 22 (internal
indicators resolve; leading indicator / ': ' inline map / trailing ':'
fail closed). Verified vs real PyYAML 6.0.3 both directions (present as
oracle, absent via PYTHONPATH shadow): wide 602 cases 0 fail-open/0
present-mismatch, dense 712 cases 0 fail-open/0 over-reject/0 mismatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:19:27 -05:00
Hermes Agent
acf7955f87 fix(git): fail closed on forbidden control chars, fix float exponent + '?' over-rejection (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round-9 auditor blockers, both fixed at root cause in the PyYAML-absent
line-parser fallback of get_gitea_token_for_login:

1. Control-character fail-open (dangerous): PyYAML 6.0.3's Reader rejects
   the WHOLE document (ReaderError) if a forbidden C0/DEL control byte
   {0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F} appears ANYWHERE in the raw
   stream -- plain scalar, inside quotes, or a comment -- regardless of
   position, verified empirically against the real installed PyYAML 6.0.3.
   The fallback previously only guarded tabs and emitted the login token
   from such documents. Added a whole-document _FORBIDDEN_CONTROL scan on
   the raw text (before splitlines(), which itself splits on some of the
   same bytes) so the fallback fails closed identically to PyYAML.

2. Unsigned-exponent float over-rejection: PyYAML's implicit float
   resolver requires an EXPLICIT sign on the exponent; unsigned-exponent
   spellings (1.0e10, +1.0e10, -1.0e10, 1.0E10, .5e10, 4.e8) are PyYAML
   STRINGS, not floats. The fallback's _IMPLICIT_FLOAT pattern allowed an
   optional sign, misclassifying these as floats and dropping the token.
   Tightened the regex to match PyYAML's resolver exactly.

Also folds in a residual over-rejection found via this round's wide
differential fuzz (1767 cases, 0 fail-opens / 0 over-rejections after the
fix): a plain scalar starting with "?" not followed by whitespace (e.g.
"?x") is a valid PyYAML string, but the fallback's blanket-reject set
treated every leading "?" as illegal. Narrowed to match the existing
space-aware handling already used for "-" and ":".

Adds regression fixtures to test-gitea-login-resolution.sh covering all
three fixes under both PyYAML-present and forced-ImportError-absent runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:39:37 -05:00
Hermes Agent
6053e1ee4c fix(git): fail closed on tabs the YAML fallback recognizer would swallow (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
PyYAML raises a ScannerError on a tab used anywhere outside a quoted
scalar (leading/trailing/embedded in a plain value, immediately before or
after a key colon, or as indentation) and yields no token, accepting tabs
ONLY inside single/double-quoted scalars. The conservative block-YAML
fallback in get_gitea_token_for_login normalized those tabs away -- via
_scalar's top-level .strip(), _KEY_RE's [ \t] separators, _Parser.__init__'s
body.rstrip(), parse_seq's body[1:].strip(), and the inline-map emptiness
checks -- and still emitted the login token: a credential fail-open in the
dangerous direction (less conservative than PyYAML).

Extend the whole-document "only ever more conservative than PyYAML, never
less" invariant to tab/scanner parity:
- _KEY_RE now uses SPACE-only separators (` *:` / `[ ](.*)`), so a tab in a
  key/value separator makes the line fail to match and the caller fails closed.
- Every whitespace-normalization site strips SPACES only (strip(" ")/rstrip(" "))
  so a tab survives to a fail-closed guard instead of being silently removed:
  _scalar top-level strip, quoted-trailing strip, post-comment strip,
  _Parser.__init__ body rstrip, parse_seq item strip, and the three inline
  emptiness checks.
- _scalar fails closed on any tab remaining in a plain scalar.
Tabs strictly inside quoted scalars are preserved verbatim (unchanged parity),
matching exactly what PyYAML accepts.

Verified empirically against PyYAML 6.0.3: ScannerError for each rejected
tab position; string-preserved for quoted inner tabs. Differential fuzz with
tab re-included: 0 fail-opens over ~6000 inputs; 0 over-rejection across 220
PyYAML-accepted quoted-tab cases. Adds section 17 to the regression harness
(fail-close fixtures for trailing/leading/embedded/after-colon/indentation
tabs; parity fixtures for double- and single-quoted inner tabs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:05:39 -05:00
Hermes Agent
044744339b fix(865): fail closed on non-constructible typed scalars and bare indicators
Round-8: the PyYAML-absent conservative recognizer proved STRUCTURE and
implicit-resolver TYPE but not CONSTRUCTOR VALIDITY. PyYAML safe_load raises a
constructor ValueError on the WHOLE document when a plain scalar matches a typed
implicit resolver but is not constructible (e.g. bad calendar date 2023-99-99,
empty-radix ints 0b_/0x_), yielding no token; the fallback instead treated such
a scalar as a null-equivalent, ignored the malformed key, and still emitted the
valid login token -- a credential fail-open in the dangerous direction.

_scalar now checks constructibility via _constructible (replicating PyYAML
6.0.3 construct_yaml_int/float/timestamp, stdlib-only): a typed scalar that is
not constructor-valid returns _FAIL, which the parser turns into _Bail so the
WHOLE document fails closed exactly as PyYAML does. int and timestamp resolver
patterns are byte-identical to PyYAML's; the float pattern is a strict superset
whose extras are all float()-constructible (fuzz-verified 0/400k raise), so it
never fails closed where PyYAML would emit.

Also fail closed on plain scalars beginning with an indicator a plain scalar may
not start with: '%' (directive) and ',' (flow), and the conditional block
indicators '-'/'?'/':' when followed by whitespace or end-of-value (bare
sequence/complex-key/value indicators PyYAML rejects), while '-x'/'-1'/'?x'/':x'
remain valid plain strings.

Differential fuzz through the real function: 0 fail-opens across 1499 diverse
non-tab cases; targeted fixtures assert fallback == PyYAML == fail-closed for
2023-99-99, 2023-13-01, bad-hour timestamp, 0b_, 0x_, 0x__, %broken, ',bad',
bare '-'/'- '/'? key', and assert the token STILL resolves for constructible/
string look-alikes (valid date/datetime, 0o_, 4.e8, 0x1f, -x, :x). Both PyYAML
present and forced-absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:42:31 -05:00
Hermes Agent
4822291707 fix(git-wrappers): #865 round-7 addendum — conservative YAML recognizer + review/comment hardening
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Fold the remaining round-7 audit items into the tea-CLI comment-invocation fix.

Fallback token parser (detect-platform.sh, test-gitea-login-resolution.sh):
Replace the line-by-line scalar fallback with a strict CONSERVATIVE block-YAML
recognizer that reconstructs the same object PyYAML would or fails closed the
instant it meets anything outside the tea-config subset. Closes 5 structural
fail-open classes the old parser missed (nested-shadow logins, block-scalar
shadow, duplicate root key / login name / token field, malformed-after-valid,
extra-document / end-marker). Validated by a 360k-check differential fuzz vs
real PyYAML (0 fail-open) plus explicit forced-PyYAML-absence fixtures.

ITEM 1 (pr-review.sh) current-head TOCTOU: after the exact review-id read-back
succeeds, re-read the live PR head and fail closed if it advanced past the
submitted commit_id, so a review is never reported as covering a superseded tip.

ITEM 2 (pr-review.sh comment action): require the returned resource be a
pull_request (populated pull_request_url); reject a bare issue_url so a plain
issue #N cannot masquerade as a verified PR comment. issue-comment.sh keeps its
broader issue-or-PR acceptance.

ITEM 3a (both wrappers): move the Authorization bearer OUT of curl argv into a
private mode-0600 curl --config file (gitea_write_auth_config), removed on every
exit path, so the token never appears in the process table.

ITEM 3b (pr-review.sh): bind the review body with presence + string-type + exact
equality instead of `(body or "")`, so a non-empty submitted body persisted as
null/missing fails closed.

Tests: add race, plain-issue, argv-capture (no token printed), and null-body
fixtures; broaden temp-leak checks to the auth-config files. Full gate set green
(bash -n, shellcheck -x -S warning, prettier, all 3 REST/resolution suites with
PyYAML and forced-absent, cold TURBO_FORCE turbo 14/14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:01:21 -05:00
Hermes Agent
3012b5c5e5 fix(git): fail closed on unquoted non-string YAML token scalars (#865)
Round-7 blocker-1 residual: the PyYAML-absent line-parser fallback in
detect-platform.sh (_strip_scalar) returned a stringified scalar for
UNQUOTED YAML values that PyYAML's implicit resolver types as a
non-string (int/null/bool/float/timestamp). That bypassed _accept's
isinstance(str) guard and could surface a garbage credential (e.g.
"12345", "null", "true") where the PyYAML path resolves NO token and
fails closed -- violating the module invariant that the fallback is only
ever MORE conservative than PyYAML, never less.

Root cause fix: mirror PyYAML 6.0.3's SafeLoader implicit resolver. An
unquoted plain scalar matching the null/bool/int/float/timestamp forms
now returns None (fail closed); a quoted scalar is always a string and is
accepted verbatim (quote-stripped) as before. Quoted-string handling,
scope-aware attribution, indentation, and inline-comment stripping are
unchanged. The predicate was fuzzed against real PyYAML over ~800k random
tokens with zero fail-open divergences.

Extends the forced-PyYAML-absence parser-equivalence harness with
token: 12345/null/~/yes/true/3.14 (each fails closed identically to
PyYAML) and token: "12345"/'abc' (quoted literals still accepted).

Blockers 2/3/4 (port-bound host, origin+full-path URL pin, review-body
binding) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:17:09 -05:00
Hermes Agent
99856c5567 fix(git-tools): scope-aware YAML fallback, port-bound creds, origin-pinned URL + review-body verification (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round-6 remediation for PR #866 addressing four cross-host exact-diff audit
blockers (REQUEST_CHANGES governs):

Blocker 1 (detect-platform.sh get_gitea_token_for_login line parser): the
PyYAML-absence fallback attributed any `key: value` at any depth to the current
login, so a token from a nested sub-map or a mis-indented line could be selected
where PyYAML fails closed, and inline comments were not stripped. The fallback is
now scope-aware — a field attaches only at the entry's own direct-field
indentation, only list items at the login list's own dash indent open an entry —
and _strip_scalar strips a trailing inline comment like PyYAML. It is therefore
only ever MORE conservative than PyYAML, never less.

Blocker 2 (detect-platform.sh host bind): credential binding compared
parsed.hostname only, dropping the port, so a :9443 login satisfied a portless
host and a matching :8443 login was rejected. Binding now normalizes scheme +
host + effective port (scheme default applied symmetrically) exactly like
gitea_url_matches_host.

Blocker 3 (issue-comment.sh + pr-review.sh read-back URL check): verification
used path.endswith, accepting a look-alike host or a decoy path prefix. It now
pins the returned issue_url/pull_request_url ORIGIN (scheme+host+effective-port)
and FULL path (deployment prefix + exact owner/repo + kind + number). A new
GITEA_WEB_BASE is exported from gitea_resolve_api_for_login for this.

Blocker 4 (pr-review.sh gitea_submit_review_verified): the submitted review body
was not verified, so a finalized/reused pending review id carrying foreign
Content passed. The persisted body is now bound to the exact submitted body.

Tests: added forced-PyYAML-absence parser-equivalence fixtures (nested sub-map,
sibling, mis-indent, inline comment, tab-indent fail-closed, port match/mismatch)
to test-gitea-login-resolution.sh; URL-forgery fail-closed cases
(wrong-host/owner/repo + prefix injection) to both write suites; and a
reused-review-id body-mismatch case to the pr-review suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:49:59 -05:00
Hermes Agent
2bb3ac4549 fix(git-tools): env-robust token parse, host-bound creds, real Gitea URL verify (#865 round-5)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
CI-red root cause (classification a: my round-4 change fails in the clean/cold
CI env): get_gitea_token_for_login hard-required PyYAML (`import yaml`), which is
absent on CI's node:24-alpine (python3 without py3-yaml). Round-4's --login
override cases were the first to exercise that path, turning the mosaic package
test (test:framework-shell -> test-pr-review-gitea-comment.sh) RED. Fix: add an
indentation-aware line-parser fallback that resolves the SAME per-name token
PyYAML would from tea's flat `logins:` list; PyYAML stays the fast path. This
also repairs a latent production defect (--login overrides were silently
unusable on any PyYAML-less host).

Auditor blockers folded into the same round-5:

1. issue_url vs pull_request_url shape (correctness): Gitea populates WEB (html)
   URLs in issue_url/pull_request_url, not API paths, and a PR-conversation
   comment carries pull_request_url (issue_url empty). Verification now accepts
   either web shape scoped to the repo slug + number, so a durable write is never
   rejected for URL shape. Test stubs now emit the REAL Gitea web shapes.

2. Cross-host credential binding (security): get_gitea_token_for_login now takes
   the repo host and requires the matched login's configured URL host to equal
   it; an override login configured for a different host FAILS CLOSED instead of
   sending a cross-host credential. Regression tests added to both suites.

3. Non-exhaustive enumeration (false-fail): removed the redundant, non-exhaustive
   post-verification list enumeration (gitea_fetch_all + confirm_*_enumerable)
   from both wrappers; the exact-id GET is authoritative. Pagination cases
   dropped; a guard asserts no list enumeration is performed.

4. Trap clobbering / temp-file leak (security/hygiene): removing the nested
   enumeration eliminates the RETURN-trap nesting that clobbered caller cleanup;
   remaining RETURN traps are single/non-nested and clean up on all exit paths.
   Temp-file leak regression tests (success + failure paths) added to both suites.

5. README: corrected the exhaustive-pagination claim and documented host-bound
   --login selection.

Preserves every round-2/3/4 fix (explicit --login fail-closed at all write
sites, token->identity attribution seam). Gates: cold `pnpm turbo run test
--filter=@mosaicstack/mosaic` green (14/14); full test-*.sh suite green with AND
without PyYAML; bash -n, shellcheck -x -S warning, prettier --check README clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:49:48 -05:00
Hermes Agent
6168f9ac86 fix(git-tools): fail closed on unresolvable explicit --login override (#865)
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
gitea_resolve_api_for_login silently fell back to the host-default identity
whenever the named login could not be resolved for ANY reason -- including when
the name came from an EXPLICIT --login override. A caller passing a dedicated
per-role credential could thus have its write attributed to the shared default
identity while being told it succeeded as requested.

Thread an "override was explicit" signal into gitea_resolve_api_for_login
(second param, "explicit" when LOGIN_OVERRIDE is non-empty). When the override
is explicit and that login's token cannot be resolved, FAIL CLOSED (return 1,
clear error naming the login, no host-default fallback). The best-effort
host-default fallback now applies ONLY on the no-override default path. Applied
symmetrically to issue-comment.sh and all three pr-review.sh dispatch sites
(approve / request-changes / comment).

Tests: both scripts' write flows now assert credential attribution via a
token->identity seam in the curl stub -- (a) resolvable --login override drives
the entire write/read-back chain under THAT login's token, nothing under the
default; (b) unresolvable --login override fails closed (nonzero, no success
line, no write, no default-identity request); (c) no-override default path still
succeeds under the host-default best-effort credential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:07:00 -05:00
Hermes Agent
9384f0bc0a fix(tools): write Gitea reviews/comments via REST POST and verify by exact created id (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Replace the tea-based write + boundary/author read-back with a direct Gitea
REST POST that returns the created record's id, and verify that exact record.

BLOCKER 2 (credential ordering): resolve the acting identity, the write token,
and the read-back token from the SAME effective login. A --login override now
selects the credential used for the POST, GET /user, and the GET-by-id
read-back, so an overridden write is verified against the identity that
performed it -- not the host default. Login-name resolution is best-effort and
non-fatal (the override always wins; otherwise fall back to the host
credential), so exotic/ported hosts still resolve a token.

BLOCKER 1+3 (attribution + tautological tests): the write is now
POST /issues/{n}/comments or POST /pulls/{n}/reviews (event + body + commit_id
== PR head), parsing the provider-returned created id. Verification GETs that
exact id and checks author == acting identity and body (comments) or state +
commit_id (reviews). Keying on the created id closes the concurrency window:
a no-op create yields no id and fails closed with no list-scan fallback, and a
concurrent same-identity record has a different id. The review body travels in
the review submit, removing the separate detached comment.

Tests: the curl stub now models a real server with persistent on-disk
review/comment state -- a POST actually creates+persists a record and returns
its id, and the read-back reads that same state (no fabricated record for the
wrapper to find). Adds same-identity no-op-concurrent and author-mismatch
fail-closed cases for both comments and reviews, and >page-1 pagination
coverage for both. README "Durable review provenance" refreshed for the REST
mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:46:32 -05:00
Hermes Agent
16481ece3d fix(tools): attribute read-back to acting identity and paginate fully (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Round 2 remediation for the read-back verification in issue-comment.sh and
pr-review.sh.

BLOCKER A (invocation attribution): id-above-boundary + content/state match
only proves temporal ordering — a concurrent write from a different identity
could satisfy it while this tea invocation created nothing. Both wrappers now
resolve the acting identity once via curl GET /api/v1/user and additionally
require the accepted record's author login to equal that identity. Residual
same-identity same-body/state concurrency is documented in-code (tea 0.11.1
emits no reliable created-record id to close it further).

BLOCKER B (pagination): the comments and reviews list reads now walk every
page (?limit=&page=1,2,… until a short/empty page) for both the pre-write
boundary and the post-write read-back, so a record beyond page 1 is still
found.

Adds regressions: concurrent different-identity write fails closed (comments
and reviews); a matching review beyond page 1 is still found. README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:02:25 -05:00
Hermes Agent
10fdd49e32 fix(tools): bound Gitea read-back to this write; verify approve/reject state (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Review remediation for two correctness holes in the #865 fix:

BLOCKER 1 — issue-comment.sh read-back was body-only across all history:
if `tea comment` silently no-opped (the #865 bug) while an identically
bodied comment already existed from a prior run, the read-back matched the
OLD comment and falsely reported success. Now record the pre-write maximum
comment id as a boundary and require a comment with id > boundary AND exact
body match; monotonic Gitea ids make id > boundary mean "created by this
write". Fails closed otherwise.

BLOCKER 2 — pr-review.sh approve/reject trusted tea's exit code for the
review STATE (same never-trust-exit-zero defect class as #865). Removed the
TODO deferral and added a real bounded read-back: record the max review id
before `tea pr approve`/`reject`, then require a review with id > boundary,
the expected state (APPROVED / REQUEST_CHANGES), and commit_id equal to the
PR's current head. Fails closed if absent.

Tests: extended test-pr-review-gitea-comment.sh to model and assert the new
review-state read-back (guardrails preserved, assertions added). Added
test-issue-comment-readback.sh proving the pre-existing-identical-body
false positive now fails closed and a genuinely new comment verifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:21:02 -05:00
Hermes Agent
a27f1fa7df fix(tools): use top-level tea comment invocation and formalize --login passthrough (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
issue-comment.sh called the non-existent `tea issue comment` subcommand
form; tea 0.11.1 silently no-ops and exits 0 instead of erroring, producing
a false-success write. Switch to the top-level `tea comment <index> <body>`
form and add fail-closed REST read-back verification so the wrapper no
longer trusts tea's exit code alone.

pr-review.sh's comment path was already fixed for this bug by #812/#835
(routes through a read-back-verified REST comment API instead of any
tea comment subcommand); this change formalizes an explicit --login
override flag there too and documents the after-detection last-wins
--login ordering, consistent with issue-comment.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:50:43 -05:00
4e5af23214 Merge pull request 'skills: add glpi-* family (solve, followup, sweep, list, create)' (#863) from feat/glpi-skills into main
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-21 01:09:50 +00:00
Hermes Agent
880c28b191 docs(glpi-skills): genericize operator-specific content per review
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-20 19:45:50 -05:00
Jason Woltje
7bc2dfb6c8 skills: add glpi-* family (solve, followup, sweep, list, create)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
GLPI helpdesk workflow skills written against the portable
tools/glpi/ tooling (session-init.sh, ticket-list.sh, ticket-create.sh),
cross-linked via [[glpi-*]]:

- glpi-solve    — close a ticket by setting status Solved (5); GLPI auto-closes
- glpi-followup — add a followup via the top-level /ITILFollowup endpoint
- glpi-sweep    — read-only hunt for done-but-open tickets needing Solve
- glpi-list     — query tickets by status/recency
- glpi-create   — open a new ticket

Core rule encoded: completing work means setting status Solved, not just
posting a resolution followup (a followup documents; only Solved auto-closes).

Note: illustrative examples in the bodies are USC-flavored (M2M / helpdesk
ticket numbers) and can be genericized in review if preferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GjBgrb9tHgvq414Fqj37c
2026-07-20 18:04:53 -05:00
b0d78d8632 fix(mosaic): de-flake mutator-class lease gate TTL-expiry test (#861)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:32:45 +00:00
344d86a635 fix(#812 follow-up): normalize detect-platform.sh host-match port comparison by scheme (#859)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:13:29 +00:00
acd7d380f6 fix(framework): install deps on worktree bootstrap + legible deps-preflight at gate seam (#856) (#858)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-07-20 09:38:12 +00:00
3b70c66c07 fix(framework): drop unsupported --comment from tea pr approve/reject; route review body via durable comment (#835) (#857)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 09:19:48 +00:00
11d2818453 docs(tasks): FCM-M5-001 done — verified completion evidence (supersedes #848) (#853)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 07:45:08 +00:00
aa999daf1b fix(framework): durable Gitea comment posting in pr-review.sh via REST + read-back verify (#812) (#852)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 06:45:48 +00:00
77c9a82614 fix(lease-broker): de-flake recovery_runtime b2 broker-socket ConnectionRefused race (#849) (#851)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-20 06:44:37 +00:00
627cf2bb38 docs(fleet): add operator configuration guide (#789)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-07-20 05:22:25 +00:00
0582a8912b WI-7 #834: T-C server-side branch-protection posture + R1 honesty amendment (#847)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 04:20:02 +00:00
2509eb7646 WI-6 (#833): constrained recovery command + mosaic-context-refresh skill wrapper (#846)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 03:33:00 +00:00
07553ead33 WI-5 #832: Receipt-challenge protocol (compaction-refresh, milestone 188) (#845)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 23:18:56 +00:00
e522b22fa4 WI-4 (#831): verbatim-hashed normative fragments (B_payload/H_payload) (#844)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 20:34:20 +00:00
e4d7d4502d WI-3 (#830): compaction observers → revoke + D4 same-PID generation auto-revoke (#842)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 19:46:28 +00:00
108 changed files with 11093 additions and 893 deletions

View File

@@ -7,3 +7,4 @@ pnpm-lock.yaml
.claude/ .claude/
docs/tess/TASKS.md docs/tess/TASKS.md
docs/scratchpads/ docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json

View File

@@ -79,6 +79,29 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
--- ---
## Compaction Refresh Trust Lifecycle (M1, #827#830)
### Problem and objective
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
### Requirements
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
### Acceptance criteria
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
---
## Fleet Declarative Configuration Management Workstream (FCM, #758) ## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective ### Problem and objective

View File

@@ -3,9 +3,11 @@
## Compaction refresh lease broker ## Compaction refresh lease broker
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings. - [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk. - [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk.
- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure.
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements. - [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. - [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
## CLI and skill management ## CLI and skill management
@@ -14,10 +16,24 @@
## Fleet configuration management ## Fleet configuration management
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence. - [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation. - [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries. - [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture. - [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
## Official channel plugins ## Official channel plugins

View File

@@ -53,7 +53,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes. > the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | | id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 | | FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation | | FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered | | FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
@@ -62,10 +62,10 @@ Active workstream is **W1 — Federation v1**. Workers should:
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered | | FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered | | FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only | | FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | | FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | | FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | | FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI | | FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
## Thin-core prompt diet (#528) — feat/contract-thin-core ## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

@@ -0,0 +1,59 @@
# Compaction observer revocation and runtime generations
WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket.
## Observer matrix
| Runtime | Lifecycle signal | Action |
| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Claude / Claudex | `PreCompact` | Revoke the current lease before compaction. A non-zero hook result blocks the lifecycle transition. |
| Claude / Claudex | `SessionStart` with matcher `compact` | Revoke again after compacted context starts. |
| Claude / Claudex | `SessionStart` with matcher `resume\|clear` | Atomically advance the private generation, then revoke the replacement incarnation. |
| Pi | `session_before_compact` | Revoke before compaction; return `{ cancel: true }` if revocation cannot be confirmed. |
| Pi | `session_compact` then the first `context` | Arm and run an independent post-compaction revoke. A failed post observer blocks later tools locally until a retry succeeds. |
| Pi | `session_start` with reason `reload`, `new`, `resume`, or `fork` | Atomically advance the private generation, then revoke the replacement incarnation before reuse. |
The first observer that reaches the broker deletes pending promotion tokens and makes the lease `UNVERIFIED`. The second compaction observer is deliberate redundancy, not a prerequisite for the first. Claudex receives the same mandatory hooks in its isolated `CLAUDE_CONFIG_DIR`; hook merging preserves unrelated isolated settings and rejects malformed or symlinked settings fail-closed.
## Private generation authority
`launch-runtime.py` still registers before `exec`, preserving the kernel-authenticated PID/starttime anchor. It now also creates `generation-<broker-session>.state` beside the broker socket. The file is owner-only mode `0600` under the broker's mode-`0700` directory. Hook descendants read that file instead of relying only on an immutable inherited environment value.
Generation changes use an exclusive file lock, validate owner/type/mode/size, increment monotonically, truncate and write the complete new value, and `fsync` before contacting the broker. Therefore reload, new-session, resume, and fork events may retain the same PID/starttime while still becoming a new broker incarnation. The higher generation causes the broker to atomically discard prior tokens and lease authority; the replacement generation inherits no VERIFIED lease.
If an observer fires while broker transport is unavailable, `revoke-lease.py` advances the private generation as a local fence before returning non-zero. Every later all-tools gate reads that higher value. When the broker is reachable again, authentication of that value performs the same old-generation revocation before authorization. Pi also keeps a process-local post-compaction/rollover failure latch that blocks tool calls. An unsafe or unreadable generation file itself makes both lifecycle revocation and tool authorization fail closed.
## Threat contract and stopping boundary
### BOUNDED RESIDUAL STALE WINDOW
If **both** pre- and post-compaction observers are missed entirely, no revocation signal exists. During the remaining unexpired lease, **within-TTL consequential actions are allowed**. Their count and timing are **bounded by lease expiry, not by the mutator gate**. WI-3 makes no claim that it bounds mutator actions inside this stale interval. The broker's monotonic lease TTL is capped at 300 seconds; after expiry, the next consequential tool is denied with `LEASE_EXPIRED`.
This is the named D2-v5 T-A residual. It is distinct from an observer that fires but cannot contact the broker: the latter creates a local generation fence and fails closed. It is also distinct from T-C total rot, where the lifecycle observers and the all-tools gate are both absent or replaced. Server-side branch protection, required CI, and independent review remain the irreducible backstop for T-C.
| Condition | Result |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Either compaction observer succeeds | Existing lease and pending promotion tokens are revoked immediately. |
| Observer runs but broker confirmation fails | Lifecycle transition is denied where supported; local generation fence and runtime latch prevent inherited authority. |
| Both observers are missed, lease unexpired | **ALLOWED** inside the bounded residual stale window. No within-window mutator bound is claimed. |
| Both observers are missed, lease expired | **DENIED** by monotonic TTL expiry. |
| Generation advances on reload/new/resume/fork | Prior incarnation revoked; replacement starts `UNVERIFIED`. |
| Lifecycle observers and all-tools gate both fail or are removed | T-C total-hook-miss residual; protected-branch controls remain required. |
## T-C server-side branch-protection posture
The required posture is that `main` is push-blocked and PR-only-merge is **MANDATORY**, regardless
of client-gate state. The client-side gate narrows the exposure window only; it is not the T-C
guarantee. The server-side protected-branch configuration is the irreducible guarantee for protected
repository actions. Status-check enforcement and approval enforcement are **RECOMMENDED**.
## Current-vs-required gap (recorded, not enacted)
The current empirical configuration is recorded here without re-probing or mutating live branch
protection. `enable_push=False` (push-block present), so the mandatory push-block/PR-only-merge core
holds. `require_approvals=0` (approvals not enforced), `enable_status_check=False` (status checks not
enforced), and `block_on_official_review=False` (official review not enforced). Those recommended
merge-quality controls are the current gap; changing them is a separate, owner-gated operations
decision and is not enacted by this documentation change.
The permanent T12b/T30 acceptance case prints both required outcomes: dual-hook miss within TTL is **ALLOWED**, and the same lease after TTL is **DENIED**. Separate real-socket tests prove each Claude observer and same-PID generation rollover; Pi lifecycle tests exercise pre/post observers, all four replacement reasons, and local failure closure.

View File

@@ -13,12 +13,23 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`. - `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
- `consume_token`: authenticated identity plus `token`. - `consume_token`: authenticated identity plus `token`.
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token. - `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned.
- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge.
The daemon owns a second protected production observer socket (mode `0600`) unless a private `--test-observer-file` fixture is selected. That transport accepts only the exact `record_runtime_observation` schema after kernel `SO_PEERCRED` plus the existing anchor/ancestry authentication; it validates the pending runtime/generation before storing one finalized assistant entry for the in-process `RuntimeReceiptObserver`. It is **not** a broker request action. Claude sends its latest assistant entry from the Stop-hook transport; Pi sends only finalized `message_end` assistant content. The public broker socket continues to reject request-supplied `latest_assistant_message` in begin, observe, and complete paths.
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible. - `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. - `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority.
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease. - `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Runtime descendants resolve the current generation from an owner-only, locked generation file created by the register-before-exec launcher; reload/new/resume/fork observers advance and `fsync` it before broker revocation. This supports generation replacement even when PID/starttime do not change. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority. VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `begin_recovery` reuses that exact transition and mints a new challenge, so a normal-path receipt/challenge cannot be replayed through recovery. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate.
## Receipt boundary and T-C residual (R1)
Receipt evidence is a T-A delivery/liveness prerequisite only; it cannot replace the mechanical
mutator gate as safety authority. The receipt detects an **ABSENT** or **PREFIX-TRUNCATED** terminal
token. A **MIDDLE-DROP** that preserves the tail is a T-C contract violation that is **NOT receipt-detectable**. It is covered by server-side protected-branch controls, **NOT** by the receipt; no category-wide receipt-detection claim is made for that tail-preserving transformation.
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.

View File

@@ -2,13 +2,25 @@
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields. - Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races. - Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. - Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease.
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
- Framing and persistence failures fail closed. Sensitive tokens are not logged. - Framing and persistence failures fail closed. Sensitive tokens are not logged.
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. - WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary. - The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary.
- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority. - WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority.
- Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. - WI-3 wires redundant Claude `PreCompact`/`SessionStart(compact)` and Pi `session_before_compact`/post-`session_compact` `context` observers to that same revoke action. If broker confirmation fails after an observer fires, the revoker advances the private generation as a local fence; subsequent authorization revokes the stale broker incarnation before any consequential allow.
- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C.
- Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
## Named residual: promote-lease-lost-ACK (WI-3 D2-v5)
A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered.
**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists).
**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant.
**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations.
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.

View File

@@ -21,13 +21,13 @@ The authenticated broker is the sole lease writer:
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED. 3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority. 4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied. The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. WI-3 installs the [compaction observer and generation lifecycle](compaction-revocation.md). Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual: consequential tools are allowed until expiry, with no claimed within-window action bound; once either observer revokes or TTL expires, the next consequential tool is denied.
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate. A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
## Runtime adapters ## Runtime adapters
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants. `launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports the broker-minted session ID and an owner-only generation-file reference to descendants; lifecycle hooks advance that file for same-PID replacement generations.
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. - Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch. - `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch.

View File

@@ -1,109 +0,0 @@
# Gate0 Charter Amendment — Probe-3 (D4) Evidence-Class EXECUTION Authorization
**Status:** DRAFT (revision 2) — supersedes prior candidate pin `8fca5b7a`; pending Mos re-scope-verify BEFORE
re-pin is ratified. Revised per Mos adjudication directive (2026-07-18): §3 = D4-focused Option A; §1 = explicit binding model.
**Amendment type:** Additive durable-authority amendment (does NOT mutate any ratified pinned doc in place).
**Milestone:** 188 — Compaction-Refresh Mechanism · **Issue:** Gitea #827 (WI-0 Gate0) · gates WI-3 #830 merge.
---
## 1. Authorized source (why this amendment is valid)
Per the Mosaic governance principle (OpenBrain `f0cb61b6`, permanent; fail-closed / DO-178C): a durable
Gate0 execution-hold clears ONLY by **(i) explicit user (owner) authorization** OR **(ii) a durable-authority
amendment from an authorized source**. A runtime coordinator's verbal GO does NOT qualify.
- **Owner authorization (i):** Jason (owner / north-star) directed in `#mos` **2026-07-18 17:28Z** — *"amend the
charter"* (option **B** from the WI-3 escalation), quoting the escalation back. This is the direct-user
authorization artifact that homelab's durable-Gate0 objection (2026-07-18 06:34:56Z) explicitly named as a
valid resolution condition (*"user artifact OR auth amendment"*). Relayed + authenticity-adjudicated by Mos
(`web1:mos-claude`). Owner outranks any peer-lane durable authority.
- **Amendment (ii):** this document, authorized BY that owner directive.
Both qualifying conditions are therefore satisfied. Homelab's stated condition is met by the owner artifact;
homelab is NOTIFIED as a transparency step (it holds durable-Gate0 standing and raised the original catch),
but its co-sign is not a gate (owner > peer).
**Binding model (explicit — how this SHA is authorized).** The authority binding for this amendment's pinned
bytes is the composition of two distinct acts:
1. **Owner class-authorization (PRE-SHA):** Jason (owner) authorized the *class of action* — "amend the
charter" (option B), `#mos` 2026-07-18 17:28Z. This occurred **before** these amendment bytes (and hence
this SHA256) existed; the owner authorized the amendment, not a specific hash.
2. **Independent-adjudicator byte-verification:** Mos (`web1:mos-claude`), acting as the independent
adjudicator (author ≠ verifier — MS-LEAD authored, Mos verifies), scope-verifies the **exact bytes** of
this document and confirms they express only the owner-authorized narrow scope.
There is therefore **NO single "Jason-signs-the-SHA" artifact, and none is required** under this binding
model: owner-authorized-class + independent-adjudicator-byte-verify **is** the binding. This is the durable,
inspectable chain of custody for the pin.
## 2. Charter provisions amended (sha-pinned, unmutated)
This amendment attaches to — and does NOT rewrite — the ratified Gate0 charter provisions:
- `compaction-refresh-BUILD-BRIEF.md` §5 / R4 "Gate0 = BUILD-ADMISSION GATE", specifically the item:
*"Same-PID `runtime_generation` bump on reload/resume/fork revokes prior lease (D4)."*
sha256 `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b` (intact/unchanged).
- `compaction-refresh-SPEC-RATIFICATION.md` R4.
sha256 `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67` (intact/unchanged).
The two pinned docs remain byte-identical; this amendment is the delta of record.
## 3. What is authorized (NARROW — D4-focused harness ONLY)
Adjudicator ruling (Mos, `web1:mos-claude`): "D4-only" against the existing runner is non-executable — the
only runner (`pi_gate0_run.py`) drives P2+P5+P6+D4 in one flow, and D4 intrinsically requires a
promoted-to-VERIFIED baseline (spec L219: "P3 immediately follows the valid P2 promotion so reload must
revoke a genuinely VERIFIED prior gen") to revoke a genuine prior generation. Therefore authorize EXACTLY the
minimal executable form (Option A) required as WI-3 (#830) merge-gate (2):
- **D4-focused harness:** spawns **ONLY** `p3_generation_broker.py` (not the full `pi_gate0_run.py` runner).
- **Minimal promotion-to-VERIFIED, as a FIXTURE PRECONDITION ONLY:** established solely so the D4 revoke has a
genuine `VERIFIED` target. This is **NOT** authorization to bank P2 as an independent evidence class.
- **D4 same-PID `runtime_generation`-bump revoke observation:** new generation → `MUTATOR_UNVERIFIED`;
prior (superseded) generation → `STALE_GENERATION`.
- **Explicitly EXCLUDED:** P5 (missing / oversize / hash-invalidation) and P6 (atomic-observation) — not
needed for the WI-3 D4 gate and **not authorized** by this amendment.
- **Isolation / integrity (per §4/§5):** 3× repeated, isolated runs; hardened deterministic harness;
non-destructive; launches broker/socket/state artifacts in an isolated fixture ONLY; **does NOT touch
production**, mutates no live lease/broker/deployment, creates no durable side effect outside the fixture;
a **FAIL** returns to planner — no retry-launder.
**Intrinsic-precondition boundary (verbatim):** the promotion step is a D4 test fixture, not a P2
evidence-gathering authorization; P5/P6 remain unauthorized.
## 4. What is NOT authorized (all other holds remain in force)
- This is **NOT** a blanket Gate0-execution release. Every other Gate0 execution item and every standing
execution hold remains exactly as-is.
- No live mutation, migration, canary, deployment, systemd, tmux-fleet, or connector/socket/Hermes action is
authorized by this amendment.
- Authorizes producing THIS evidence class once (3× isolation is the evidence-integrity requirement, not a
license to re-run on failure): a **FAIL** returns to planner — **no retry-launder**, no severity-laundering.
## 5. Preserved fail-closed / DO-178C invariants (verbatim, unchanged)
- Producing probe evidence **EXECUTES** the Gate0 mechanism (launches processes, creates socket/state
artifacts, exercises revocation transitions); "isolated + non-destructive" does **not** make it
non-execution. This amendment authorizes that execution for the narrow class in §3 — it does not redefine
execution as non-execution.
- A sha-pinned DURABLE authority OUTRANKS a runtime coordinator's verbal GO; when they conflict the durable
artifact wins. This amendment derives its authority from the OWNER, not from a coordinator.
- Card-advancement gates and execution-holds are distinct; this amendment lifts ONLY the §3 execution-hold,
not any advancement/review/SECREV gate. WI-3's independent-review + Opus-SECREV + green-suite gates stand.
## 6. Effect on WI-3 (#830) merge chain
On this amendment being pinned (Mos-verified + Jason-shown): a fresh lane (ms-rev-826) executes the §3
probe-3 evidence class (3× isolation) → PASS/FAIL reported to Mos. **On PASS**, and only after re-confirming
WI-3 head `f4008307` UNMOVED + FIRE-PRECONDITION-0 (fresh `mosaic-context-refresh` attestation for
mosaic-100 + planner-opus), the existing chain runs: CI queue-guard → push → PR → mirror RoR → relay full-40
head + pr/ci# → Mos independent 6-check → squash `closes #830` → chain WI-4→7. **On FAIL**: planner-return.
---
**Amendment author:** MS-LEAD (`web1:mosaic-100`), Gate0 governance interface / charter custodian.
**Pin protocol:** this file's sha256 becomes the amendment pin ONLY after Mos re-scope-verify. Reported to Mos
before any ratification/publication, per the transparency checkpoint on a durable-authority change. Ratification
and durable publication (to a homelab-inspectable provider ref) are the adjudicator's (Mos), NOT the author's
(MS-LEAD) — the author does not self-ratify and does not publish.

View File

@@ -1,49 +0,0 @@
# Gate0 Probe-3 Amendment — Owner Transparency Window (durable record)
**Purpose.** Durable evidence (defined duration + closure) that the owner-transparency window on the
probe-3 amendment **scope-correction** has a bounded, inspectable lifecycle — per homelab's GATE-A
requirement (2026-07-18 18:13:14Z). This is NOT the owner's core authorization (that is durable in the
amendment §1); it is the objection-window on the narrowing disclosed afterward.
## What was disclosed
The owner (Jason) explicitly authorized the amendment **class** — "amend the charter" (option B),
`#mos` 2026-07-18 17:28Z (amendment §1). My initial narrowing was "D4-only"; on adjudication that proved
non-executable (D4 intrinsically needs a promoted-to-VERIFIED baseline). The **corrected** scope
(rev 2, pin `9ac9ff87`, §3) adds a *minimal promotion-to-VERIFIED as a test fixture only* (P5/P6 still
excluded, P2 not banked as evidence). Because this adds a fixture step beyond the "D4-only" I first told
the owner, I disclosed the correction to him for transparency.
## Window (bounded, durable)
- **Opened:** 2026-07-18T18:00:25Z — anchored to the adjudicator's Discord scope-correction disclosure to the
owner (message id `1528099140100296736`, snowflake-decoded; not an informal estimate).
- **Duration:** 2 hours (defined).
- **Scheduled closure:** 2026-07-18T20:00:25Z.
- **Closure condition (fail-closed):**
- No owner objection received by scheduled closure → window **CLOSED-CLEAR** (the disclosed correction
stands; it is within the already-granted class authorization).
- Any owner objection before closure → window does **NOT** clear → re-adjudicate the corrected scope.
- An affirmative owner "proceed" before closure → **early CLOSED-CLEAR** (recorded here).
## Closure attestation
Closure will be confirmed by a dated adjudicator tick appended below (and to the orchestration ledger)
at/after 2026-07-18T20:00:25Z. Until that attestation exists, GATE A is OPEN and probe-3 execution stays HELD.
- [x] **CLOSED-CLEAR attestation — recorded 2026-07-18T20:08Z by adjudicator Mos (`web1:mos-claude`).**
### CLOSED-CLEAR attestation (2026-07-18T20:08Z)
The owner-transparency window on the probe-3 amendment scope-correction is **CLOSED-CLEAR**.
- **Scheduled closure:** 2026-07-18T20:00:25Z — reached (authoritative UTC at attestation: 2026-07-18T20:07Z, past closure).
- **Closure condition met:** *No owner objection received by scheduled closure.* Verified fail-closed against the
**durable Discord #mos record** (not memory): the channel's last message is the scope-correction disclosure itself
(`id 1528099140100296736`, 2026-07-18T18:00:25.709Z). From window-open (18:00:25Z) through closure (20:00:25Z) and
to this attestation, there are **zero owner (jetrich) messages** — no objection, no affirmative early-GO. The last
owner message preceding the window was the class authorization *"Amend the charter"* (17:28Z, amendment §1).
- **Effect:** the disclosed corrected scope (rev 2, pin `9ac9ff87`, §3 — minimal promotion-to-VERIFIED as a D4 test
fixture only; P5/P6 excluded; P2 not banked as evidence) **stands**, within the already-granted class authorization.
- **GATE A is now CLOSED.** Probe-3 execution proceeds ONLY under the full remaining chain: GATE-B fresh §3-review PASS
(concurred @839d156f, author≠reviewer) + adjudicator explicit FIRE GO. No other Gate0 hold is lifted by this tick.
*Adjudicator: Mos (`web1:mos-claude`). This record is durable and provider-inspectable alongside the
amendment (`9ac9ff87`) and its RATIFICATION.md.*

View File

@@ -1,37 +0,0 @@
# Gate0 Probe-3 (D4) Amendment — Adjudicator Ratification Record
**Ratifies pin:** `9ac9ff873fad41a6e15763cc89cb94d0bc4a6b0cf9b6770561d1781b03f63276`
(`GATE0-PROBE3-EXEC-AMENDMENT.md`, 7699 bytes, this branch).
**Supersedes:** prior candidate pin `8fca5b7a` (never executed against; withdrawn on scope + durability grounds).
## Re-scope-verify — PASSED
Verified by **Mos (`web1:mos-claude`)**, acting as independent adjudicator (author ≠ verifier: MS-LEAD
authored, Mos verified), 2026-07-18. SHA256 recomputed = pin (exact). Scope checks, all PASS:
- §3 = Option A, D4-focused: harness spawns ONLY `p3_generation_broker.py`; minimal promotion-to-VERIFIED
is a FIXTURE PRECONDITION only (not P2 evidence-banking); D4 gen-bump revoke observation
(new→`MUTATOR_UNVERIFIED`, prior→`STALE_GENERATION`); **P5/P6 explicitly excluded + unauthorized**;
verbatim intrinsic-precondition boundary line present.
- §1 binding model explicit; §2 charter provisions pinned unmutated; §4/§5 invariants intact.
- No scope creep beyond the owner-authorized narrow class.
## Authority binding
- **Owner class-authorization (PRE-SHA):** Jason (owner) — "amend the charter" (option B), `#mos`
2026-07-18 17:28Z. Authorized the amendment class before these bytes existed.
- **Independent-adjudicator byte-verification:** Mos scope-verified the exact bytes.
- No single "Jason-signs-the-SHA" artifact exists or is required under this binding model.
## Round-trip integrity (this branch/commit)
All three files fetched back via the Gitea raw API and SHA-matched (each > 200B, no not-found sentinel):
- `GATE0-PROBE3-EXEC-AMENDMENT.md``9ac9ff87…f63276`
- `charter-BUILD-BRIEF.md``89fdbc27…71b`
- `charter-SPEC-RATIFICATION.md``bac58319…67`
## Status: RATIFIED — probe-3 execution remains HELD
Publication here closes the governance-infra gap (the charter provisions were previously local-only,
not inspectable). Probe-3 execution stays HELD pending both: (1) homelab independent verification of
these published bytes; (2) owner transparency window. On both clearing → ms-rev-826 builds + runs the
§3 D4-focused harness (3× isolation, non-destructive) → PASS/FAIL to Mos → WI-3 (#830) chain per §6.
*Note: the amendment file's own status line reads "DRAFT (revision 2)"; that internal text is
superseded by THIS external ratification record (the pinned bytes are deliberately not edited, so the
SHA stays stable). Ratification status lives here, not in the pinned artifact.*

View File

@@ -1,106 +0,0 @@
# Compaction-Refresh Mechanism — BUILD BRIEF (Mos → MS-LEAD)
**Status:** STAGED — do NOT dispatch until PR #826 (#824 skill-CLI bridge) MERGES. Mos routes this to
MS-LEAD at #824 land. PLAN phase COMPLETE + RATIFIED; this is the BUILD-execution brief.
**Routed by:** Mos (main orchestrator). **Owner:** MS-LEAD (mosaic-100). **Runtime scope M1:** Claude + Pi.
## 1. AUTHORITY (sha256-pinned — build AGAINST these, do not re-derive)
| Artifact | Path | SHA-256 |
|---|---|---|
| **Ratification record (SSOT)** | `~/agent-work/reviews/compaction-refresh-SPEC-RATIFICATION.md` | `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67` |
| Ratified SPEC v5 | `~/agent-work/reviews/compaction-refresh-SPEC-v5.md` | `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433` |
| sol FINAL red-team (GO) | `~/agent-work/reviews/compaction-refresh-SPEC-v5-redteam-sol.md` | `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa` |
The coder MUST verify these hashes before building. The in-context working spec is DERIVED and
compaction-fragile — the sha256'd records above are the recovery SSOT (durable-authority discipline).
## 2. TARGET & SCOPE
- **Framework-native `packages/mosaic/…`** in `mosaicstack/stack`. NOT jarvis-brain. NOT
`~/.config/mosaic/` directly (that tree is WIPED on framework upgrade — durable home is SOURCE).
- **M1 = Claude + Pi orchestrators only.** Codex/other runtimes = scope-note only.
- **Hard dependency (now satisfied at dispatch):** the `mosaic skill` register/list + install/upgrade
symlink auto-sync (#824 / PR #826) — so the durable `mosaic-context-refresh` skill ships REACHABLE
(auto-symlinked into `~/.claude/skills/`). Do not dispatch before #826 merges.
## 3. DELIVERABLES (locked architecture — from the ratification record; do NOT redesign)
1. **Authenticated external lease broker** bound to kernel `SO_PEERCRED` `(pid, starttime,
runtime_generation)` — the only unforgeable identity on a same-uid tmux fleet. Broker **MINTS** the
logical `session_id` at first peercred contact (NEVER caller-asserted). Distinct-principal /
protected socket.
2. **Whole mutator-class gate** (not per-wrapper): no consequential mutator succeeds without a valid
VERIFIED lease. **Revoke-first / promote-last.**
3. **Compaction observers → revoke:** Claude `PreCompact` + `SessionStart(matcher=compact)`; Pi
`session_before_compact`/`context` equivalents. Any `runtime_generation` bump (reload/resume/fork,
same PID included) auto-revokes the prior incarnation.
4. **Verbatim-hashed normative fragments:** `B_payload` (exact fragments + deterministic metadata) and
`H_payload = SHA256(domain_sep ‖ length_framed(B_payload))` — NO self-reference (byte-identical
across the Claude and Pi builders).
5. **Receipt-challenge protocol (promotion prerequisite, NOT the safety mechanism):** broker mints a
single-use `receipt_challenge` per cycle bound to `(session_id, runtime_generation,
compaction_epoch, request_epoch, H_source, H_payload, schema_version)`. **COMPUTE = broker**
(observes latest-assistant message, computes the binding incl `H(latest-assistant-message)`);
**COPY = model** (verbatim copy of challenge + `H_payload` — an LLM cannot hash its own output;
T29). Observed in the EXACT latest assistant message (Claude: latest assistant entry + pending
challenge, NO transcript grep; Pi: `message_end`, NOT `after_provider_response`). Single-use,
consumed before the tool batch. Broker transition order (mandatory): `revoke → build
B_payload/H_payload → PENDING_DELIVERY(mint unique challenge) → deliver → observe exact receipt →
evidence commit → consume challenge → promote VERIFIED (last)`.
6. **Constrained recovery command** = the single ungated mutator; the `mosaic-context-refresh` skill
is its wrapper (uses a newly-minted challenge + identical protocol; cannot replay normal-path
receipt). Durable skill lands here, symlink-reachable via #824.
7. **T-C server-side line = branch protection** (the irreducible guarantee; client gate is
window-narrowing only). If ops-config rather than code, DOCUMENT the required posture explicitly.
## 4. BINDING BUILD CONDITIONS R1R6 (MANDATORY — verbatim from ratification record)
- **R1 (honesty):** state that the receipt detects ABSENT or PREFIX-TRUNCATED terminal token, but a
MIDDLE-DROP preserving the tail is a **T-C contract violation, NOT receipt-detectable** (covered by
server-side). No over-claim.
- **R2 (atomicity):** evidence-commit → consume-challenge → promote-VERIFIED is ONE atomic broker
transaction; a crash leaves neither VERIFIED-with-live-challenge nor consumed-with-ambiguous-promote;
recovery mints a NEW challenge, never reuses.
- **R3 (parsing):** exact single current-cycle receipt parse — no unbounded grep; reject
quoted/tool-output receipts, wrong generation/epoch, unknown/stale challenge, multiple receipts.
- **R4 (Gate0 = BUILD-ADMISSION GATE):** see §5 — runtime evidence BEFORE build admission; a failed
Gate0 item RETURNS the design to planner review, it is NOT waived by the GO.
- **R5 (TTL):** 300 s MAX lease TTL + soak-tighten; soak may only SHORTEN, never lengthen. Mos-accepted.
- **R6 (receipt semantics):** receipt = T-A delivery/liveness ONLY, never obedience/safety/residency.
Permanent invariant.
## 5. R4 GATE0 — RUNTIME EVIDENCE BEFORE BUILD ADMISSION (WI-0, do this FIRST)
Structure the build like gitwatch: **WI-0 is a Gate0 runtime-evidence probe; no feature build admitted
until every item produces POSITIVE runtime evidence** (not a design assertion). A failed item RETURNS
to planner — do not paper over:
- Launcher `exec`/parent topology + supported-hook ancestry (D1); broker authenticates the launcher
chain, rejects sibling-substitution.
- Pi lifecycle: last-position invariant (last-or-closed), per-tool nonce → tool-call-id map (D5).
- Same-PID `runtime_generation` bump on reload/resume/fork revokes prior lease (D4).
- Broker socket authenticity posture (protected / distinct-principal); `SO_PEERCRED` returns the true
`(pid, starttime)`.
- Source-invalidation fail-closed (missing/oversize/hash-mismatch fragment → no promotion).
- Claude `additionalContext` + Pi `context` inject atomically (A-v5-1) — the transport assumption
T27 rests on; verify or class the gap T-C.
## 6. ACCEPTANCE TESTS (red-first TDD)
Carry all v4 ACs + T12b; add **T24T30**: T24 hash-construction no-self-reference byte-identical across
builders · T25 replay rejected · T26 transcript-stale-match cannot promote · T27 partial-delivery
(prefix-trunc/tail-only/middle-drop/malformed) none promote · T28 single-use (no renew/reopen) · T29
model copies not computes · T30 dual-hook-miss matches AMENDED threat table within+after TTL.
Acceptance = full green suite + Gate0 evidence pack.
## 7. REVIEW & MERGE DISCIPLINE
- **This IS an Opus-SECREV-mandatory surface** — UNLIKE #824 (local same-uid FS). The broker is an
authentication/identity/authorization mechanism (peercred binding, session_id minting, mutator-class
gating, receipt protocol). Security review = **Opus-SECREV, no GPT/terra substitute** on the
broker/gate/receipt/socket surfaces. Functional/non-security parts may take GPT review.
- **Author ≠ reviewer**, independent review, no self-merge, exact-head RoR (reviewed-SHA=merged-SHA),
`closes #<issue>` close-keyword, full 40-char head. Never edit tests to pass / never force-merge red
/ never `--no-verify`.
- **Mos merges** (per ratification record) after Opus-SECREV + independent review pass + green suite +
Gate0 pack. Decompose into work-items (WI-0 Gate0 first); likely a milestone, not one monolithic PR.
## 8. SEQUENCING
#826 (#824 bridge) MERGES → Mos routes this brief → MS-LEAD decomposes (WI-0 Gate0 first) → build M1
(Claude+Pi) → Gate0 evidence pack (failed item → planner) → Opus-SECREV + independent review → Mos
merges → durable `mosaic-context-refresh` skill lands symlink-reachable → jarvis-brain `CLAUDE.md`
mandate PR aligns to the shipped receipt/hash contract. Interim skill + CLAUDE.md mandate stay LIVE
until the mechanism ships.

View File

@@ -1,75 +0,0 @@
# Compaction-Refresh Mechanism — SPEC RATIFICATION RECORD
**Ratifier:** Mos (main fleet orchestrator, `mos-claude`)
**Date:** 2026-07-17 ~22:2xZ
**Decision:****RATIFIED — GO WITH BINDING CONDITIONS R1R6.** Architecture converged v1→v5 via
oppositional adversarial planning (planner-opus authors / planner-sol red-teams). No v6 required.
## Authenticated authority artifacts (sha256-pinned)
| Artifact | Path | SHA-256 |
|---|---|---|
| Ratified SPEC v5 | `~/agent-work/reviews/compaction-refresh-SPEC-v5.md` | `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433` |
| sol FINAL red-team (GO) | `~/agent-work/reviews/compaction-refresh-SPEC-v5-redteam-sol.md` | `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa` |
| (base) SPEC v4 | `~/agent-work/reviews/compaction-refresh-SPEC-v4.md` | `a5e9c261a613974fc0d853d69ad76f616f5f1c3b6b63a436daecdd15b4a72b80` |
| (base) sol v4 red-team | `~/agent-work/reviews/compaction-refresh-SPEC-v4-redteam-sol.md` | `1e76ee5942241d9520b9ff8f39a628488578ec2d9b91cc09b9f0e7cc6091ef2f` |
| Ratification-Input (R1-R6 + rulings, provenance-clean) | `~/agent-work/reviews/compaction-refresh-SPEC-RATIFICATION-INPUT.md` | `d01411a5c966f513e992a941deef0ff5cc6f29d37adab97ea2fbd3c5e6aef266` |
## Convergence trail
v1 NO-GO (9 architectural) → v2 NO-GO (7 enforcement, arch accepted) → v3 NO-GO (5 surgical, arch
locked) → v4 NO-GO (narrow; echo-lock narrowing APPROVED) → **v5 GO-with-conditions**.
## Locked architecture (do not redesign)
Authenticated external **lease broker** bound to kernel `SO_PEERCRED` (pid, starttime,
runtime_generation) — the only unforgeable identity fact on a same-uid tmux fleet. Broker MINTS the
logical session_id at first peercred contact (never caller-asserted). **Revoke-first / promote-last.**
Whole **mutator-class gate** (not per-wrapper). Verbatim-hashed normative fragments. Single
**constrained recovery command** = the only ungated mutator; the `mosaic-context-refresh` skill is its
wrapper. **Threat tiers:** T-A honest-stale → client gate · T-B compromised-tool → mutator-class · T-C
fully-rotted → **server-side branch-protection = the irreducible line** (client gate is
window-narrowing, NOT a complete guarantee).
**Delivery-receipt (echo-lock, sol §8-final):** broker mints a single-use `receipt_challenge` per
cycle bound to (session, runtime_generation, epochs, H_source, H_payload); **COMPUTE = broker**
(observes + computes the full binding incl H(latest-assistant-message) as the cycle-audit fact),
**COPY = model** (copies challenge + H_payload verbatim — model never computes a hash; sol T29
copy-not-compute preserved). Receipt is a **one-cycle T-A delivery/liveness proof ONLY — never an
obedience, residency, or safety proof.** The mechanical mutator-class gate remains THE safety
mechanism; §8 intact for T-B/T-C.
## Binding ratification conditions (R1R6) — MANDATORY for build acceptance
- **R1 (honesty / T27 amendment):** state honestly that the receipt detects an ABSENT or
PREFIX-TRUNCATED terminal token, but a MIDDLE-DROP that preserves the tail token is a **T-C
contract violation, NOT receipt-detectable** (covered by server-side, not the receipt). No
over-claim. Fold as a doc amendment — no v6.
- **R2 (atomicity):** evidence-commit → consume-challenge → promote-VERIFIED is ONE atomic broker
transaction. A crash leaves NEITHER a VERIFIED-with-live-challenge NOR a
consumed-with-ambiguous-promotion state; recovery mints a **NEW** challenge, never reuses one.
- **R3 (parsing):** exact single current-cycle receipt parse — no unbounded grep; reject
quoted/tool-output receipts, wrong generation/epoch, unknown/stale challenge, and multiple receipts.
- **R4 (Gate0 runtime evidence — BUILD-ADMISSION GATE):** launcher-ancestry/D1,
sibling-substitution rejection, Pi last-or-closed, same-PID runtime_generation bump,
nonce→tool-call-id map, socket/authenticity posture, and source-invalidation fail-closed require
**runtime Gate0 evidence BEFORE build admission**, not design assertion. **A failed Gate0 item
RETURNS the design to planner review — it is NOT waived by this GO.**
- **R5 (TTL — RATIFIER ACCEPTED):** proposed **300s MAX lease TTL + soak-tighten** is **ACCEPTED by
Mos (ratifier)**. Safe-direction default: soak data may only SHORTEN it, never lengthen. Operator
(Jason) may tighten at will; flagged for operator awareness, non-blocking (not an escalation
trigger — a proposed max that only tightens).
- **R6 (receipt semantics — REAFFIRMED):** receipt remains T-A delivery/liveness ONLY, never
obedience/safety proof. Reaffirmed as a permanent invariant of the design.
## Build sequencing (PLAN-ONLY until these clear)
1. **HARD DEP:** `mosaic skill` register/unregister/list + generic install/upgrade symlink auto-sync
(mosaicstack/stack **#824**, in flight on ms-824) MUST land first — else the durable
`mosaic-context-refresh` skill ships unreachable (no `~/.claude/skills/` bridge symlink).
2. Then MS-LEAD builds the mechanism in **packages/mosaic** (M1 = Claude + Pi orchestrators) against
THIS ratified spec + R1R6, producing R4 Gate0 evidence. Author≠reviewer independent review; no
self-merge; Mos merges.
3. The durable `mosaic-context-refresh` skill (+ jarvis-brain CLAUDE.md mandate PR) lands aligned to
the ratified receipt/hash contract, AFTER the #824 bridge exists.
**Interim protection (live now, until the mechanism ships):** `mosaic-context-refresh` skill
(fail-closed 7-line residency attestation) + jarvis-brain CLAUDE.md "Directive Freshness After
Compaction" mandate — self-run manual re-read + attestation. Symlink hand-created this session; loads
and attests 7/7.

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""P5 Gate0 replay probe; BUILT ONLY, execution is Mos-gated.
Run only under fresh-executor authorization:
python3 -I -S -B docs/compaction-refresh/probes/p5_receipt_replay.py
Each of the default three isolated runs launches the shipped lease-broker daemon
in a distinct private temporary directory. This driver never changes broker
state directly and does not replace the promote gate: every transition is sent
over the daemon's real Unix socket. It proves the shipped order is
PENDING_DELIVERY -> observe/evidence commit -> consume -> VERIFIED and that a
consumed challenge cannot be replayed or reopen/renew its lease.
"""
from __future__ import annotations
import argparse
import base64
import importlib.util
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPOSITORY = HERE.parents[2]
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
DAEMON = TOOLS / "daemon.py"
FRAGMENTS = TOOLS / "normative_fragments.py"
def load_shipped_fragments():
if not FRAGMENTS.is_file():
raise RuntimeError(f"shipped normative construction missing: {FRAGMENTS}")
spec = importlib.util.spec_from_file_location("p5_shipped_normative_fragments", FRAGMENTS)
if spec is None or spec.loader is None:
raise RuntimeError("unable to load shipped normative construction")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(3.0)
connection.connect(str(socket_path))
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
connection.shutdown(socket.SHUT_WR)
response = bytearray()
while True:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if not response.endswith(b"\n") or response.count(b"\n") != 1:
raise AssertionError(f"unframed broker reply: {bytes(response)!r}")
parsed = json.loads(response[:-1])
if not isinstance(parsed, dict):
raise AssertionError(f"non-object broker reply: {parsed!r}")
return parsed
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if socket_path.exists():
return
if process.poll() is not None:
output = process.stdout.read() if process.stdout is not None else ""
raise RuntimeError(f"shipped daemon exited before READY: {output}")
time.sleep(0.02)
raise TimeoutError("shipped daemon did not create private probe socket")
def expect_refused(reply: dict[str, object], code: str) -> None:
if reply != {"ok": False, "code": code}:
raise AssertionError(f"expected refusal {code}, got {reply!r}")
def run_once(index: int) -> str:
fragments = load_shipped_fragments()
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p5-replay-{index}-"))
os.chmod(root, 0o700)
socket_path = root / "broker.sock"
state_path = root / "state.json"
observer_path = root / "test-observer.json"
process = subprocess.Popen(
[
sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
"--state", str(state_path), "--test-observer-file", str(observer_path),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
wait_ready(process, socket_path)
registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1})
if registered.get("ok") is not True or not isinstance(registered.get("session_id"), str):
raise AssertionError(f"registration failed: {registered!r}")
session_id = registered["session_id"]
construction = fragments.build_payload(
manifest_version=1,
generator_version="p5-replay-probe",
fragments=[
fragments.NormativeFragment(
"authority/probe",
b"P5 shipped transition driver\n",
"63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6",
),
],
)
if construction.injectionDecision != "ACCEPTED" or not construction.promotion:
raise AssertionError("shipped normative construction refused P5 fixture")
binding = {
"compaction_epoch": index,
"request_epoch": index + 100,
"h_source": construction.h_source,
"h_payload": construction.h_payload,
"schema_version": 1,
}
construction_request = {
"manifest_version": 1,
"generator_version": "p5-replay-probe",
"fragments": [{
"source_id": "authority/probe",
"content_base64": base64.b64encode(b"P5 shipped transition driver\n").decode("ascii"),
"expected_sha256": "63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6",
}],
}
pending = request(socket_path, {
"action": "begin_verification",
"session_id": session_id,
"runtime_generation": 1,
"runtime": "pi",
"binding": binding,
"construction": construction_request,
})
if pending.get("ok") is not True or pending.get("state") != "PENDING_VERIFICATION":
raise AssertionError(f"shipped pending-delivery transition failed: {pending!r}")
challenge = pending.get("receipt_challenge")
receipt = pending.get("receipt")
if not isinstance(challenge, str) or not isinstance(receipt, str):
raise AssertionError(f"shipped broker did not mint a receipt challenge: {pending!r}")
# Promotion before observation/evidence/consumption is forbidden.
expect_refused(request(socket_path, {
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": challenge,
}), "INVALID_LEASE_TRANSITION")
observer_path.write_text(json.dumps({
"session_id": session_id,
"runtime_generation": 1,
"latest_assistant_message": receipt,
}), encoding="utf-8")
os.chmod(observer_path, 0o600)
observed = request(socket_path, {
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": challenge,
})
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
raise AssertionError(f"shipped evidence transition failed: {observed!r}")
durable = json.loads(state_path.read_text(encoding="utf-8"))
evidence = durable["tokens"][challenge].get("evidence")
if not isinstance(evidence, dict) or not isinstance(evidence.get("h_latest_assistant"), str):
raise AssertionError("shipped receipt evidence was not committed before consume/promote")
promoted = request(socket_path, {
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": challenge,
})
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
raise AssertionError(f"shipped consume-before-promote transition failed: {promoted!r}")
# T25/T28: the actual consumed challenge, re-presented through the
# shipped daemon, can neither be observed again nor re-promote/reopen.
expect_refused(request(socket_path, {
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": challenge,
}), "RECEIPT_REPLAY")
expect_refused(request(socket_path, {
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": challenge,
}), "RECEIPT_REPLAY")
return challenge
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
shutil.rmtree(root, ignore_errors=True)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--runs", type=int, default=3)
arguments = parser.parse_args()
if arguments.runs != 3:
raise SystemExit("P5 requires exactly three isolated runs")
challenges = [run_once(index) for index in range(arguments.runs)]
if len(set(challenges)) != arguments.runs:
raise AssertionError("separate shipped cycles did not mint unique challenges")
print("P5 receipt replay probe PASS: 3 isolated shipped-daemon runs")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated.
DO NOT self-fire. Under Mos authorization only:
python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py
The default three isolated runs launch the shipped daemon plus its production
observer transport on private sockets. The driver invokes the shipped recovery
command and adapter gate identity; it never resets broker state, mocks promote,
or taps a live model-output stream.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import importlib.util
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPOSITORY = HERE.parents[2]
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
DAEMON = TOOLS / "daemon.py"
GATE = TOOLS / "mutator-gate.py"
RECOVERY_COMMAND = TOOLS / "recover-context.py"
OBSERVER_CLIENT = TOOLS / "receipt-observer-client.py"
FRAGMENTS = TOOLS / "normative_fragments.py"
CLAUDE_SETTINGS = REPOSITORY / "packages/mosaic/framework/runtime/claude/settings.json"
PI_EXTENSION = REPOSITORY / "packages/mosaic/framework/runtime/pi/mosaic-extension.ts"
def load_shipped_fragments():
spec = importlib.util.spec_from_file_location("p6_shipped_fragments", FRAGMENTS)
if spec is None or spec.loader is None:
raise RuntimeError("shipped normative construction unavailable")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(3.0)
connection.connect(str(socket_path))
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
connection.shutdown(socket.SHUT_WR)
response = bytearray()
while True:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if not response.endswith(b"\n") or response.count(b"\n") != 1:
raise AssertionError(f"unframed broker reply: {bytes(response)!r}")
reply = json.loads(response[:-1])
if not isinstance(reply, dict):
raise AssertionError("broker reply is not an object")
return reply
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if socket_path.exists():
return
if process.poll() is not None:
output = process.stdout.read() if process.stdout is not None else ""
raise RuntimeError(f"shipped daemon exited before READY: {output}")
time.sleep(0.02)
raise TimeoutError("shipped daemon did not create private probe socket")
def run_json(command: list[str], environment: dict[str, str], input_value: object | None = None) -> dict[str, object]:
completed = subprocess.run(
command,
input=None if input_value is None else json.dumps(input_value),
text=True,
capture_output=True,
env=environment,
check=False,
)
if not completed.stdout.endswith("\n"):
raise AssertionError(f"command omitted framed result: {completed.stderr!r}")
reply = json.loads(completed.stdout)
if not isinstance(reply, dict):
raise AssertionError("command result is not an object")
return reply
def gate_recovery(runtime: str, phase: str, environment: dict[str, str]) -> None:
command = [sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", runtime]
if runtime == "claude":
command.extend(["--recovery-command", str(RECOVERY_COMMAND)])
recovery_invocation = (
f"python3 {RECOVERY_COMMAND} begin --construction /tmp/p6.json "
"--compaction-epoch 1 --request-epoch 1"
if phase == "begin"
else f"python3 {RECOVERY_COMMAND} complete"
)
value = {"tool_name": "Bash", "tool_input": {"command": recovery_invocation}}
else:
value = {"tool_name": "mosaic_context_recover"}
completed = subprocess.run(command, input=json.dumps(value), text=True, capture_output=True, env=environment, check=False)
if completed.returncode != 0:
raise AssertionError(f"{runtime} recovery invocation remained gated: {completed.stderr!r}")
def record_production_observation(runtime: str, message: str, root: Path, environment: dict[str, str]) -> None:
command = [sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", runtime]
if runtime == "claude":
transcript = root / "claude-transcript.jsonl"
transcript.write_text(json.dumps({"message": {"role": "assistant", "content": message}}) + "\n", encoding="utf-8")
payload = {"transcript_path": str(transcript)}
command.append("--latest-entry")
else:
payload = {"latest_assistant_message": message}
completed = subprocess.run(command, input=json.dumps(payload), text=True, capture_output=True, env=environment, check=False)
if completed.returncode != 0:
raise AssertionError(f"{runtime} production observer transport refused: {completed.stderr!r}")
def run_once(index: int, runtime: str) -> None:
# Parity guard: drive the shipped command and the repaired adapter/observer
# bytes, not a shadow receipt or promotion implementation.
recovery_source = RECOVERY_COMMAND.read_text(encoding="utf-8")
if '"action": "begin_recovery"' not in recovery_source or '"action": "complete_recovery"' not in recovery_source:
raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints")
gate_source = GATE.read_text(encoding="utf-8")
if "--recovery-command" not in CLAUDE_SETTINGS.read_text(encoding="utf-8"):
raise AssertionError("P6 parity guard: Claude recovery mapping is missing")
if "_SHELL_ACTIVE" not in gate_source or "argv[1] != str(recovery_command)" not in gate_source:
raise AssertionError("P6 parity guard: Claude mapping is not literal-only")
if "const RECOVERY_TOOL = 'mosaic_context_recover'" not in PI_EXTENSION.read_text(encoding="utf-8"):
raise AssertionError("P6 parity guard: Pi recovery tool mapping is missing")
fragments = load_shipped_fragments()
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-"))
os.chmod(root, 0o700)
socket_path = root / "broker.sock"
observer_socket = root / "observer.sock"
state_path = root / "state.json"
construction_path = root / "construction.json"
content = b"P6 constrained recovery fixture\n"
construction = {
"manifest_version": 1,
"generator_version": "p6-constrained-recovery",
"fragments": [{
"source_id": "authority/p6",
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": hashlib.sha256(content).hexdigest(),
}],
}
construction_path.write_text(json.dumps(construction), encoding="utf-8")
os.chmod(construction_path, 0o600)
process = subprocess.Popen(
[sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
"--state", str(state_path), "--observer-socket", str(observer_socket)],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
wait_ready(process, socket_path)
registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1})
session_id = registered.get("session_id")
if registered.get("ok") is not True or not isinstance(session_id, str):
raise AssertionError(f"broker anchor registration failed: {registered!r}")
built = fragments.build_payload_from_wire(construction)
normal = request(socket_path, {
"action": "begin_verification", "session_id": session_id, "runtime_generation": 1,
"runtime": runtime, "construction": construction,
"binding": {"compaction_epoch": index, "request_epoch": index + 100,
"h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1},
})
normal_challenge = normal.get("receipt_challenge")
normal_receipt = normal.get("receipt")
if not isinstance(normal_challenge, str) or not isinstance(normal_receipt, str):
raise AssertionError("normal path did not mint a receipt challenge")
environment = {
**os.environ,
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
"MOSAIC_RECEIPT_OBSERVER_SOCKET": str(observer_socket),
"MOSAIC_LEASE_SESSION_ID": session_id,
"MOSAIC_RUNTIME_GENERATION": "1",
"MOSAIC_LEASE_RUNTIME": runtime,
}
gate_recovery(runtime, "begin", environment)
recovery = run_json([
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
"--compaction-epoch", str(index + 10), "--request-epoch", str(index + 110),
], environment)
challenge = recovery.get("receipt_challenge")
receipt = recovery.get("receipt")
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(challenge, str) or not isinstance(receipt, str):
raise AssertionError(f"recovery command did not drive pending delivery: {recovery!r}")
if challenge == normal_challenge:
raise AssertionError("recovery reused a normal-path challenge")
# C4: production observer content is still exact-current-cycle only.
record_production_observation(runtime, normal_receipt, root, environment)
refused = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH":
raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}")
gate_recovery(runtime, "begin", environment)
recovery = run_json([
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
"--compaction-epoch", str(index + 20), "--request-epoch", str(index + 120),
], environment)
receipt = recovery.get("receipt")
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str):
raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}")
record_production_observation(runtime, receipt, root, environment)
gate_recovery(runtime, "complete", environment)
promoted = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}")
replay = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION":
raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}")
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
shutil.rmtree(root, ignore_errors=True)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--runs", type=int, default=3)
arguments = parser.parse_args()
if arguments.runs != 3:
raise SystemExit("P6 requires exactly three isolated runs")
for index, runtime in enumerate(("pi", "claude", "pi")):
run_once(index, runtime)
print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs")
if __name__ == "__main__":
main()

View File

@@ -5,28 +5,17 @@
This checklist is an acceptance contract for documentation and examples. It does not authorize This checklist is an acceptance contract for documentation and examples. It does not authorize
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
named artifact exists, is linked from the fleet documentation entry point, and its evidence is named artifact exists, is linked from the fleet documentation entry point, and its evidence is
recorded in the M0 task/PR. recorded in the M5 closure report and linked deferral evidence.
## M0 baseline acceptance ## M0 baseline acceptance
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, - [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of unsupported or quarantined legacy input.
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of - [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess and Ultron remain configurable.
unsupported or quarantined legacy input. - [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and observed state, including stopped-state preservation through migration, apply, and reboot.
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but - [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` - [x] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation, connector mutation, secret references, arbitrary commands/channels, gateway convergence, and UI configuration storage.
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess - [x] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with agent tier, branch, dependency, estimate, and evidence expectations.
and Ultron remain configurable. - [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped fleet example, profile, and service preset before M1 implementation starts.
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
observed state, including stopped-state preservation through migration, apply, and reboot.
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
- [ ] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation,
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
UI configuration storage.
- [ ] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with
agent tier, branch, dependency, estimate, and evidence expectations.
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
fleet example, profile, and service preset before M1 implementation starts.
## Required documentation IA for M1M5 ## Required documentation IA for M1M5
@@ -72,15 +61,18 @@ recorded in the M0 task/PR.
## Cross-cutting evidence gates ## Cross-cutting evidence gates
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the - [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the same declared executable production parser/resolver path recorded by the disposition inventory; versioned v1 fixtures are not forced through the v2 compiler.
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI. - [x] Every retired example/profile/service preset has a replacement link and deprecation note; no unresolved legacy class or tool-policy alias remains silently shipped.
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no - [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded Tess/Ultron identity.
unresolved legacy class or tool-policy alias remains silently shipped. - [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed mosaic agent catalog.
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded - [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of legacy sensitive keys are never printed.
Tess/Ultron identity. - [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links, the canonical roster through the production compiler/resolver, and fenced/canonical-example safety checks.
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed - [ ] FCM-M5-001 does not deterministically assert owner/evidence/deferral metadata for every checklist row. Closure and deferral reports provide human-reviewable evidence only; broader assertion coverage remains unclaimed.
`mosaic agent` catalog.
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of ## Held downstream gates
legacy sensitive keys are never printed.
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have These unchecked items are intentionally outside FCM-M5-001 and are not authorized by this checklist:
owner/evidence or an explicit approved deferral.
- [ ] FCM-M4-002 executes and evidences live cutover, canary, stopped-state preservation, and rollback.
- [ ] FCM-M5-002 completes independent exact-head review and issues the validator certificate.
- [ ] The exact PR head reaches terminal-green CI after independent review.

View File

@@ -8,11 +8,11 @@ Generated environment files are rebuildable projections, not an operator-editabl
| Layer | Responsibility | | Layer | Responsibility |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | | Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. | | Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. | | Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. | | systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | | session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. | | runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
@@ -20,7 +20,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
## Generated and local files ## Generated and local files
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are: <name>.env.generated is complete, deterministic, and written only by Mosaic. Its ordered keys are:
```dotenv ```dotenv
MOSAIC_AGENT_NAME=<roster name> MOSAIC_AGENT_NAME=<roster name>
@@ -33,12 +33,12 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add` The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
it has no generated-launch adapter and cannot be added through this path. it has no generated-launch adapter and cannot be added through this path.
`<name>.env.local` is optional and may contain only non-secret machine data: <name>.env.local is optional and may contain only non-secret machine data:
- `MOSAIC_RUNTIME_BIN` - `MOSAIC_RUNTIME_BIN`
- `MOSAIC_HEARTBEAT_RUN_DIR` - `MOSAIC_HEARTBEAT_RUN_DIR`
@@ -52,9 +52,9 @@ private, non-symlink paths. Violations fail closed before tmux interaction.
## Legacy input and diagnostics ## Legacy input and diagnostics
A legacy `<name>.env` is input only during projection generation. Roster-owned keys are regenerated; A legacy <name>.env is input only during projection generation. Roster-owned keys are regenerated;
valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority. <name>.env.quarantine. Neither legacy nor quarantine files are launch authority.
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
text, credentials, or other values. text, credentials, or other values.
@@ -62,11 +62,11 @@ text, credentials, or other values.
## Launch and stop behavior ## Launch and stop behavior
The launcher obtains the agent's socket only from the validated generated projection. It creates or The launcher obtains the agent's socket only from the validated generated projection. It creates or
checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match. checks the exact =<agent-name> tmux target; it never uses an ambient socket or fuzzy session match.
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative; The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
the shell sidecar only provides fallback state when the native marker is stale or absent. the shell sidecar only provides fallback state when the native marker is stale or absent.
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms mosaic agent comms-block <exact-member> can inspect that exact roster member's resolved Fleet-Comms
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster. block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus

View File

@@ -14,7 +14,7 @@ parallel resolver. The current executable implementation and per-artifact outcom
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence | | Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested | | `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: implementer → code, reviewer → review; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note | | `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note |
| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner | | `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner |
| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior | | `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior |
@@ -35,13 +35,13 @@ parallel resolver. The current executable implementation and per-artifact outcom
## Service presets ## Service presets
| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence | | Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. | | `framework/fleet/services/operator-interaction.yaml` | Generic policy only: runtime: pi, model: openai/gpt-5.6-sol, reasoning: high, tool_policy: operator-interaction; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate tool_policy: operator-interaction only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
## Required disposition controls ## Required disposition controls
1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and 1. **No silent aliasing:** only implementer → code, reviewer → review, and
`operator-interaction → interaction` are approved deterministic aliases in this M0 baseline. operator-interaction → interaction are approved deterministic aliases in this M0 baseline.
`worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an `worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an
explicit version/retirement decision. explicit version/retirement decision.
2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile 2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile

View File

@@ -33,7 +33,7 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists. - **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick. - **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change. - **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides. - **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
## Workstreams ## Workstreams

View File

@@ -97,7 +97,7 @@ success_criteria:
- id: AC-NS-7 - id: AC-NS-7
text: >- text: >-
A user-customized persona (edited or added via the orchestrator) survives A user-customized persona (edited or added via the orchestrator) survives
`mosaic update`: baseline reseed never clobbers user overrides. mosaic update: baseline reseed never clobbers user overrides.
workstreams: workstreams:
- id: A - id: A

View File

@@ -8,7 +8,7 @@
## Mission ## Mission
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**: Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid), a user runs mosaic fleet init, answers a few questions (general / coding / research / hybrid),
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
CLI/TUI and (designed-for) the webUI. CLI/TUI and (designed-for) the webUI.
@@ -22,9 +22,9 @@ functional, we use the fleet itself to continue the work.
### A. Configure-without-AI CLI ### A. Configure-without-AI CLI
| ID | Requirement | | ID | Requirement |
| --- | ------------------------------------------------------------------------------------------------------------- | | --- | ----------------------------------------------------------------------------------------------------------- |
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). | | R1 | mosaic fleet command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. | | R2 | mosaic fleet init is an interactive, **AI-free** CLI wizard. |
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). | | R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). | | R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. | | R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
@@ -35,11 +35,11 @@ functional, we use the fleet itself to continue the work.
### B. Comms & orchestrator chat-ops ### B. Comms & orchestrator chat-ops
| ID | Requirement | | ID | Requirement |
| --- | --------------------------------------------------------------------------------------------------------------------------------- | | --- | ----------------------------------------------------------------------------------------------------------------------------- |
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. | | R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. | | R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. | | R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. | | R19 | **"Mos" orchestrator on Discord** (chan 1517622518662434996 / srv 1112631390438166618) on `w-jarvis` — the first live target. |
### C. Runtime, health, lifecycle ### C. Runtime, health, lifecycle
@@ -64,15 +64,15 @@ functional, we use the fleet itself to continue the work.
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session. - **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable. - **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path. - **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher). - **Updates (R13):** mosaic update (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path. - **webUI (R18):** the fleet exposes machine-readable state (fleet ps --json already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
## Phases (incremental, each shippable) ## Phases (incremental, each shippable)
| Phase | Deliverable | Notes | | Phase | Deliverable | Notes |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1R5, R8, R10, R17 | | **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free fleet init selecting a preset; re-init idempotent | R1R5, R8, R10, R17 |
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 | | **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord 1517…/1112…** on w-jarvis | R6, R19, partial R16 |
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 | | **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 | | **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 | | **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
@@ -82,28 +82,28 @@ functional, we use the fleet itself to continue the work.
## Work division (proposed — confirm with dragon-lin) ## Work division (proposed — confirm with dragon-lin)
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis. - **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11). - **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins fleet init/updates — coordinate the install path). Co-testing on dragon-lin (R11).
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous. - **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
## Immediate target: Mos on Discord (F2 first slice) ## Immediate target: Mos on Discord (F2 first slice)
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable The discord plugin is available (~/.claude.json). Path: configure the **orchestrator** as a durable
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996` fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within (server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar. ~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
## Success criteria ## Success criteria
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator. - A non-AI user can mosaic fleet init, pick a type, and get a working fleet + orchestrator.
- **Mos answers in Discord `1517…`** on w-jarvis. - **Mos answers in Discord 1517…** on w-jarvis.
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin. - Fleet runs + is observable (fleet ps) on **both** w-jarvis and dragon-lin.
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents. - Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
- Docs let a new operator install/configure/use the fleet. - Docs let a new operator install/configure/use the fleet.
- Re-init + orchestrator mutation work. - Re-init + orchestrator mutation work.
## Assumptions (veto-able) ## Assumptions (veto-able)
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`. - `ASSUMPTION:` presets ship as example rosters under the framework (fleet/examples/\*.yaml), selected by `init`.
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later. - `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol). - `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed). - `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).

View File

@@ -10,8 +10,8 @@
The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
(which protects the operator's default tmux) makes the fleet **invisible** to default (which protects the operator's default tmux) makes the fleet **invisible** to default
tooling, and truth is split across three planes no single command joins — systemd tooling, and truth is split across three planes no single command joins — systemd
(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`). (systemctl --user), tmux (-L mosaic-fleet), and the process tree (`pstree`).
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send` agent tail (`capture-pane`) returns **blank for full-screen TUIs**, and agent send
confirms only keystroke injection, not acceptance. Net: the operator has near-zero confirms only keystroke injection, not acceptance. Net: the operator has near-zero
observability and no safe way to watch a session. observability and no safe way to watch a session.
@@ -33,21 +33,21 @@ observability and no safe way to watch a session.
## Functional requirements ## Functional requirements
| ID | Requirement | | ID | Requirement |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). | | FR-1 | mosaic fleet ps [--json] prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. | | FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. fleet ps issues probes (or reads last-seen) and reports health per FR-1. |
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. | | FR-3 | mosaic agent watch <name> opens a **read-only** view of the pane (grouped session or tmux attach -r) that cannot send keystrokes and does not shrink the agent's window. |
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). | | FR-4 | mosaic agent attach <name> remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. | | FR-5 | mosaic agent send <name> --verify confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. | | FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
## Heartbeat protocol v1 ## Heartbeat protocol v1
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a - **Probe:** operator/fleet ps writes a sentinel line to the agent's input or a
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`. well-known per-agent heartbeat file path ~/.config/mosaic/fleet/run/<agent>.hb.
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>` - **Response:** the runtime updates <agent>.hb with ts=<iso8601> pid=<pid> status=<ok|busy>
on a fixed interval (default 15s) and on demand when probed. on a fixed interval (default 15s) and on demand when probed.
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`. - **Health rule:** `healthy` if now - ts <= 3 × interval; else `stale`; missing file = `unknown`.
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3) - **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
full-screen TUIs alike (no `capture-pane` dependency). full-screen TUIs alike (no `capture-pane` dependency).
@@ -56,15 +56,15 @@ observability and no safe way to watch a session.
## Acceptance criteria ## Acceptance criteria
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct - mosaic fleet ps shows all 5 live sessions on `mosaic-fleet` with correct
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled). `dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
- Killing one agent's pane flips its row to dead/stale within one `interval`. - Killing one agent's pane flips its row to dead/stale within one `interval`.
- `agent watch` shows live output and provably cannot type into the pane; detaching - agent watch shows live output and provably cannot type into the pane; detaching
leaves the agent's window size unchanged. leaves the agent's window size unchanged.
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane. - agent send --verify returns success on an accepting pane and non-zero on a wedged/draft pane.
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus - Quality gates green: pnpm typecheck, pnpm lint, pnpm format:check, plus
`pnpm --filter @mosaicstack/mosaic test`. pnpm --filter @mosaicstack/mosaic test.
- Independent review passed; dogfood evidence captured against the live fleet. - Independent review passed; dogfood evidence captured against the live fleet.
## Test plan ## Test plan
@@ -72,18 +72,18 @@ observability and no safe way to watch a session.
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new - Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert `fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
exact tmux/systemd command construction and JSON shape (tenant+host present). exact tmux/systemd command construction and JSON shape (tenant+host present).
- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output, - Situational: run against the live `mosaic-fleet` fleet; capture fleet ps output,
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair. a kill-and-detect cycle, a read-only `watch`, and a send --verify pass/fail pair.
## Known limitations ## Known limitations
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft - **Verify heuristic is best-effort:** agent send --verify uses a > -prefix draft
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
TUIs is best-effort only; those runtimes may not use the same input-line indicator. TUIs is best-effort only; those runtimes may not use the same input-line indicator.
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded - **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then timeout:** agent send --verify captures a BEFORE snapshot, sends the message, then
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s, polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if controlled by --verify-timeout <ms>). On each poll it runs classifySendResult: if
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no 'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
definitive result, it fails closed: exit 1 with "no pane change after send". This definitive result, it fails closed: exit 1 with "no pane change after send". This
@@ -92,15 +92,15 @@ observability and no safe way to watch a session.
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
poll is the best signal available against an opaque TUI for Phase-2. poll is the best signal available against an opaque TUI for Phase-2.
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi) - **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify` render blank for tmux capture-pane. When the AFTER snapshot is empty, send --verify
returns non-zero with an "unverifiable" message rather than silently succeeding. This returns non-zero with an "unverifiable" message rather than silently succeeding. This
is an intentional fail-closed design (FR-5). is an intentional fail-closed design (FR-5).
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the - **agent watch uses a grouped viewer session:** tmux attach -r directly against the
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead agent session lets the viewer terminal shrink the agent's window. agent watch instead
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s creates a throwaway grouped session (tmux new-session -d -t '=<agent>' -s
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach. '<agent>-watch-<pid>'), attaches read-only to that session, and kills it on detach.
The grouped session shares the agent's windows but has independent sizing, so the The grouped session shares the agent's windows but has independent sizing, so the
agent's window is never affected. `tmux attach` is still interactive and requires agent's window is never affected. tmux attach is still interactive and requires
inherited stdio; the `interactiveRunner` handles TTY passthrough. inherited stdio; the `interactiveRunner` handles TTY passthrough.
## Surfaces & parity (MVP-X1) ## Surfaces & parity (MVP-X1)

63
docs/fleet/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Fleet Configuration Management
This book documents the local roster-v2 desired-state control plane delivered under issue #758. The normative requirements are the [FCM section of the repository PRD](../PRD.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages.
## Authority boundary
<MOSAIC_HOME>/fleet/roster.yaml is the sole writable desired-state authority for local fleet membership, launch policy, and persisted lifecycle. Generated environment files, systemd enablement, tmux sessions, heartbeat files, and status output are derived or observed. Rebuild projections from the roster; never edit them as desired state.
This control plane is local tmux/systemd only. Remote/SSH entries and connectors are inventory, not reconciliation targets. Arbitrary commands, channels, secret references, gateway catalog convergence, and UI configuration storage are outside this workstream. `mos-comms` is temporary transport glue, not permanent fleet architecture.
## Choose the right workflow
1. **Need to inspect intent?** Read the roster and use mosaic fleet get; see [desired versus observed state](concepts/desired-vs-observed-state.md).
2. **Need to inspect reality?** Use `status` or `doctor`; use `verify` for a strict non-zero drift/ownership gate. These commands do not repair anything.
3. **Need to change membership or persisted policy?** Use generation-guarded `plan`, `create`, `update`, or `delete`; see [safe CRUD](how-to/create-update-delete-agent.md).
4. **Need a one-time runtime action?** Use `start`, `stop`, or `restart`. These do not change persisted desired state.
5. **Need convergence?** Review apply --dry-run, resolve blockers, then use `apply` with the same current generation; see [reconcile and recover](operations/reconcile-and-recover.md).
6. **Need v1 migration evidence?** Use preview only. Cutover, canary, and rollback remain held for FCM-M4-002.
7. **Need the gateway-backed agent catalog?** That is the separate mosaic agent surface, not local fleet desired state.
## Concepts
- [Desired versus observed state](concepts/desired-vs-observed-state.md)
- [Identity, class, runtime, provider, and model](concepts/identity-class-runtime.md)
- [Role authority and leases](concepts/role-authority-and-leases.md)
- [Generated environment launch chain](concepts/generated-env-launch-chain.md)
## Operator how-to
- [Create, inspect, update, and delete](how-to/create-update-delete-agent.md)
- [Start, stop, restart, and reconcile](how-to/start-stop-restart.md)
- [Configure an interaction instance](how-to/configure-tess-interaction.md)
- [Configure a validator instance](how-to/configure-ultron-validator.md)
- [Customize roles](how-to/customize-roles.md)
## Operations and recovery
- [Reconcile and recover](operations/reconcile-and-recover.md)
- [Environment quarantine](operations/env-quarantine.md)
- [Systemd/tmux troubleshooting](operations/systemd-tmux-troubleshooting.md)
- [Backup and restore boundary](operations/backup-restore.md)
- [Upgrade and asset-drift hold](operations/upgrade-assets.md)
## Reference and migration
- [Roster v2 fields](reference/roster-v2-fields.md) · [executable JSON Schema](reference/roster-v2.schema.json) · [validated example](examples/roster-v2.yaml)
- [CLI and exit codes](reference/cli.md)
- [Role classes](reference/role-classes.md)
- [Lifecycle transitions](reference/lifecycle-transitions.md)
- [Status and drift](reference/status-and-drift.md)
- [Generated environment boundary](reference/generated-env-boundary.md)
- [v1-to-v2 preview](migration/v1-to-v2.md)
- [Example/profile dispositions](migration/example-profile-disposition.md)
- [Legacy class aliases](migration/legacy-class-aliases.md)
## Acceptance evidence and holds
- [M0/M5 IA checklist](FLEET-CONFIG-DOCS-IA-CHECKLIST.md)
- [Legacy example/profile inventory](LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
- [M5 closure evidence](../reports/documentation/758-fleet-config-ia-closure.md)
- [Approved-existing deferrals and live-action holds](../reports/deferred/758-fleet-config-deferrals.md)
The canonical publishing source remains this repository. This card does not publish externally, run a migration, operate a live fleet, or close parent issue #758.

View File

@@ -8,13 +8,13 @@
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed` > Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
| id | status | description | depends_on | agent | pr | notes | | id | status | description | depends_on | agent | pr | notes |
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` | | FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD | | FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based ~/.config/mosaic/fleet/run/<agent>.hb; spec in PRD |
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) | | FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc mosaic yolo fleet agent (dogfood) |
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null | | FLEET-OBS-003 | done | mosaic fleet ps — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired | | FLEET-OBS-004 | done | mosaic agent watch <name> — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | attach -r; verb wired |
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify | | FLEET-OBS-005 | done | mosaic agent send --verify — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead | | FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` | | FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad | | FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
@@ -22,6 +22,6 @@
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream) ## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
``` ```text-table
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) | | W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
``` ```

View File

@@ -2,10 +2,10 @@
The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built
end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres) end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres)
and surfaced as `mosaic fleet backlog <sub> --json`. and surfaced as mosaic fleet backlog <sub> --json.
> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter. > **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter.
> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes` > There is **no** runtime dependency on Hermes, hermes kanban, or ~/.hermes
> anywhere in this feature. Anything previously delegated to Hermes is recreated > anywhere in this feature. Anything previously delegated to Hermes is recreated
> here on Mosaic's own Postgres storage layer. > here on Mosaic's own Postgres storage layer.
@@ -18,7 +18,7 @@ engine (no sqlite, no raw client).
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- | | ---------------------------------- | -------------------- | ---------------------------------------------------------------- |
| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL | | `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL |
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory | | `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` | | neither (default) | Embedded PGlite | ~/.config/mosaic/fleet/backlog |
PGlite is real Postgres semantics in-process — including the row locks the atomic PGlite is real Postgres semantics in-process — including the row locks the atomic
claim relies on — so the **same code** runs on a laptop (embedded, single-host claim relies on — so the **same code** runs on a laptop (embedded, single-host
@@ -28,9 +28,9 @@ For embedded PGlite only, the local backlog routine may prepare its local schema
### Update safety ### Update safety
The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is The embedded PGlite store lives under ~/.config/mosaic/fleet/backlog, which is
listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means
`mosaic update` (which runs the framework sync with `rsync --delete`) will **not** mosaic update (which runs the framework sync with rsync --delete) will **not**
wipe the operator's backlog — same protection as the roster, per-agent env, and wipe the operator's backlog — same protection as the roster, per-agent env, and
heartbeat run dir. heartbeat run dir.
@@ -46,10 +46,10 @@ A card is one row in the `backlog` table:
| `phase` | text (nullable) | Board/phase grouping (see below). | | `phase` | text (nullable) | Board/phase grouping (see below). |
| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. | | `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. |
| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. | | `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. |
| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. | | `depends_on` | jsonb string[] | DAG edges — ids of cards this one depends on. |
| `claim_owner` | text (nullable) | Owner token of the active claim. | | `claim_owner` | text (nullable) | Owner token of the active claim. |
| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. | | `claim_ttl_seconds` | int (nullable) | TTL of the active claim. |
| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. | | `claimed_at` | timestamptz (null) | When the claim was taken. claimed_at + ttl = expiry. |
| `attempts` | int (default 0) | Incremented each time the card is claimed. | | `attempts` | int (default 0) | Incremented each time the card is claimed. |
| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. | | `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. |
| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). | | `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). |
@@ -65,12 +65,12 @@ would add ceremony without benefit.
### Board / phase convention ### Board / phase convention
`phase` is a free-form grouping string used as the board column / milestone label `phase` is a free-form grouping string used as the board column / milestone label
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane. (e.g. `M1`, `fleet`, `infra`). list --phase <phase> filters to one board lane.
`priority` orders cards **within** the ready pool regardless of phase. `priority` orders cards **within** the ready pool regardless of phase.
## Status lifecycle ## Status lifecycle
``` ```text-diagram
create create
@@ -87,51 +87,49 @@ would add ceremony without benefit.
- **blocked** — explicitly parked; never auto-claimed. - **blocked** — explicitly parked; never auto-claimed.
- **done** — completed; satisfies dependents. - **done** — completed; satisfies dependents.
## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL ## Atomic claim (FOR UPDATE SKIP LOCKED) + TTL
`claim` is atomic. Inside a single transaction it locks candidate `ready` rows `claim` is atomic. Inside a single transaction it locks candidate `ready` rows
with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks with SELECT ... FOR UPDATE SKIP LOCKED (via the drizzle `sql` operator), picks
the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row
already locked by a concurrent claimer is **skipped**, two claimers can **never** already locked by a concurrent claimer is **skipped**, two claimers can **never**
both win the same card — the loser falls through to the next candidate or gets both win the same card — the loser falls through to the next candidate or gets
`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.) `null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.)
- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`. - **Deps gate:** a card is only claimable when every id in `depends_on` is `done`.
- **TTL:** `claim --ttl <sec>` (default **900s**) records `claim_ttl_seconds`. - **TTL:** claim --ttl <sec> (default **900s**) records `claim_ttl_seconds`.
- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired) - **reclaim:** releases claims whose claimed_at + ttl is in the past (expired)
back to `ready`, clearing the claim fields. `reclaim --id <id>` force-releases a back to `ready`, clearing the claim fields. reclaim --id <id> force-releases a
specific card regardless of expiry. This is how a crashed worker's card returns specific card regardless of expiry. This is how a crashed worker's card returns
to the pool. to the pool.
## CLI — `mosaic fleet backlog <sub> --json` ## CLI — mosaic fleet backlog <sub> --json
All subcommands support `--json`. All subcommands support `--json`.
| Subcommand | Purpose | | Subcommand | Purpose |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). | | create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key] | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. | | list [--status --phase --ready-only] | List cards. `--ready-only` = status `ready` AND all deps `done`. |
| `claim --owner [--ttl <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. | | claim --owner [--ttl <sec> --id <id>] | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
| `reclaim [--id <id>]` | Release expired claims (or a specific card) back to `ready`. | | reclaim [--id <id>] | Release expired claims (or a specific card) back to `ready`. |
| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). | | link --from --to | Add a `depends_on` edge (`--from` depends on `--to`). |
| `stats` | Counts by status, oldest-ready age, expired-claim count. | | `stats` | Counts by status, oldest-ready age, expired-claim count. |
| `block --id` | Set a card to `blocked`. | | block --id | Set a card to `blocked`. |
| `complete --id` | Set a card to `done` (releases any claim). | | complete --id | Set a card to `done` (releases any claim). |
### Example ### Example
```sh Seed two cards; the second depends on the first. Because A2 is gated on A1, claim returns A1 first. Finish A1, then list A2 as ready. Recover stalled work.
# Seed two cards, the second depends on the first.
```fleet-command
mosaic fleet backlog create --id A1 --title "schema" --priority 5 mosaic fleet backlog create --id A1 --title "schema" --priority 5
mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9 mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9
# A2 is gated on A1, so claim returns A1 first.
mosaic fleet backlog claim --owner worker-1 --ttl 600 --json mosaic fleet backlog claim --owner worker-1 --ttl 600 --json
# Finish A1; now A2 is ready.
mosaic fleet backlog complete --id A1 mosaic fleet backlog complete --id A1
mosaic fleet backlog list --ready-only --json mosaic fleet backlog list --ready-only --json
# Recover stalled work.
mosaic fleet backlog reclaim --json mosaic fleet backlog reclaim --json
``` ```

View File

@@ -0,0 +1,42 @@
# Desired, Derived, and Observed Fleet State
## One writable authority
The canonical local v2 roster at <MOSAIC_HOME>/fleet/roster.yaml is desired state. Membership, stable identity, class, runtime/provider/model selection, launch policy, enablement, and persisted `running`/`stopped` intent are written only through generation-guarded roster mutations.
Derived projections are reproducible consequences of that authority:
- <name>.env.generated;
- exact roster-named tmux sessions on the configured socket after reconciliation;
- systemd service targets managed by installation/reconciliation.
Current systemd unit enablement is not yet lifecycle-conformant at boot: installation can enable every
agent unit, and the launcher projection does not carry `enabled` or `desired_state`. Therefore reboot
preservation for stopped/disabled agents remains an FCM-M3-002 acceptance hold, not a guaranteed
projection behavior.
Observed evidence available to current roster-v2 status commands includes systemd active state, tmux
presence, holder ownership, and unmanaged sessions. Heartbeat files are observational in the wider fleet,
but roster-v2 `status`, `doctor`, and `verify` do not currently read them. Observation never writes back
to the roster.
## Generation and ownership
`generation` is a positive integer concurrency fence. A mutating request must provide the current value. Successful changed CRUD increments it exactly once; stale or concurrent writers fail before mutation. Apply/reconcile rereads the canonical roster under a private exclusive lock and uses only that generation and content for effects.
Ownership is exact, never fuzzy. Reconciliation is limited to roster names, the configured socket, the exact holder session, a private installation identity, and private managed paths. An ownership mismatch, unmanaged session, unsafe path, stale generation, or ambiguous lock fails closed.
## Drift decisions
| Observation | Interpretation | Safe response |
| ---------------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
| Generated file differs or is missing | Derived projection drift | Review apply --dry-run; regenerate from the roster. |
| Desired `running`, exact session missing | `missing-session` | Diagnose ownership/runtime, then reconcile if safe. |
| Desired `stopped`, exact session present | `unexpected-session` | Inspect; reconciliation may stop only the proven roster target. |
| Disabled agent running | `disabled-running` | Inspect; disabled state wins during explicit safe reconciliation. |
| Unknown session on the configured socket | Unmanaged state | Report only. Do not adopt, rename, or kill it. |
| Heartbeat stale in the wider fleet | Liveness evidence | Diagnose separately; current roster-v2 status does not read heartbeat. |
`status` and `doctor` classify. `verify` is also observational but exits non-zero when ownership, drift, or unmanaged-state checks fail. `plan`/apply --dry-run validates proposed projection and lifecycle work without mutation. `apply` and `reconcile` converge only after all preconditions pass.
A partial projection failure does not roll the roster back. Treat the committed roster as authority and regenerate. A lifecycle failure after projection completion preserves both roster and projections for inspection. Sensitive legacy values are never printed; diagnostics are bounded to stable codes, key names where applicable, and hashes.

View File

@@ -0,0 +1,23 @@
# Generated Environment Launch Chain
The launcher consumes validated data, not shell configuration.
1. Read and validate the canonical roster.
2. Render deterministic <name>.env.generated data from that roster.
3. Parse optional <name>.env.local through a strict allowlist.
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
5. Derive the runtime command from validated runtime/model/reasoning data.
6. Target only the exact configured tmux socket and roster session after ownership checks.
## File precedence and ownership
| File | Owner | Use |
| ----------------- | ------------------------ | --------------------------------------------------------------------------- |
| `.env.generated` | Mosaic projection writer | Complete deterministic roster projection. Rebuild; do not edit. |
| `.env.local` | Operator | Optional, private, strict machine-local data. Cannot shadow generated keys. |
| `.env` | Legacy input | One-time migration input only; never launch authority. |
| `.env.quarantine` | Private quarantine | Retained unsafe legacy evidence; never loaded by the launcher. |
Neither systemd nor the launcher sources these files. No `eval`, shell expansion, arbitrary `MOSAIC_AGENT_COMMAND`, channel, or secret-reference compatibility path exists. Safe legacy generated keys are regenerated, allowed local keys are relocated, and unsafe material is quarantined.
Diagnostics never expose the rejected value, credential material, or command text. They are bounded to stable rule code, key name where safe, and SHA-256 content identity. See [generated environment reference](../reference/generated-env-boundary.md) and [quarantine operations](../operations/env-quarantine.md).

View File

@@ -0,0 +1,20 @@
# Fleet Identity, Class, and Runtime
Each roster field has one job. Do not use names or model strings as authority shortcuts.
| Concern | Field | Contract |
| ----------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
| Stable machine identity | agents[].name | Unique, immutable mutation target and exact service/session name. |
| Display identity | agents[].alias | Human-facing label only; may be changed and grants no authority. |
| Behavioral contract | agents[].class | Resolves through the shared baseline plus `roles.local` persona library. |
| Tool boundary | agents[].tool_policy | Must match protected canonical classes; cannot independently grant authority. |
| Harness | agents[].runtime | One of `claude`, `codex`, `opencode`, or `pi`, declared in `runtimes`. |
| Backend selection | agents[].provider and `model` | Explicit non-empty data; capability validity is not inferred from the display name or class. |
| Effort | agents[].reasoning | `low`, `medium`, or `high`. |
| Local placement | `working_directory` | Explicit safe local work path; not remote placement authority. |
Tess and Ultron are conventional instance/display names only. They are not products, required machine identities, role aliases, or authority-bearing classes. A configurable interaction instance uses class: interaction; a configurable validation instance uses class: validator. Any stable name and alias satisfying the structural contract may be used.
Class aliases are deliberately narrow: implementer → code, reviewer → review, and operator-interaction → interaction. No runtime, provider, model, persona prose, or instance name changes this mapping. See [role classes](../reference/role-classes.md) and the [validated generic example](../examples/roster-v2.yaml).
Roster v2 is local-only. It contains no host/SSH placement, connector, channel, secret-reference, arbitrary-command, per-agent socket, or gateway mapping fields. Those concerns require separate requirements and threat models.

View File

@@ -0,0 +1,22 @@
# Fleet Role Authority and Leases
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
## Required workstream classes
`code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction` are required FCM classes. `merge-gate` is additionally protected because it remains the sole approve-to-land and merge authority.
| Class | Authority | Boundary |
| -------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `merge-gate` | Approve-to-land and merge | Sole merge authority. |
| `validator` | Issue independent validation evidence/certificate | Never approves landing or merges. |
| `orchestrator` | Orchestrate topology and issue bounded leases | Does not gain merge authority. |
| `team-leader` | Use explicitly leased capacity | Cannot issue leases or mutate roster, credentials, topology authority, or merge state. |
| `interaction` | Receive requests and report status | Cannot orchestrate, issue leases, mutate configuration, or merge. |
| `code`, `review`, `enhancer`, custom classes | No protected authority by default | Persona prose cannot grant protected powers. |
A lease is capacity authorization from an orchestrator, not ownership. It must identify a bounded task or period and does not alter the leased agent's roster identity, role contract, credentials, authority, or persisted lifecycle. Expiry/revocation returns capacity; it does not rewrite the roster.
Semantic validation rejects protected class/tool-policy mismatch in either direction. An instance named Ultron with class: validator remains validation-only. An instance named Tess with class: interaction remains request/status-only. Renaming either instance changes no authority.
For resolver layering and safe customization, see [role classes](../reference/role-classes.md) and [customize roles](../how-to/customize-roles.md).

View File

@@ -0,0 +1,61 @@
version: 2
generation: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: code-example
alias: Code Example
class: code
runtime: pi
provider: example-provider
model: example-model
reasoning: medium
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false
- name: interaction-example
alias: Interaction Example
class: interaction
runtime: pi
provider: example-provider
model: example-model
reasoning: low
tool_policy: interaction
working_directory: ~/src
persistent_persona: true
reset_between_tasks: false
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false
- name: validator-example
alias: Validator Example
class: validator
runtime: pi
provider: example-provider
model: example-model
reasoning: high
tool_policy: validator
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false

View File

@@ -15,7 +15,7 @@ core.
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`): Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
```ts ```typescript
interface OrchestratorConnector { interface OrchestratorConnector {
readonly kind: 'tmux' | 'discord' | 'matrix'; readonly kind: 'tmux' | 'discord' | 'matrix';
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
@@ -25,11 +25,11 @@ interface OrchestratorConnector {
``` ```
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the - **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
ack half; `health()` is the liveness half. ack half; health() is the liveness half.
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are - **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
Discord plugin) fit **without an interface change**. Discord plugin) fit **without an interface change**.
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)` - **Registry** (`registry.ts`) — implementations register a factory by kind; createConnector(config)
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2. `tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
@@ -39,7 +39,7 @@ A roster may carry an optional `connector` block (`roster.schema.json`); absent
```yaml ```yaml
connector: connector:
kind: matrix # tmux | discord | matrix kind: matrix
matrix: matrix:
homeserver_url: https://matrix.example.internal homeserver_url: https://matrix.example.internal
user_id: '@mos:example.internal' user_id: '@mos:example.internal'
@@ -56,10 +56,10 @@ The connector speaks the **Matrix client-server API** directly over HTTPS (`fetc
for MVP), so it is **homeserver-agnostic**: for MVP), so it is **homeserver-agnostic**:
| Op | Matrix CS-API | | Op | Matrix CS-API |
| ----------- | ------------------------------------------------------------------------ | | ----------- | ----------------------------------------------------------------------- |
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` | | `send` | PUT /\_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId} |
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline | | `subscribe` | GET /\_matrix/client/v3/sync (long-poll, `since` token) → room timeline |
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) | | `health` | GET /\_matrix/client/versions (reachable) + …/account/whoami (authed) |
| threads | `m.thread` relations ↔ `threadId` | | threads | `m.thread` relations ↔ `threadId` |
## Local homeserver (infra, not connector code) ## Local homeserver (infra, not connector code)
@@ -79,7 +79,7 @@ homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not conn
| ----- | --------------------------------------------------------------------------------------- | ------- | | ----- | --------------------------------------------------------------------------------------- | ------- |
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes | | **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow | | 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow | | 2 | fleet init / `configure` connector-selection UX; roster parse wires the block | follow |
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow | | 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow | | 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |

View File

@@ -0,0 +1,21 @@
# Configure an Interaction Instance
An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable `name`, any descriptive `alias`, a supported declared runtime, explicit provider/model/reasoning, and a safe work directory. Start with:
```yaml
name: interaction-example
alias: Interaction Example
class: interaction
tool_policy: interaction
lifecycle:
enabled: true
desired_state: stopped
```
Plan the complete agent payload with the current roster generation, then create it without `--persisted-start`. Creation defaults to enabled/stopped and performs no runtime action. Review the resulting roster and projection plan before any later lifecycle decision.
The interaction class is request/status only. It cannot orchestrate, issue leases, mutate the roster/configuration, grant credentials, certify validation, approve landing, or merge. Connector and channel configuration are outside roster v2; do not add connector, channel, secret, command, remote-host, or gateway fields.
See [safe CRUD](create-update-delete-agent.md), [identity separation](../concepts/identity-class-runtime.md), and [role authority](../concepts/role-authority-and-leases.md).

View File

@@ -0,0 +1,21 @@
# Configure a Validator Instance
A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable name and explicit supported runtime/provider/model/reasoning values. Start stopped:
```yaml
name: validator-example
alias: Validator Example
class: validator
tool_policy: validator
lifecycle:
enabled: true
desired_state: stopped
```
Plan the full payload with the current generation and create without `--persisted-start`. Creation writes desired state and projections only; it does not launch a validator.
`validator` may issue independent validation evidence or a certificate. It has no approve-to-land or merge authority. `merge-gate` remains the sole protected merge authority, and changing the validator's name, alias, persona prose, runtime, provider, model, or tool-policy text cannot elevate it.
Certificate consumption and final release evidence remain FCM-M5-002 gates. This page does not create a certificate or authorize merge. See [safe CRUD](create-update-delete-agent.md) and [role authority](../concepts/role-authority-and-leases.md).

View File

@@ -4,20 +4,20 @@ Use the local roster-v2 control plane only. These commands change desired state
## Read and plan first ## Read and plan first
```sh ```fleet-synopsis
mosaic fleet get <name> mosaic fleet get <name>
mosaic fleet plan create --expected-generation <n> --agent '<json>' mosaic fleet plan create --expected-generation <n> --agent '<json>'
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>' mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
mosaic fleet plan delete <name> --expected-generation <n> mosaic fleet plan delete <name> --expected-generation <n>
``` ```
`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result. plan create takes the name from `--agent`. plan update and plan delete require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`. Every successful command prints JSON. `get` returns { "generation", "agent" }; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
## Create safely ## Create safely
```sh ```fleet-command
mosaic fleet create --expected-generation 7 --agent '{ mosaic fleet create --expected-generation 7 --agent '{
"name":"coder0", "name":"coder0",
"alias":"Coder 0", "alias":"Coder 0",
@@ -34,20 +34,20 @@ mosaic fleet create --expected-generation 7 --agent '{
}' }'
``` ```
Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value. Create defaults to enabled: true and desired_state: stopped. It does not start a process. Add `--persisted-start` only to persist desired_state: running; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
## Update and delete safely ## Update and delete safely
```sh ```fleet-synopsis
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>' mosaic fleet update <name> --expected-generation <n> --agent '<complete JSON agent payload>'
mosaic fleet delete coder0 --expected-generation 9 mosaic fleet delete <name> --expected-generation <n>
``` ```
Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical. Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical.
## Handle generation conflicts ## Handle generation conflicts
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get <name>` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock. Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON error.code: "stale-generation" with a non-zero exit. Reload with mosaic fleet get <name> or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
## Interpret partial failures ## Interpret partial failures
@@ -71,4 +71,4 @@ This is not a rollback and not a no-op: reload the roster because its generation
Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone. Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone.
The commands operate only on `<mosaic-home>/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations. The commands operate only on <mosaic-home>/fleet/roster.yaml, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.

View File

@@ -2,8 +2,8 @@
Mosaic resolves persona contracts through two layers: Mosaic resolves persona contracts through two layers:
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract. 1. fleet/roles/<canonical-class>.md — seeded baseline contract.
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins. 2. fleet/roles.local/<canonical-class>.md — operator override or custom role; this layer wins.
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation, The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
and launch-time persona injection. and launch-time persona injection.
@@ -34,11 +34,11 @@ A custom class remains supported when a readable contract exists for the exact i
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy. The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
``` ```
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching Save it as `fleet/roles.local/release-notes.md`, then reference class: release-notes and a matching
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient. tool_policy: release-notes in roster v2. Adding only a `LIBRARY.md` row is insufficient.
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class. contracts. agents[].alias, Tess, and Ultron are display names and cannot select a class.
## Validation and authority boundaries ## Validation and authority boundaries

View File

@@ -2,22 +2,22 @@
Use the canonical local roster-v2 command surface: Use the canonical local roster-v2 command surface:
```sh ```fleet-synopsis
mosaic fleet apply --expected-generation <n> --dry-run mosaic fleet apply --expected-generation <n> --dry-run
mosaic fleet apply --expected-generation <n> mosaic fleet apply --expected-generation <n>
mosaic fleet reconcile --expected-generation <n> mosaic fleet reconcile --expected-generation <n>
mosaic fleet start <name> --expected-generation <n> mosaic fleet start <name> --expected-generation <n>
mosaic fleet stop <name> --expected-generation <n> mosaic fleet stop <name> --expected-generation <n>
mosaic fleet restart <name> --expected-generation <n> mosaic fleet restart <name> --expected-generation <n>
mosaic fleet status [name] mosaic fleet status [<name>]
mosaic fleet verify mosaic fleet verify
mosaic fleet doctor mosaic fleet doctor
``` ```
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started. Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. Explicit `apply` and `reconcile` rebuild derived projections and enforce persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started. This guarantee does not extend to reboot/service activation yet; boot preservation remains an FCM-M3-002 hold.
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state. `start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. `update` preserves the agent's existing lifecycle, and no delivered operation changes durable lifecycle after creation.
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op. Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports projections: "incomplete" with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets. These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.

View File

@@ -11,7 +11,7 @@ artifact is added, removed, or left without one of the dispositions below.
## Disposition rules ## Disposition rules
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must - **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2 declare version: 1. It remains a compatibility fixture; it is not silently treated as a v2
roster or given inferred aliases. roster or given inferred aliases.
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared - **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes. baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
@@ -59,7 +59,7 @@ rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md
## Running the guard ## Running the guard
```bash ```fleet-command
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \ pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture" -t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
``` ```

View File

@@ -2,14 +2,14 @@
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only **Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus mosaic fleet migrate-v1 preview inventories a v1 roster and emits a canonical v2 candidate plus
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback. `tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
FCM-M4-002 owns reversible cutover and rollback. FCM-M4-002 owns reversible cutover and rollback.
## Inputs ## Inputs
```bash ```fleet-command
mosaic fleet migrate-v1 preview \ mosaic fleet migrate-v1 preview \
--source roster-v1.yaml \ --source roster-v1.yaml \
--decisions migration-decisions.json \ --decisions migration-decisions.json \
@@ -45,13 +45,13 @@ be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition ## Field disposition
| v1 field | v2 disposition | | v1 field | v2 disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block | | `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation | | agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical ~/~/... values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference | | `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation | | `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required | | `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block | | agent `host`, `ssh` | host != fleetHost is demonstrably remote and inventory-only; host == fleetHost stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition | | agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled | | root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness | | unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
@@ -61,8 +61,8 @@ be marked disabled. Observed-stopped agents always remain stopped.
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover | | legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 | | legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
The only automatic aliases are `implementer → code`, `reviewer → review`, and The only automatic aliases are implementer → code, reviewer → review, and
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic operator-interaction → interaction. Similar or domain-specific names are never inferred. Automatic
classes do not accept competing disposition records. Semantic validation delegates to the existing classes do not accept competing disposition records. Semantic validation delegates to the existing
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler. baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.

View File

@@ -44,9 +44,9 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements:
| MVP req | What it means for the Fleet | | MVP req | What it means for the Fleet |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) | | MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage | | MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own systemd --user, socket, ~/.config/mosaic); no cross-tenant leakage |
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth | | MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push | | MVP-X4 quality gates | pnpm typecheck/`lint`/`format:check` green before any push |
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker | | MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary | | MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` | | MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
@@ -56,9 +56,9 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements:
One **definition** is the source of truth; the **session** is how it runs. One **definition** is the source of truth; the **session** is how it runs.
| Layer | Owner | Phase-2 reality | Destination | | Layer | Owner | Phase-2 reality | Destination |
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it | | **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; mosaic agent --new materializes it |
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts | | **Tenancy boundary** | **Linux uid per tenant** (linger, own systemd --user, own socket, own ~/.config/mosaic) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs | | **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" | | **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams | | **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
@@ -68,7 +68,7 @@ One **definition** is the source of truth; the **session** is how it runs.
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`). > **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand); > The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`, > an absent roster `socket_name` means the default socket everywhere (spawn, fleet ps,
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory` > onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
> socket pending migration. > socket pending migration.
@@ -177,7 +177,7 @@ routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-l
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
decides its own budget — see delegation discipline). decides its own budget — see delegation discipline).
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes **Budget CLI UX (#558):** mosaic budget set --reset-at sets the window reset; reset-datetimes
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
authorization**. (Also feeds the budgeting workstream, not only this doc.) authorization**. (Also feeds the budgeting workstream, not only this doc.)
@@ -185,14 +185,14 @@ authorization**. (Also feeds the budgeting workstream, not only this doc.)
## Observation model ## Observation model
| Verb | Behavior | | Verb | Behavior |
| ----------------------------------- | -------------------------------------------------------------------------------------------------- | | --------------------------------- | -------------------------------------------------------------------------------------------------- |
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags | | mosaic fleet ps | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes | | mosaic agent watch <name> | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) | | mosaic agent attach <name> | explicit interactive takeover (the only path that can type) |
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected | | mosaic agent send <name> --verify | confirms message **accepted**, not merely keystroke-injected |
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory` > Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane` > socket (invisible to default tmux ls), the only sanctioned read is `capture-pane`
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The > (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
> verbs above restore "join and observe" safely. > verbs above restore "join and observe" safely.
@@ -214,7 +214,7 @@ compromised pane cannot corrupt or exfiltrate the register.
| Layer | Responsibility | Implementation | | Layer | Responsibility | Implementation |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) | | **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes | | **Access** | Typed, auth-gated API | Gateway fleet/\* routes |
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new | | **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions | | **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
@@ -236,7 +236,7 @@ role implementation.
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB, `docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
`fleet.*` tables and commits the output. DB is authoritative; docs are for human fleet.\* tables and commits the output. DB is authoritative; docs are for human
reference. reference.
### Spend ### Spend
@@ -267,11 +267,11 @@ re-evaluate if isolation or write-volume demands it.
## Phased roadmap ## Phased roadmap
| Phase | Outcome | Status | | Phase | Outcome | Status |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| 01 | tmux PoC, hardening, published CLI v0.0.34 (#565#568) | ✅ done | | 01 | tmux PoC, hardening, published CLI v0.0.34 (#565#568) | ✅ done |
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now | | **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now |
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned | | 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned | | 4 — Unified definition | one agent schema in gateway; mosaic agent --new → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned | | 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
## Decisions of record (2026-06-20, with Jason) ## Decisions of record (2026-06-20, with Jason)
@@ -285,9 +285,9 @@ re-evaluate if isolation or write-volume demands it.
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5. - Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude - Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
Code only** (avoid alternate-harness API pricing). Validated durable recipe: Code only** (avoid alternate-harness API pricing). Validated durable recipe:
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the mosaic yolo pi --model openai-codex/gpt-5.5:high. Durable detached launch requires the
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger), runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
which `fleet init` should automate. which fleet init should automate.
## Decisions of record (2026-06-22, with Jason) ## Decisions of record (2026-06-22, with Jason)
@@ -304,19 +304,18 @@ re-evaluate if isolation or write-volume demands it.
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are - **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard. have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as **Enforcement split:** the _window_ lives in **~/.claude/settings.json** (host-global) as
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch "autoCompactWindow": 200000 + "autoCompactEnabled": true; the _1M-disable_ lives in **launch
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude a [1m] model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
agent is capped at spawn. (settings = window; env = 1M-disable.) agent is capped at spawn. (settings = window; env = 1M-disable.)
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane - **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not. **stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to - **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration subagents / workflows / ultracode / coder agents and confines its own context to the personal-assistant
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its lane. Keeping delivery out of the orchestrator's window keeps its context unpolluted and measurably
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and reduces off-plan divergence. The orchestrator coordinates and decides; it does not implement.
decides; it does not implement.
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern - **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend (see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization), is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
@@ -344,7 +343,7 @@ re-evaluate if isolation or write-volume demands it.
### Control plane & central register ### Control plane & central register
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure. - **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern. - **Access:** gateway API only (`apps/gateway`, fleet/\* routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask``agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now). - **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask``agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban. - **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented. - **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
@@ -357,9 +356,9 @@ re-evaluate if isolation or write-volume demands it.
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is - **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in **per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists — ~/.claude/settings.json; each agent chooses its model independently. The plumbing already exists —
roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends `--model <hint>` to that roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends --model <hint> to that
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`. agent's harness (claude or pi); settable today via mosaic fleet add|edit <agent> --model <hint>.
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity **North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
@@ -385,7 +384,7 @@ re-evaluate if isolation or write-volume demands it.
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
future transport — peer to tmux/Discord, not superseded by them. future transport — peer to tmux/Discord, not superseded by them.
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface; - **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
`tmux send-keys` / socket access could enable malicious action against agents directly. tmux send-keys / socket access could enable malicious action against agents directly.
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
@@ -418,9 +417,9 @@ re-evaluate if isolation or write-volume demands it.
--- ---
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet > **Release procedure (drift re-capture, 2026-06-22):** mosaic update only propagates new fleet
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never > commands when the **CLI version is bumped** — without a version bump, fleet command changes never
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update` > reach installed hosts. The release/version-bump procedure (bump → publish → mosaic update
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting > [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
> workstream.) > workstream.)
> >

View File

@@ -30,7 +30,7 @@ connector entry.
The preview evidence deliberately records: The preview evidence deliberately records:
- `executable: false`; - executable: false;
- required backup artifacts; - required backup artifacts;
- source and candidate identities; - source and candidate identities;
- lifecycle observations and resulting desired states; - lifecycle observations and resulting desired states;

View File

@@ -0,0 +1,20 @@
# Environment Quarantine Operations
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
- generated roster keys → discard and regenerate;
- allowed strict local keys → relocate to private `.env.local`;
- malformed, duplicate, unknown, sensitive-looking, shell-bearing, unsafe, or command-override entries → move the legacy input to private `.env.quarantine`.
## Safe response
1. Stop and read the stable error code and reported key name/hash. Do not request or paste the value.
2. Confirm the canonical roster contains the intended non-sensitive desired state.
3. If the key is an allowed local machine-data field, place only its validated data form in `.env.local` under private permissions.
4. Remove unsupported intent rather than translating it into commands, channels, secret references, or unknown MOSAIC*AGENT*\* keys.
5. Regenerate `.env.generated` from the roster and rerun a dry-run/verification gate.
6. Retain quarantine evidence privately until the operator's normal retention process permits removal.
The launcher never reads quarantine. Public/JSON diagnostics expose stable code, key name where safe, and SHA-256 only—never a legacy sensitive value, credential, rejected command, or full line. Quarantine does not prove remediation, backup, migration, or rollback.
See [generated launch chain](../concepts/generated-env-launch-chain.md), [generated environment boundary](../reference/generated-env-boundary.md), and [migration field disposition](../migration/v1-to-v2.md#field-disposition).

View File

@@ -2,10 +2,12 @@
## Safe sequence ## Safe sequence
1. Read `mosaic fleet doctor` and `mosaic fleet status`. 1. Read mosaic fleet doctor and mosaic fleet status.
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`. 2. Run mosaic fleet apply --expected-generation <n> --dry-run.
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying. 3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood. 4. Run mosaic fleet apply --expected-generation <n> only after the plan is understood.
This is per-generation convergence, not a rolling canary. Executable canary cutover/rollback remains held for FCM-M4-002; rolling local release evidence remains FCM-M5-002. Do not approximate either with repeated live apply commands.
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it. The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
@@ -23,4 +25,4 @@ The roster is never changed by reconciliation. If derived projection application
} }
``` ```
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content. If projections completed but lifecycle work failed, JSON reports projections: "complete", lifecycle: "incomplete", and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" } without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.

View File

@@ -0,0 +1,24 @@
# Systemd and tmux Troubleshooting
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
## Decision table
| Finding | Meaning | Safe next step |
| ------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Empty roster `tmux.socket_name` | Literal default tmux server | Do not substitute the named `mosaic-fleet` socket. Use roster-derived commands only. |
| Non-empty socket | Exact named socket | Never target another socket or infer a per-agent socket. |
| holder: missing | Required exact holder absent | Inspect installation/projection readiness; do not create an unproven holder manually. |
| `ownership-mismatch` | Holder identity or global environment differs | Stop. Verify private install identity and managed paths before retry. |
| `missing-session` | Desired-running roster agent lacks exact session | Check service/runtime preconditions; review apply dry-run. |
| `unexpected-session` | Desired-stopped roster agent still has exact session | Confirm ownership; only reconciler may target the exact proven roster member. |
| `disabled-running` | Disabled roster member is observed running | Inspect and reconcile only after ownership proof. |
| `unmanagedSessions` | Unknown session exists on configured named socket | Report and investigate separately. Reconciler will not kill or adopt it. |
| stale/concurrent generation | Desired state changed since plan | Reload roster/generation and recompute the plan. |
| stale or ambiguous lock | Prior writer/cleanup cannot be proven | Inspect ownership; do not blindly remove the lock. |
| projection failure | Derived files incomplete | Keep roster as authority and regenerate projections. |
| lifecycle failure | Projections complete, runtime convergence incomplete | Inspect the exact owned resource, then rerun with current generation. |
Systemd state, tmux state, heartbeat, and generated files are observations/projections, not alternate desired state. Explicit apply/reconcile honors stopped/disabled intent, but current unit enablement and launcher projections do not yet prove lifecycle-safe reboot; inspect unit enablement before reboot and treat stopped/disabled boot preservation as an FCM-M3-002 hold. Current roster-v2 status commands also do not read heartbeat files. Executable gates do not provide site cutover/rollback or package asset-revision repair.
Errors and troubleshooting output never print legacy sensitive values, credential contents, or privileged command text. Use stable codes, key names/hashes, exact roster identities, and bounded recovery actions. See [status and drift](../reference/status-and-drift.md) and [reconcile and recover](reconcile-and-recover.md).

View File

@@ -0,0 +1,18 @@
# Upgrade and Installed-Asset Drift
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
## Current safe boundary
- The canonical roster remains authority and must survive package/framework refresh.
- Generated projections are rebuilt from that roster after the installed contract is independently verified.
- Operator `roles.local`, `.env.local`, and private quarantine evidence are not generated assets and must not be overwritten.
- Baseline roles, schemas, examples, service presets, launcher helpers, and systemd templates must move as one reviewed release set.
- Remote/connector inventory and `mos-comms` are not promoted into permanent architecture by an update.
- No update may start an agent persisted stopped, adopt an unmanaged session, or bypass generation/ownership checks.
## Explicit hold
FCM-M5-002 owns deterministic asset-drift checks, safe package/update refresh evidence, rolling local canary, independent validation certificate, and release evidence. Until that card lands, this page is an operational hold rather than an executable procedure: use the repository/release review path, preserve backups, and do not claim source/installed parity without exact revision evidence from the future validator.
See [approved deferrals](../../reports/deferred/758-fleet-config-deferrals.md) and [backup/restore boundary](backup-restore.md).

View File

@@ -4,17 +4,17 @@ FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operat
## CLI contract ## CLI contract
The commands operate only on the canonical `<mosaic-home>/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`. The commands operate only on the canonical <mosaic-home>/fleet/roster.yaml v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and launch: { "yolo": boolean }.
```sh ```fleet-synopsis
mosaic fleet get <name> mosaic fleet get <name>
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start] mosaic fleet plan <create|update|delete> [<name>] --expected-generation <n> [--agent '<json>'] [--persisted-start]
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start] mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run] mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
mosaic fleet delete <name> --expected-generation <n> [--dry-run] mosaic fleet delete <name> --expected-generation <n> [--dry-run]
``` ```
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code. `get` returns the authoritative generation and the selected agent. plan create derives its name from `--agent`; plan update <name> and plan delete <name> require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records desired_state: running, but does not start a process. Without it, create records enabled: true and desired_state: stopped. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
## Generation, validation, and idempotency ## Generation, validation, and idempotency
@@ -22,7 +22,7 @@ Each create, update, or delete request includes `expectedGeneration`. A request
`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops. `planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops.
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation. Delete removes only the exact <name>.env.generated projection for the removed roster entry. Operator-owned <name>.env.local, legacy <name>.env, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
## Result and recovery ## Result and recovery
@@ -40,4 +40,4 @@ Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `p
} }
``` ```
Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation. Dry-runs and idempotent no-ops report authoritativeRoster: "unchanged" and projections: "not-applied"; a complete mutation reports "committed" and "complete". Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.

View File

@@ -1,27 +1,46 @@
# Fleet Control-Plane CLI # Fleet Control-Plane CLI
The local roster-v2 control plane is `mosaic fleet`. The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
```text ## Roster-v2 desired-state commands
mosaic fleet apply --expected-generation <n> [--dry-run]
mosaic fleet reconcile --expected-generation <n> [--dry-run] | Command | Effect | Generation | Output |
mosaic fleet start [name] --expected-generation <n> [--dry-run] | ---------------------------------------------------------- | ---------------------------------------------- | ---------- | -------------------------- |
mosaic fleet stop [name] --expected-generation <n> [--dry-run] | mosaic fleet get <name> | Read one authoritative agent | no | One JSON object |
mosaic fleet restart [name] --expected-generation <n> [--dry-run] | mosaic fleet plan <create\|update\|delete> ... | Validate proposed CRUD and projections | required | One JSON object; no writes |
mosaic fleet status [name] | mosaic fleet create ... [--dry-run] [--persisted-start] | Add desired state; default enabled/stopped | required | One JSON object |
| mosaic fleet update <name> ... [--dry-run] | Replace mutable agent fields | required | One JSON object |
| mosaic fleet delete <name> ... [--dry-run] | Remove roster member/generated projection | required | One JSON object |
| mosaic fleet apply ... [--dry-run] | Plan or converge projections/lifecycle | required | One JSON object |
| mosaic fleet reconcile ... [--dry-run] | Alias of the same convergence contract | required | One JSON object |
| mosaic fleet start\|stop\|restart [<name>] ... [--dry-run] | Exact one-shot lifecycle action | required | One JSON object |
| mosaic fleet status [<name>] | Observe desired/managed/runtime state | no | One JSON object |
| mosaic fleet verify | Strict observational drift/ownership gate | no | One JSON object |
| mosaic fleet doctor | Classify local drift and recovery context | no | One JSON object |
| mosaic fleet migrate-v1 preview ... | Non-mutating field-complete migration evidence | no | One JSON object |
CRUD syntax and full payload shape are documented in [agent mutations](agent-mutations.md). Reconciliation syntax:
```fleet-synopsis
mosaic fleet apply --expected-generation <n>
mosaic fleet reconcile --expected-generation <n>
mosaic fleet start [<name>] --expected-generation <n> [--dry-run]
mosaic fleet stop [<name>] --expected-generation <n> [--dry-run]
mosaic fleet restart [<name>] --expected-generation <n> [--dry-run]
mosaic fleet status [<name>]
mosaic fleet verify mosaic fleet verify
mosaic fleet doctor mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path> mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
``` ```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically `get` is the read/show operation for one v2 agent. Full roster parsing and semantic validation occur on every v2 mutation/reconcile path; there is no separate mutable “config store.” The executable JSON Schema and validated example provide offline structural evidence. The PRD requires an explicit programmatic mosaic fleet validate operation, but the current CLI does not expose one; do not substitute another command or claim that requirement is delivered. This remains an implementation gap for #758.
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational. ## JSON and exit behavior
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry. Roster-v2 CRUD and reconciler precondition failures emit { "error": { "code": "..." } } and exit non-zero. Migration preview has its own result envelope: a non-ready preview emits { "status": "blocked", "blockers": [...] } and exits non-zero rather than using the CRUD/reconciler error object. Use both exit status and command-specific state fields. A partial reconciliation result distinguishes `authoritativeRoster`, `projections`, `lifecycle`, `recovery`, and optional `cleanup`; it never claims automatic rollback. `verify` exits non-zero for drift, ownership failure, or unmanaged sessions. Sensitive legacy values, credentials, and rejected command text are never printed.
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership. ## Compatibility and scope
Roster-v1 initialization, provisioning, profiles/personas, and historical fleet add/remove remain compatibility surfaces, not roster-v2 CRUD aliases. New v2 automation should use the table above. migrate-v1 preview writes nothing and has no cutover, canary, or rollback option.
mosaic agent is a separate catalog/transport surface; it does not own <MOSAIC_HOME>/fleet/roster.yaml desired state. Remote/SSH reconciliation, connector mutation, arbitrary commands/channels, secret references, and gateway convergence are rejected or outside scope.

View File

@@ -1,6 +1,6 @@
# Fleet Generated Environment Boundary # Fleet Generated Environment Boundary
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local **Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
The local fleet roster is the desired-state authority. A launch reads a deterministic, The local fleet roster is the desired-state authority. A launch reads a deterministic,
roster-derived generated projection and an optional strictly data-only local file; neither file is roster-derived generated projection and an optional strictly data-only local file; neither file is
@@ -8,14 +8,14 @@ a second roster or a command configuration surface.
## Paths and ownership ## Paths and ownership
For agent `<name>` under `<MOSAIC_HOME>/fleet/agents/`: For agent <name> under <MOSAIC_HOME>/fleet/agents/:
| Path | Owner | Purpose | | Path | Owner | Purpose |
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `<name>.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. | | <name>.env.generated | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
| `<name>.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. | | <name>.env.local | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
| `<name>.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. | | <name>.env | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
| `<name>.env.quarantine` | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. | | <name>.env.quarantine | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared
bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before
@@ -44,7 +44,7 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty> MOSAIC_TMUX_SOCKET=<roster socket or empty>
``` ```
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. `fleet add` The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
uses that same runtime authority and rejects any other runtime before it writes the roster or changes uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through socket remains an observability canary; it has no generated-launch adapter and cannot be added through
@@ -62,7 +62,7 @@ Local paths must be safe absolute paths and the interval must be a positive inte
quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names, quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names,
and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the
validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a
Pi runtime writes a fresh `<name>.hb.native` marker, its native heartbeat remains authoritative; the Pi runtime writes a fresh <name>.hb.native marker, its native heartbeat remains authoritative; the
shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent. shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent.
## Legacy disposition ## Legacy disposition
@@ -79,13 +79,13 @@ consolidated downstream interface packet. Status is deliberately separated from
product release version has been evidenced for this interface set. product release version has been evidenced for this interface set.
| Interface | Canonical public path and version | Tracker/release status | Downstream limit | | Interface | Canonical public path and version | Tracker/release status | Downstream limit |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. | | M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster version: 2 | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. | | M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 merged as #768 (`a5e8e55`); no released product version is asserted here. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. | | M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture version: 1 | FCM-M1-003 merged as #770 (`e9c4aa3`); checkout evidence remains validation, not migration authorization. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. | | M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 merged as #772 (`191efae`); no released product version is asserted here. | Render/write a roster-derived projection; local input is never authority. |
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path. The canonical source remains <MOSAIC_HOME>/fleet/roster.yaml for the current local fleet path.
Generated environment data is a rebuildable projection, not an operator-editable source of membership, Generated environment data is a rebuildable projection, not an operator-editable source of membership,
runtime policy, or lifecycle state. runtime policy, or lifecycle state.

View File

@@ -1,14 +1,21 @@
# Local Fleet Lifecycle Transitions # Local Fleet Lifecycle Transitions
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state. Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
| Command | Desired-state write | Runtime effect | Preconditions | | Event | Desired-state write | Runtime effect | Safety boundary |
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | ------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions | | fleet create | Adds enabled/stopped by default; `--persisted-start` records running | None | Generation-guarded; validates full roster/projections. |
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership | | fleet update | Preserves the existing enabled/desired state; updates other mutable fields | None | Generation-guarded; stable name and lifecycle are immutable on this path. |
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership | | fleet delete | Removes exact roster member | None | Removes only generated projection; retains local/quarantine evidence. |
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership | | fleet apply / `reconcile` | Never | Rebuilds projections; starts only enabled/running; stops disabled or stopped roster members | Current generation, private lock/paths, semantic validity, holder ownership, no unmanaged named-socket sessions. |
| fleet start <name> | Never | One-shot exact service start | Exact enabled roster name and proven ownership. |
| fleet stop <name> | Never | One-shot exact service stop | Exact roster name and proven ownership. |
| fleet restart <name> | Never | One-shot exact service restart | Exact enabled roster name and proven ownership. |
| Reboot/service activation | Never | Current installation may activate enabled units without honoring roster lifecycle | **Held for FCM-M3-002:** boot preservation for stopped/disabled agents is not yet proven; inspect/disable units rather than assuming lifecycle-safe reboot. |
| v1 migration preview | Never | None | Observed active+present maps running; inactive+missing maps stopped; ambiguity blocks. |
| Cutover/canary | Held for FCM-M4-002 | Not implemented by preview | Must preserve every observed stopped state. |
| Rollback | Held for FCM-M4-002 | Not implemented | Must restore selected authority/projections without surprise starts or unmanaged targeting. |
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state. Explicit apply/reconcile never starts a stopped roster agent. Direct lifecycle commands are explicit one-shot actions and do not persist intent. The current update operation preserves `existing.lifecycle`; there is no delivered generation-guarded CRUD operation for changing durable lifecycle after creation. Reboot preservation for stopped/disabled agents is not yet guaranteed because current enabled units and launcher projections do not carry the persisted lifecycle fence; that acceptance evidence remains FCM-M3-002.
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority. Missing/stale generation, concurrent writer, unsafe path, ownership mismatch, unmanaged session, unsupported runtime, invalid projection, and lifecycle precondition failures return stable redacted JSON and non-zero status. No command targets fuzzy names, arbitrary sockets/commands/channels/secrets, or generated files as authority. Legacy sensitive values are never printed.

View File

@@ -16,7 +16,7 @@ Only these legacy class aliases are recognized:
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
names, not classes. `agents[].alias` is display-only and cannot grant authority. names, not classes. agents[].alias is display-only and cannot grant authority.
Canonicalization happens before role lookup. For example, requesting `implementer` resolves Canonicalization happens before role lookup. For example, requesting `implementer` resolves
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical `code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical

View File

@@ -50,36 +50,38 @@ agents:
## Root fields ## Root fields
| Field | Required | Constraint | Meaning | | Field | Required | Default | Constraint | Meaning |
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | -------- | ------- | --------------------- | ------------------------------------------------------------------------------------------------- |
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. | | `version` | yes | none | integer constant `2` | Identifies this contract. Version `1` stays on the compatibility path pending explicit migration. |
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. | | `generation` | yes | none | positive safe integer | Desired-state generation and mutation/reconcile concurrency fence. |
| `transport` | yes | constant `tmux` | M1M5 support local tmux only. | | `transport` | yes | none | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. | | `tmux` | yes | none | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. | | `defaults` | yes | none | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. | | `runtimes` | yes | none | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. | | `agents` | yes | none | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields ## Nested fields
All nested fields in the v2 schema are required and have no implicit default. CRUD `create` is the only higher-level convenience: it records lifecycle.enabled: true and desired_state: stopped unless `--persisted-start` explicitly records running. That convenience still performs no runtime action.
| Path | Required | Constraint | | Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket | | `tmux.socket_name` | yes | [A-Za-z0-9_.-]\*; empty string means the literal default tmux server, while a non-empty value names a socket |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` | | `tmux.holder_session` | yes | non-empty [A-Za-z0-9_.-]+ |
| `defaults.working_directory` | yes | non-empty string | | `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime | | runtimes.<runtime>.reset_command | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity | | agents[].name | yes | unique [A-Za-z0-9][A-Za-z0-9_.-]\* stable machine identity |
| `agents[].alias` | yes | non-empty display string | | agents[].alias | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 | | agents[].class | yes | [a-z][a-z0-9-]\*; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | agents[].runtime | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | | agents[].provider, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` | | agents[].reasoning | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 | | agents[].tool_policy | yes | [a-z][a-z0-9-]\*; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans | | agents[].persistent_persona, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 | | agents[].lifecycle.enabled | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | | agents[].lifecycle.desired_state | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | | agents[].launch.yolo | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Semantic handoff ## Semantic handoff
@@ -97,12 +99,12 @@ Semantic validation:
`operator-interaction` to `interaction`; `operator-interaction` to `interaction`;
- canonicalizes `tool_policy` with the same exact alias table; - canonicalizes `tool_policy` with the same exact alias table;
- rejects protected class/tool-policy mismatches in either direction, while accepting - rejects protected class/tool-policy mismatches in either direction, while accepting
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical class: operator-interaction with tool_policy: operator-interaction as canonical
`interaction`; `interaction`;
- derives immutable protected authority only from canonical class; and - derives immutable protected authority only from canonical class; and
- accepts custom baseline or `roles.local` classes without granting protected authority. - accepts custom baseline or `roles.local` classes without granting protected authority.
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes. agents[].alias remains display-only. Tess and Ultron are instance names, never semantic classes.
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
[Customize Fleet Roles](../how-to/customize-roles.md). [Customize Fleet Roles](../how-to/customize-roles.md).
@@ -112,7 +114,7 @@ lifecycle mutation.
## Fail-closed boundary ## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed, Every object is additionalProperties: false. The compiler rejects unknown, missing, malformed,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields, per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently

View File

@@ -1,13 +1,25 @@
# Local Fleet Status and Drift # Local Fleet Status and Drift
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux. mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
The report distinguishes: ## State dimensions
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session. - **Desired:** roster membership, generation, enabled flag, and persisted running/stopped target.
- `unexpected-session`: a desired-`stopped` agent still has its exact session. - **Managed/derived:** generated environment and expected exact service/session topology.
- `disabled-running`: a disabled roster agent has its exact session. - **Observed by current roster-v2 commands:** systemd active state, tmux presence, exact holder ownership, and unmanaged sessions.
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session. Implemented drift classifications include:
- `missing-session`: enabled/desired-running agent lacks its exact session;
- `unexpected-session`: desired-stopped agent has its exact session;
- `disabled-running`: disabled roster agent has its exact session;
- `unmanagedSessions`: named-socket sessions that are neither exact holder nor roster agent;
- `holder`: `owned`, `missing`, or `ownership-mismatch` after private identity and global environment checks.
Generated projection failures/staleness are surfaced by plan/apply preparation and bounded recovery fields rather than adopted as configuration. Heartbeat remains wider-fleet observational evidence, never desired state, but the current roster-v2 `status`, `doctor`, and `verify` commands do not read heartbeat files. A provable removed-agent projection may be treated as stale derived state during deletion, but general projection-orphan classification and installed source-versus-asset revision mismatch remain FCM-M4-002/M5-002 holds; current commands must not claim those future checks.
## Command behavior
`status` and `doctor` classify rather than adopt, destroy, or repair. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
Doctor/error output uses stable codes and bounded recovery context. Migration, quarantine, lifecycle, status, and troubleshooting output never prints a legacy sensitive value, credential, or privileged command text.

View File

@@ -17,7 +17,9 @@ export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi
``` ```
The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools hook into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. The wrapper obtains a broker-minted session ID, creates a private `generation-<session>.state` file beside the socket, and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity and read the current generation from the file. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools and compaction-lifecycle hooks into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, unsafe generation state, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
Claude `PreCompact` and `SessionStart(compact)` hooks and Pi pre-/post-compaction handlers invoke `revoke-lease.py`. Pi `session_start` reload/new/resume/fork and Claude resume/clear advance the locked generation before revocation, so a replacement session inherits no lease even when PID/starttime stay unchanged. Do not invoke the revoker manually as a way to restore authority; it only removes authority. If a lifecycle hook reports failure, stop consequential work and repair broker/generation-state availability before re-verification.
Run the permanent launch inventory locally with: Run the permanent launch inventory locally with:
@@ -29,7 +31,13 @@ The same check runs in the Mosaic package test suite and therefore in root CI. A
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected. Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. `mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. Claude maps only the exact direct recovery executable/validated arguments to this exempt tool identity; ordinary `Bash` remains gated. Pi exposes only the `mosaic_context_recover` custom tool; ordinary `bash` and all other tools remain gated. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence.
Production daemon startup creates a separate private observer socket unless a test-only `--test-observer-file` fixture is selected. Claude's Stop hook sends its exact latest assistant entry and Pi's `message_end` handler sends only finalized assistant content to that authenticated transport; the broker public socket never accepts message text. This is byte-build and private out-of-process harness wiring only: do not activate it against a live daemon, live socket, systemd service, tmux session, or model-output stream outside the controlled integration procedure.
Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink.
After a runtime exits, its `generation-<session>.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
## Security posture ## Security posture

View File

@@ -0,0 +1,34 @@
# #830 Documentation Completion Checklist
## Required artifacts
- [x] `docs/PRD.md` contains the M1 compaction-refresh trust-lifecycle requirements and acceptance criteria.
- [x] Operator behavior and recovery are documented in `docs/guides/lease-broker-operations.md`.
- [x] Developer architecture and protocol behavior are documented in `docs/architecture/compaction-revocation.md`, `lease-broker-protocol.md`, and `mutator-class-gate.md`.
- [x] Security boundaries and residuals are documented in `docs/architecture/lease-broker-security.md` and `compaction-revocation.md`.
- [x] `docs/SITEMAP.md` links the new architecture page.
- [x] User-guide changes are not applicable: observers are mandatory internal runtime controls with no end-user workflow.
- [x] OpenAPI/endpoint changes are not applicable: the broker remains an internal Unix-socket protocol, not a public HTTP API.
## Contract coverage
- [x] Claude and Claudex lifecycle signals, matchers, commands, and fail-closed behavior are documented.
- [x] Pi pre-/post-compaction signals and session replacement reasons are documented.
- [x] Private generation-file ownership, monotonic update, same-PID replacement, and failure fencing are documented.
- [x] `revoke_lease` input purpose, broker response state, and denial behavior are documented.
- [x] T12b/T30 explicitly names the bounded residual stale window and reports within-TTL **ALLOWED** / after-TTL **DENIED**.
- [x] Documentation explicitly disclaims a within-window mutator-action bound.
- [x] T-A, T-C, same-principal, and protected-branch boundaries are retained.
## Structure and review
- [x] New architecture content is under `docs/architecture/`.
- [x] This report is under `docs/reports/compaction-refresh/`.
- [x] Session evidence is under `docs/scratchpads/`.
- [x] Documentation changes are in the same logical change set as code and tests.
- [ ] Independent exact-head code and Opus security reviews pending coordinator sequencing after the deterministic-main rebase gate.
## Publishing
- [x] Canonical documentation remains in-repository.
- [x] No external publishing target is required for this internal M1 control.

View File

@@ -0,0 +1,54 @@
# FCM-M5-001 Fleet Documentation Deferrals and Holds
**Issue:** #758 · **Branch:** `docs/758-fleet-config-operator-docs`
These are accepted existing DAG boundaries, not omissions silently claimed as delivered.
## FCM-M3-002 hold
- Boot/reboot preservation for roster members persisted stopped or disabled.
- Current installation may enable all agent units, while the launcher projection does not yet carry
`lifecycle.enabled` or `desired_state`; documentation therefore does not claim lifecycle-safe reboot.
- Heartbeat/liveness integration into roster-v2 `status`, `doctor`, and `verify`; current observations
cover systemd active state, tmux sessions, holder ownership, and unmanaged sessions only.
## FCM-M4-002 hold
- Executable v1-to-v2 cutover, reversible canary, and rollback.
- Stale-projection/orphan migration classification and current-host managed/unmanaged fixture coverage.
- Any live migration, lifecycle, systemd/tmux/session, or rollback action.
M5 docs describe prerequisites and the preview boundary only. A ready preview is not migration or rollback evidence.
## Explicit validate-operation gap
- `FCM-REQ-03` requires a documented programmatic `mosaic fleet validate` operation.
- The current CLI does not expose that operation. Existing mutation/reconcile validation and the
documentation example test are not a replacement for the missing command.
- FCM-M5-001 documents this implementation gap without inventing syntax, JSON, exit behavior, or an
owning implementation card. Parent #758 must remain open until the requirement is implemented and
evidenced or the PRD/DAG is explicitly revised through the authoritative process.
## FCM-M5-002 hold
- Deterministic source-versus-installed asset revision detection and safe refresh implementation.
- Rolling local canary, independent validator certificate, final release evidence, merge-gate approval, and parent #758 closure.
`operations/upgrade-assets.md` is therefore a fail-closed hold, not an invented procedure.
## Compatibility interpretation
The M0 cross-cutting row requiring every retained/migrated artifact to validate through the executable contract is satisfied by each artifact's declared executable disposition, not by forcing versioned v1 fixtures through the v2 parser:
- retained examples are explicit `version: 1` fixtures validated by the production v1 parser;
- canonical profiles validate through the shared baseline plus `roles.local` resolver;
- the service preset validates through its production service-policy reader;
- migration candidates validate through the production v2 compiler and shared semantic resolver.
The executable disposition inventory rejects undeclared additions/removals and prevents silent legacy drift.
## Repository-wide documentation structure
The accepted #758 IA is the domain book under `docs/fleet/`. Creating global `USER-GUIDE`, `ADMIN-GUIDE`, or `DEVELOPER-GUIDE` books and cleaning unrelated pre-existing `docs/` root files are outside this bounded card. The repository sitemap links the fleet book. No HTTP/API/auth contract changed, so OpenAPI and endpoint-index updates are not applicable.
Canonical documentation remains in-repository; no external publishing or generated publishing output is in scope. Parent issue #758 stays open through M5.

View File

@@ -0,0 +1,44 @@
# FCM-M5-001 Fleet Documentation IA Closure Evidence
**Issue:** #758 · **Task:** FCM-M5-001
## Artifact map
- Fleet entry point and desired/observed decision tree: `docs/fleet/README.md`.
- Concepts: `docs/fleet/concepts/` covers authority/projections, identity separation, role authority/leases, and the generated launch chain.
- Operator workflows: `docs/fleet/how-to/` covers CRUD, lifecycle, interaction and validator instances, and role overrides.
- Operations: `docs/fleet/operations/` covers reconciliation/recovery, quarantine, systemd/tmux troubleshooting, backup/restore boundaries, and upgrade-asset holds.
- References: executable schema, complete field/default/constraint reference, CLI/JSON/exit behavior, lifecycle/status/drift, role authority, and generated environment boundary under `docs/fleet/reference/`.
- Migration: preview field map, lifecycle preservation, backup/recovery prerequisites, aliases, and executable artifact dispositions under `docs/fleet/migration/`.
- Navigation: `docs/SITEMAP.md` and the fleet entry point.
## Acceptance mapping
| Checklist area | Evidence |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Roster authority and fail-closed legacy handling | Root PRD FCM-REQ-01/05/08; desired/observed and quarantine pages. |
| Classes and authority | Root PRD FCM-REQ-07; role authority concept/reference; configurable interaction/validator how-tos. |
| Lifecycle | Root PRD FCM-REQ-04; lifecycle transition table and operator lifecycle how-to. |
| Local-only generated launch boundary | Root PRD FCM-REQ-05/09; generated launch concept/reference. |
| Complete DAG and artifact inventory | `docs/TASKS.md`; M0 inventory; executable disposition tests. |
| IA pages | Every path named by the M0 checklist exists and is linked from `docs/fleet/README.md`. |
| Examples | `docs/fleet/examples/roster-v2.yaml` validates through production v2 compiler/shared resolver; shipped artifact dispositions validate through declared production readers. |
| Links | Deterministic local Markdown link test covers the entire fleet book and sitemap, including local heading-fragment resolution. |
| Sensitive/example safety | Validator scans backtick- and tilde-fenced fleet-book examples plus the canonical roster for sensitive-looking keys, common credential formats (including Anthropic, OpenAI project, and Stripe restricted keys), path-qualified privileged commands, package-manager/root commands, arbitrary command override, and hardcoded Tess/Ultron identities; findings report only file/block and violation kind, never matched values. |
| Holds | `docs/reports/deferred/758-fleet-config-deferrals.md` records M3-002, M4-002, M5-002, compatibility, and repository-structure boundaries. |
## Documentation completion checklist
- [x] Root PRD exists and remains the #758 requirements authority.
- [ ] The accepted project-specific fleet book is indexed, but it is not complete against `FCM-REQ-03`: the required explicit programmatic `mosaic fleet validate` operation is not implemented. The CLI reference and deferral report record this gap without inventing behavior.
- [x] Sitemap links the fleet entry point and operator-critical pages.
- [x] No HTTP/API/auth contract changed; OpenAPI/endpoint rows are not applicable.
- [x] Working evidence remains under `docs/scratchpads/`; closure and deferral evidence remains under `docs/reports/`.
- [x] Canonical source remains in-repository; no external publishing action is in scope.
- [ ] Independent exact-head documentation review, PR CI, and FCM-M5-002 release certificate remain post-PR gates and are not claimed here.
## Live-action boundary
No migration, canary, rollback, deployment, systemd/tmux/session operation, generated projection, or product mutation was performed. `roster.yaml` remains the sole writable desired-state authority. `mos-comms` remains temporary. Parent issue #758 remains open.
Validation command results and exact commit/tree evidence are recorded in the task scratchpad and PR body after execution.

View File

@@ -0,0 +1,58 @@
# Issue #812 — durable Gitea PR review comments
- **Lane:** ms-812
- **Branch:** `fix/812-pr-review-comment`
- **Issue:** mosaicstack/stack#812
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
## Objective
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
## Plan
1. Add and commit a failing shell regression harness before production changes.
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
3. Implement the minimal supported write plus ID-based provider read-back.
4. Document that wrapper write output is not durable provenance until read-back succeeds.
5. Run focused regression tests, touched-package tests, and repository quality gates.
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
## Progress checkpoints
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
- [x] Focused, package, and repository gates green
- [ ] Coordinator-owned independent review pending after push
- [x] No PR opened; no self-review or self-merge
## Tests run
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
## Risks / blockers
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
- Existing approve/request-changes behavior remains covered.
- Independent exact-head re-review remains coordinator-owned.
## Final verification evidence
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.

View File

@@ -0,0 +1,95 @@
# WI-3 Scratchpad — Compaction revocation and runtime-generation rollover
- **Issue:** Gitea #830
- **Branch:** `feat/830-compaction-revoke`
- **Base HEAD:** `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`
- **Role:** sol author/build lane only; terra CODE and Opus SECREV are coordinator-owned.
## Mission prompt
Implement BUILD-BRIEF Deliverable 3.3 and D4 on merged WI-1/WI-2 under `packages/mosaic/`. Claude `PreCompact` and `SessionStart(matcher=compact)` plus Pi `session_before_compact`/`context` equivalents must revoke the active lease through the existing broker state machine. Any `runtime_generation` bump—including same-PID reload/resume/fork—must auto-revoke the prior incarnation so the new generation inherits no prior lease. M1 is Claude + Pi only.
Honor amended D2-v5 exactly: hard fail-closure when at least one observer fires or after lease expiry; both observers missing within TTL is an explicitly named bounded residual stale window (maximum 300 seconds, soak-tighten only), with no claim that the mutator gate bounds actions inside that window; total gate-hook miss is T-C. T12b/T30 must report both the within-TTL ALLOWED outcome and after-TTL DENIED outcome.
## Session start verification
- Worktree is clean on `feat/830-compaction-revoke` at exact required base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`; `origin/main` is the same SHA and includes merged WI-2 atop WI-1.
- Authority SHA-256 verified:
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
- sol red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa`
- WI-0 evidence pack SHA-256 `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d` read directly. Probe P3 is **PASS**: real Pi retained the same PID/starttime through reload/fork/new/resume while generations advanced and a prior VERIFIED generation was revoked.
- P6 planner-return ruling SHA-256 `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09` read directly: feature WI admission is GO with the exact-delivery empirical compatibility fact and disclosed T-C middle-drop residual; no receipt redesign.
## Plan and budget
1. RED real-socket acceptance for T12b/T30, each Claude observer, same-PID generation rollover, Claude/Claudex hook wiring, and Pi lifecycle wiring.
2. Add one broker client executable for observer revocation plus a private monotonic generation-file helper shared by launcher, gate, and revoker.
3. Wire Claude `PreCompact`, `SessionStart(compact)`, and resume/clear generation rollover; merge equivalent mandatory hooks into isolated Claudex settings.
4. Wire Pi pre/post compaction observers and reload/new/resume/fork generation rollover with local fail-closed tool blocking if lifecycle revocation fails.
5. Document the D2-v5 bounded stale window without claiming the mutator gate bounds within-TTL actions; update protocol/security/operations/sitemap/checklist.
6. Run focused real tests, independently measured executable coverage ≥85%, full repository gates, commit/push, open an unmerged `closes #830` PR, and hand off for terra CODE + mandatory Opus SECREV.
Working estimate: **35K tokens**. No explicit hard cap was supplied; reduce refactor breadth before touching locked broker authority/state-machine semantics.
## RED evidence
- New T12b/T30 test already reports the inherited primitive honestly: within-TTL **ALLOWED**, after-TTL **DENIED**. The complete AC remains RED because the mandatory threat-contract document is absent.
- Focused real-socket suite is RED with 7 expected failures: missing revoker executable (both Claude observers + generation bump), missing Claude/Pi wiring, missing isolated Claudex observers, and missing D2-v5 disclosure.
- Branch-focused Python suite is RED on the wished generation initializer/resolver interfaces and missing `lease_generation.py` / `revoke-lease.py`.
- Pi lifecycle suite is RED because the wished standalone `lease-lifecycle.ts` observer/generation module does not exist.
## Locked discipline
- RED-first T12b/T30 and observer/generation tests; test commit precedes implementation.
- Reuse broker `revoke_lease`; do not fork identity, lease, or transition authority.
- Preserve revoke-first/promote-last and WI-1/WI-2 reviewed state machine.
- ≥85% attributable executable coverage with real tests.
- No author self-review, no merge, no `--no-verify`.
## Local implementation complete (push held)
Implemented on the WI-3 base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2` without changing the reviewed broker state machine:
- Added `revoke-lease.py`, which authenticates through the existing broker session/generation and invokes `revoke_lease`. A fired observer that cannot confirm broker revocation advances the private generation as a local fence before returning non-zero.
- Added `lease_generation.py`: owner/type/mode/size validation, no-follow opens, exclusive bump lock, monotonic `int64` generation, write-all + `fsync`, and fail-closed exhaustion/corruption handling.
- `launch-runtime.py` creates `generation-<broker-session>.state` mode `0600` beside the socket before `exec`; `mutator-gate.py` resolves that current file value on every tool check.
- Claude settings and isolated Claudex settings now preserve/install `PreCompact`, `SessionStart(compact)`, and resume/clear rollover hooks in addition to the global all-tools gate.
- Pi now registers tested `session_before_compact`, `session_compact`→first `context`, and `session_start(reload|new|resume|fork)` handlers. Failed pre-compact revocation cancels compaction; failed post-compact/rollover revocation latches local all-tool denial.
- Added PRD requirements, architecture/security/protocol/operations updates, sitemap entry, and the ignored-by-default documentation checklist (force-add required at commit).
### Acceptance and coverage evidence
- Focused acceptance: `19/19`; T12b/T30 prints within-TTL **ALLOWED** and after-TTL **DENIED**.
- Pi lifecycle: `8/8`, with **100% statements/branches/functions/lines** attributable coverage.
- New Python generation/revoker: `24/24`, **99% branch-aware aggregate coverage** (`lease_generation.py` 98%, `revoke-lease.py` 100%).
- Mosaic package: `1399/1399`; framework shell Python `24/24`, launch guard `12/12`, permanent launch inventory `14 gated/14 total`.
- Existing lease-broker real-socket acceptance: `37/37` within the package run.
- Full repository: `43/43` Turbo tasks green; gateway `628 passed / 12 skipped`; Mosaic `1399/1399`.
- Root typecheck: `42/42`; lint: `23/23`; format and `git diff --check` green.
- Initial direct package test without first building the package reproduced the known missing-`dist/cli.js` harness condition; the canonical root Turbo test (which schedules `@mosaicstack/mosaic#build`) and explicit package build+test are green. No test was weakened.
### Review evidence
- Codex uncommitted code review: **APPROVE**, confidence `0.88`, zero findings. Its read-only sandbox could not rerun Vitest, but the author-side focused and full suites above were green.
- Codex uncommitted security review: risk **none**, confidence `0.91`, zero findings.
- Coordinator-mandated fresh exact-head terra CODE and Opus SECREV remain pending after rebase/push clearance; these local reviews do not replace that final gate.
### Hold and residuals
- **DO NOT PUSH OR OPEN A PR YET.** Coordinator requires flake-fix #838 to land, then WI-3 must rebase onto deterministic-green `main` before push.
- Merge remains gated on #838, #827 Probe 3, and combined GO.
- Named residual retained verbatim: when both observers are entirely missed, within-TTL consequential actions remain allowed; only lease expiry denies after the bounded stale window. No within-window mutator-action bound is claimed.
## Deterministic-main rebase evidence
- Fetched and confirmed `origin/main` at `8dfcf1903e385f977121069f798f476eb671fffc` (`#838` bounded broker deadlines, empty-read fail-closure, and de-flaked acceptance client).
- Linear rebase completed. The only content conflict was `packages/mosaic/src/mutator-gate/runtime_tools_unittest.py`; resolution retained #838's `subprocess`/`threading` deadline regressions and WI-3's `stat` generation-state coverage. No authority/state-machine choice was ambiguous.
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts` auto-merged on top of #838's shared `requestBrokerReply` helper. No inline socket/`JSON.parse` client was resurrected.
- Verified WI-3 has zero diff from `origin/main` for #838-owned `daemon.py`, `broker-test-client.ts`, `lease-broker.acceptance.spec.ts`, `vitest.config.ts`, and `packages/mosaic/package.json`; bounded deadlines and the de-flaked harness are preserved byte-for-byte.
- Required verbose acceptance command: **2 files / 56 tests green**. T12b/T30 still prints within-TTL **ALLOWED** and after-TTL **DENIED**.
- Full Mosaic package after explicit build: **74 files / 1408 tests green**; deadline unit `2/2`, runtime tools `25/25`, launch guard `12/12`, inventory `14/14`.
- Full repository: **43/43 Turbo tasks green**. Root typecheck `42/42`, lint `23/23`, format and diff checks green.
- Attributable coverage remains Python **99%** branch-aware and Pi lifecycle **100%** statements/branches/functions/lines.
- Push and PR remain held pending combined GO and all WI-3 merge gates. The coordinator-owned promote-lease-lost-ACK SPEC amendment/backstop is acknowledged as a future merge prerequisite and was not retro-expanded into this core rebase/build.

View File

@@ -0,0 +1,13 @@
# #832 Receipt-challenge protocol — build scratchpad
- **Objective:** Deliver WI-5 receipt-challenge protocol ACs T25, T26, T28, and T29 only.
- **Authority:** BUILD-BRIEF, SPEC-v5, ratification, and red-team hashes verified in STEP-0.
- **Base:** `e522b22fa4492861b0fcd4a956a8795c54eb9bfe` (`origin/main`).
- **Constraints:** Byte-build only: no live broker/socket/systemd/tmux mutation. No PR, self-review, or probe fire. T27/T30 are out of scope.
- **Plan:**
1. Add red-first deterministic T26/T29 in-build tests that call shipped normative construction and broker path.
2. Add an unexecuted, isolated P5 out-of-process replay harness that drives the shipped daemon and asserts consume-before-promote for T25/T28.
3. Implement the broker-minted receipt challenge and exact receipt observation/consume/promote path.
4. Run unit, framework-shell, compile, lint, and type checks; push after the required queue guard; report to `mosaic-100`.
- **Risks:** The standalone harness must drive the real daemon without a divergent fixture. If that is impossible, stop and flag Mos.
- **Evidence:** Initial RED recorded in `/home/hermes/agent-work/reviews/832-wi5-red-receipt-challenge.log`; initial green checks passed. Remediation RED recorded in `/home/hermes/agent-work/reviews/832-wi5-remediation-red.log` before observer/payload implementation; remediation green passed. Remediation-2 RED recorded in `/home/hermes/agent-work/reviews/832-wi5-remediation2-red.log`: each rejected begin restored prior VERIFIED authority. Remediation-2 GREEN: receipt unittest (5: all `INVALID_CONSTRUCTION`, `PAYLOAD_CONSTRUCTION_REFUSED`, and `PAYLOAD_BINDING_MISMATCH` cases preserve UNVERIFIED and deny the next mutator), normative-fragments unittest (5), state-store regression (10), full mutator-gate acceptance (20, including the real begin → observer → consume → promote path), `py_compile`, Mosaic package lint/typecheck, and targeted Prettier check. The P5 harness remains unfired. Coverage tooling remains unavailable (`python3 -m coverage`: module not installed). Push pending.

View File

@@ -0,0 +1,15 @@
# #833 constrained recovery command — build scratchpad
- **Objective:** Deliver WI-6 plus Mos-ruled B1/B2 and R2 Claude-only literal-argv repair: no shell-active recovery mapping bypass, unchanged Pi gate/B2 observer, AC-1/C4 preservation, and an unfired P6 probe.
- **Authority:** STEP-0 SHA-256 verified 4/4 against the supplied BUILD-BRIEF, SPEC-v5, ratification, and red-team records.
- **Base:** exact `07553ead337a70a9241f826d27571650262b289c`; new branch `feat/833-constrained-recovery-command`; merge-base assertion passed before any commit.
- **Constraints:** No rebase/pull during build; no live install/symlink or live broker/socket/tmux/systemd/model-stream activation; no self-review, PR, merge, push, or P6 fire. `docs/TASKS.md` is orchestrator-owned and will not be modified.
- **Plan:**
1. Add red-first unit tests against the recovery broker entrypoint for fresh recovery challenge, normal-receipt replay refusal, observable partial-delivery refusal, and the explicit middle-drop negative capability; commit the RED test and preserve its command output.
2. Implement the recovery command as a thin driver over shared WI-5 broker transitions and the trusted observer seam; it never accepts caller receipt text.
3. Add the source-resident skill under `packages/mosaic/framework/skills/`, plus a tmp-only #824 bridge projection test.
4. Build an unfired, default-3-run P6 standalone real-socket driver; it is not added to package scripts and will not be run.
5. Run targeted broker/mutator/receipt suites, lint, and format check; report head to `mosaic-100` and stop.
- **Risks:** The observer can only represent an exact latest message. Tail-preserving middle-drop is intentionally not claimed receipt-detectable (T-C residual deferred to WI-7 server evidence).
- **Evidence:** Original RED test committed at `3b5513bd6efad0c06b599fc759a66cff4286db04`; expected `UNKNOWN_ACTION` is logged in `/home/hermes/agent-work/reviews/833-wi6-red.log`. B1/B2 repair RED is `f4beedc3e7ac2e142dcbdeefb0e5ee40c20d9b86` in `/home/hermes/agent-work/reviews/833-wi6-repair-red.log`. R2 adversarial RED is committed at `65e2bd71cf360b6f45c86eec0b06ae24494832d8` in `/home/hermes/agent-work/reviews/833-wi6-repair-R2-red.log` before the literal-only gate source: the private real-gate/real-daemon battery covers every argv position (executable, path, phase, each flag, each value) for command substitution, backticks, parameter/arithmetic expansion, brace/tilde, process substitution, glob, redirects, control operations, embedded newline, and quotes. P6 remains rebuilt and unfired. The ordinary package Vitest/lint/typecheck/root-format commands cannot resolve their executables in this intentionally dependency-free fresh worktree (`node_modules` absent); no install/symlink workaround was used.
- **Budget:** No explicit task token cap was supplied; scope is fixed to WI-6 and no unrelated behavior will be added.

View File

@@ -0,0 +1,85 @@
# FCM-M5-001 — Fleet configuration operator documentation
- Task: `FCM-M5-001`
- Issue: `#758`
- Branch: `docs/758-fleet-config-operator-docs`
- Exact base: `9745bc3f29c26b021a478b7ad03cfb494f6c9de3` (tree `4da210da9a71b035130d4160a4a2e691bdfde2da`)
## Objective
Deliver the accepted fleet documentation information architecture, operator workflows, operations and migration references, comprehensive contract documentation, and deterministic link/example validation without live fleet action or product mutation.
## Scope and constraints
- Documentation, examples, documentation validation, and tracking only.
- `roster.yaml` remains the sole writable desired-state authority; generated state is derived/observed.
- No M4-002 implementation or execution; no canary, migration, rollback, deployment, systemd/tmux/session, generated projection, or product mutation.
- `mos-comms` is temporary and is not permanent architecture.
- Parent issue `#758` remains open through M5.
- No credentials, sensitive values, or privileged command content.
## Plan
1. Update tracking first with exact M4-001 evidence and mark M5-001 in progress.
2. Map the M0 checklist and current implementation behavior to documentation pages.
3. Author operator, operations, migration, schema/reference, recovery, troubleshooting, and security/authority docs.
4. Add or extend deterministic documentation/link/example validation if required, red-first.
5. Run repository documentation, link, example, and relevant package checks; review and remediate.
6. Commit, queue-guard, push one branch, and open one wrapper-created PR; stop for independent review.
## Budget
- Task estimate: `24K`.
- Working cap: stay within the card estimate by parallelizing read-only discovery and limiting edits to checklist-required artifacts.
## Progress checkpoints
- [x] Loaded repository/global delivery and documentation contracts.
- [x] Verified `origin/main` is exact required base and created isolated worktree.
- [x] Tracking updated first.
- [x] Checklist mapped and docs authored.
- [x] Validation green.
- [x] Review/remediation complete.
- [x] Commit, queue guard, push, PR #789.
- [x] Rejected exact-head RoR findings repaired on a new descendant commit candidate.
- [ ] New exact-head review and CI after repair push.
## Tests and verification
- Red-first documentation validator initially failed for the absent fleet entry point and canonical
example, then passed after the IA and example were added.
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/roster-v2.spec.ts src/fleet/example-profile-dispositions.spec.ts src/fleet/fleet-documentation.spec.ts src/fleet/v1-v2-migration.spec.ts src/fleet/generated-env-boundary.spec.ts src/fleet/fleet-agent-crud.spec.ts src/fleet/fleet-reconciler.spec.ts` — 7 files, 195 tests passed after building workspace dependencies.
- `pnpm format:check` — passed.
- `pnpm lint` — 23 tasks passed.
- `pnpm typecheck` — 42 tasks passed.
- `pnpm test` — 43 tasks passed; `@mosaicstack/mosaic` contributed 61 files and 1,045 tests.
- `bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh` — passed.
- `bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh` — passed.
- `git diff --check` — passed before final review.
- Independent staged-snapshot review identified four documentation/validation blockers: reboot safety,
heartbeat observation, migration failure envelope, and example-scan coverage. All were remediated;
focused rereview approved the staged remediations with no blockers. Exact committed-head review remains
a post-PR gate.
- Post-remediation `@mosaicstack/mosaic` lint/typecheck passed; package test passed 61 files / 1,045
tests; sanitization and resident-budget gates passed again.
- Post-PR exact-head RoR on rejected head `0aee2c09819fd06e28f927384ea56fa2ef374edf`
identified five blockers: update-lifecycle overclaim, missing explicit `fleet validate` gap,
fragment-blind link validation, unsupported checklist-evidence claim, and insufficient example safety
validation. Red-first regressions failed before implementation for missing-heading, privileged-command,
and credential-format fixtures. Repairs now preserve/document implementation truth, validate heading
fragments, narrow checklist claims, and scan fenced/canonical examples for common credential formats
and privileged commands without printing fixture values.
- Repair-focused fleet contracts: 7 files, 192 tests passed after review remediation; documentation
validator contributed 11 tests. Full gates passed: format; lint 23/23; typecheck 42/42; test 43/43
tasks with `@mosaicstack/mosaic` 61 files / 1,052 tests; sanitization; resident budget; and
`git diff --check`. New exact-head review/CI remain pending until the repair commit is pushed.
## Risks/blockers
- Checklist may include behavior intentionally deferred to M4-002/M5-002; such items must be recorded as approved-existing holds rather than claimed delivered.
- Commands/examples must remain non-live and avoid privileged/sensitive content.
## Final evidence
- Pending.

View File

@@ -35,6 +35,8 @@ CONTRIBUTING.md
defaults/** defaults/**
examples/** examples/**
guides/** guides/**
# Shipped framework subtree — canonical skills are upgrade-reconciled.
skills/**
install.sh install.sh
install.ps1 install.ps1
LICENSE LICENSE
@@ -67,6 +69,9 @@ policy/**
memory/** memory/**
sources/** sources/**
credentials/** credentials/**
# Operator-authored/customized skills live separately from canonical skills/ and
# must remain structurally unprunable even as skills/** is framework-owned.
skills-local/**
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree. # Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
# Listed explicitly so the deny-wins rule carves it out of tools/**. # Listed explicitly so the deny-wins rule carves it out of tools/**.
tools/_lib/credentials.json tools/_lib/credentials.json

View File

@@ -1,13 +1,44 @@
{ {
"model": "opus", "model": "opus",
"hooks": { "hooks": {
"PreCompact": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
}
]
},
{
"matcher": "resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
}
]
}
],
"PreToolUse": [ "PreToolUse": [
{ {
"matcher": ".*", "matcher": ".*",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
"timeout": 3 "timeout": 3
} }
] ]
@@ -48,6 +79,11 @@
"Stop": [ "Stop": [
{ {
"hooks": [ "hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
"timeout": 3
},
{ {
"type": "command", "type": "command",
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",

View File

@@ -0,0 +1,93 @@
export type LeaseLifecycleRunner = (args: string[]) => boolean;
type LifecycleEvent = {
reason?: unknown;
toolName?: unknown;
};
type LifecycleHandler = (
event: LifecycleEvent,
context: Record<string, unknown>,
) => unknown | Promise<unknown>;
export interface LeaseLifecyclePiApi {
on(event: string, handler: LifecycleHandler): void;
}
const ROLLOVER_REASONS = new Set(['reload', 'new', 'resume', 'fork']);
function eventReason(event: LifecycleEvent): string {
return typeof event.reason === 'string' && event.reason.length > 0 ? event.reason : 'unknown';
}
/**
* Register redundant Pi compaction observers and same-PID generation rollover.
*
* A failed pre-compaction observer cancels compaction. A failed post-compaction
* observer or generation rollover locally blocks later tools in addition to the
* broker-backed all-tools gate.
*/
export function registerLeaseLifecycleHooks(
pi: LeaseLifecyclePiApi,
runRevoker: LeaseLifecycleRunner,
): void {
let postCompactReason: string | null = null;
let postCompactFailure = false;
let rolloverFailure = false;
pi.on('session_before_compact', async (event) => {
const reason = eventReason(event);
const revoked = runRevoker([
'--runtime',
'pi',
'--reason',
`pi-session-before-compact:${reason}`,
]);
if (!revoked) return { cancel: true };
return undefined;
});
pi.on('session_compact', async (event) => {
postCompactReason = eventReason(event);
});
pi.on('context', async () => {
if (postCompactReason === null) return undefined;
const reason = postCompactReason;
const revoked = runRevoker([
'--runtime',
'pi',
'--reason',
`pi-context-after-compact:${reason}`,
]);
if (revoked) {
postCompactReason = null;
postCompactFailure = false;
} else {
postCompactFailure = true;
}
return undefined;
});
pi.on('session_start', async (event) => {
const reason = eventReason(event);
if (!ROLLOVER_REASONS.has(reason)) return undefined;
const revoked = runRevoker([
'--runtime',
'pi',
'--reason',
`pi-session-start:${reason}`,
'--bump-generation',
]);
rolloverFailure = !revoked;
return undefined;
});
pi.on('tool_call', async () => {
if (!postCompactFailure && !rolloverFailure) return undefined;
return {
block: true,
reason: 'BLOCKED: Mosaic lease lifecycle revoke failed; runtime remains UNVERIFIED.',
};
});
}

View File

@@ -22,6 +22,7 @@ import {
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { execSync, spawnSync } from 'node:child_process'; import { execSync, spawnSync } from 'node:child_process';
import { registerLeaseLifecycleHooks, type LeaseLifecyclePiApi } from './lease-lifecycle.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Config // Config
@@ -29,6 +30,15 @@ import { execSync, spawnSync } from 'node:child_process';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py'); const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py');
const RECOVERY_COMMAND = join(MOSAIC_HOME, 'tools', 'lease-broker', 'recover-context.py');
const RECEIPT_OBSERVER_CLIENT = join(
MOSAIC_HOME,
'tools',
'lease-broker',
'receipt-observer-client.py',
);
const RECOVERY_TOOL = 'mosaic_context_recover';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -107,6 +117,15 @@ function nowIso(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
} }
function runPiLeaseRevoker(args: string[]): boolean {
const result = spawnSync('python3', [LEASE_REVOKER, ...args], {
encoding: 'utf8',
timeout: 2_000,
env: process.env,
});
return result.status === 0;
}
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
input: `${JSON.stringify({ tool_name: toolName })}\n`, input: `${JSON.stringify({ tool_name: toolName })}\n`,
@@ -124,6 +143,78 @@ function checkPiMutatorGate(toolName: string): { block: true; reason: string } |
}; };
} }
function checkPiRecoveryGate(): { block: true; reason: string } | undefined {
return checkPiMutatorGate(RECOVERY_TOOL);
}
function assistantMessageText(message: unknown): string | undefined {
if (typeof message !== 'object' || message === null) return undefined;
const value = message as { role?: unknown; content?: unknown };
if (value.role !== 'assistant') return undefined;
if (typeof value.content === 'string') return value.content;
if (!Array.isArray(value.content)) return undefined;
const text: string[] = [];
for (const part of value.content) {
if (typeof part !== 'object' || part === null) return undefined;
const typed = part as { type?: unknown; text?: unknown };
if (typed.type !== 'text' || typeof typed.text !== 'string') return undefined;
text.push(typed.text);
}
return text.join('');
}
function recordPiMessageEnd(message: unknown): void {
const latestAssistantMessage = assistantMessageText(message);
if (latestAssistantMessage === undefined) return;
// This sends finalized Pi message_end content only to the daemon-owned
// authenticated observer transport, never to the public broker request API.
spawnSync('python3', [RECEIPT_OBSERVER_CLIENT, '--runtime', 'pi'], {
input: `${JSON.stringify({ latest_assistant_message: latestAssistantMessage })}\n`,
encoding: 'utf8',
timeout: 2_000,
env: process.env,
});
}
function runPiRecoveryCommand(params: {
phase: 'begin' | 'complete';
construction?: string;
compactionEpoch?: number;
requestEpoch?: number;
}): { content: Array<{ type: 'text'; text: string }> } {
const args = [RECOVERY_COMMAND, params.phase];
if (params.phase === 'begin') {
if (
typeof params.construction !== 'string' ||
!Number.isInteger(params.compactionEpoch) ||
!Number.isInteger(params.requestEpoch) ||
params.compactionEpoch < 0 ||
params.requestEpoch < 0
) {
return {
content: [
{ type: 'text', text: 'Recovery begin requires construction and non-negative epochs.' },
],
};
}
args.push(
'--construction',
params.construction,
'--compaction-epoch',
String(params.compactionEpoch),
'--request-epoch',
String(params.requestEpoch),
);
}
const result = spawnSync('python3', args, {
encoding: 'utf8',
timeout: 3_000,
env: process.env,
});
const output = result.status === 0 ? String(result.stdout ?? '') : String(result.stderr ?? '');
return { content: [{ type: 'text', text: output || 'Constrained recovery refused.' }] };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mission detection // Mission detection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -268,11 +359,40 @@ export default function register(pi: ExtensionAPI) {
let hbModel: string | null = null; let hbModel: string | null = null;
let hbTimer: ReturnType<typeof setInterval> | null = null; let hbTimer: ReturnType<typeof setInterval> | null = null;
// ── Compaction observers and same-PID generation rollover ─────────────
registerLeaseLifecycleHooks(pi as unknown as LeaseLifecyclePiApi, runPiLeaseRevoker);
// ── Whole mutator-class authorization gate ──────────────────────────── // ── Whole mutator-class authorization gate ────────────────────────────
// Every Pi tool, including unknown/custom tools, reaches the broker-backed // Every Pi tool, including unknown/custom tools, reaches the broker-backed
// class gate before execution. Broker/script failure blocks fail-closed. // class gate before execution. Broker/script failure blocks fail-closed.
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName)); pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
// Pi records only a finalized assistant entry at message_end. It never uses
// after_provider_response, which occurs before stream consumption.
pi.on('message_end', async (event) => {
recordPiMessageEnd((event as unknown as { message?: unknown }).message);
});
// The recovery custom tool is the only Pi invocation that maps to the
// broker's exempt RECOVERY_TOOL identity. It is not a Bash exception.
pi.registerTool({
name: RECOVERY_TOOL,
label: 'Mosaic Context Recovery',
description:
'Run the constrained broker-backed context recovery flow. This is the sole ungated mutator.',
parameters: Type.Object({
phase: Type.Union([Type.Literal('begin'), Type.Literal('complete')]),
construction: Type.Optional(Type.String()),
compactionEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
requestEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
}),
async execute(_toolCallId, params) {
const blocked = checkPiRecoveryGate();
if (blocked !== undefined) return { content: [{ type: 'text', text: blocked.reason }] };
return runPiRecoveryCommand(params);
},
});
// ── Session Start ───────────────────────────────────────────────────── // ── Session Start ─────────────────────────────────────────────────────
pi.on('session_start', async (_event, ctx) => { pi.on('session_start', async (_event, ctx) => {
sessionCwd = process.cwd(); sessionCwd = process.cwd();

View File

@@ -0,0 +1,65 @@
---
name: mosaic-context-refresh
description: Run the constrained Mosaic context-recovery flow after compaction or directive-loss. This is a thin wrapper over the broker-backed recovery command; it never treats a receipt as a safety or residency proof.
---
# mosaic-context-refresh
Use this only after compaction, session resume, or confirmed directive drift. It invokes the
**single ungated mutator**, `tools/lease-broker/recover-context.py`; every other consequential
mutator remains behind the verified lease gate.
## Wrapper procedure
1. The runtime supplies the exact validated normative-fragment construction and the current
compaction/request epochs.
- **Claude:** invoke only this direct command shape (no shell composition):
```bash
python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py begin --construction /absolute/path/to/mosaic-context-refresh-construction.json --compaction-epoch 0 --request-epoch 0
```
This is a literal argv template: replace the recover-context.py path and construction JSON path
with the literal absolute paths for your install, then replace each epoch with literal decimal
digits. Do not use variables, quoting, globs, redirects,
shell operators, substitutions, or line continuations. Claude's all-tools gate maps only this
fully literal recovery shape to `mosaic_context_recover`; ordinary `Bash` remains gated.
- **Pi:** call the registered `mosaic_context_recover` tool with `phase: "begin"`,
`construction`, `compactionEpoch`, and `requestEpoch`. It is the exact broker-exempt tool name;
Pi `bash` and every other tool remain gated.
Both forms delegate to the shipped WI-5 broker transition: revoke first, build the canonical
`B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. They print
the terminal receipt envelope to deliver exactly as returned.
2. The current assistant message copies that one terminal receipt verbatim. It does not compute a
hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge.
3. The production trusted-observer transport records that finalized assistant entry before completion:
- **Claude** selects the latest assistant entry at its `Stop` hook.
- **Pi** records only finalized assistant content at `message_end` (never
`after_provider_response`).
Then invoke completion with the same adapter form: Claude runs
`python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py complete`; Pi calls
`mosaic_context_recover` with `phase: "complete"`. Completion supplies no receipt or challenge
argument. The broker observes the exact latest assistant entry, commits evidence, consumes its own
fresh challenge, and promotes VERIFIED last. If observation is absent, malformed, stale, or
duplicated, recovery remains UNVERIFIED and a retry begins a new cycle.
## Scope and honesty
- A receipt from the normal verification path cannot be replayed through recovery: recovery mints a
distinct current challenge and does not accept caller-provided receipt text as evidence.
- Observable absent, malformed, prefix-truncated, and adapter-mutated terminal receipts do not
promote. “Tail-only” is non-promoting only when the delivered terminal bytes are concretely
malformed or incomplete.
- **Negative capability:** a tail-preserving middle drop is not represented as receipt-detectable.
It is a T-C injection-contract residual deferred to WI-7 server-side evidence; do not claim this
skill or receipt catches it.
- The receipt is a T-A delivery/liveness prerequisite only. It never proves obedience, comprehension,
durable residency, or safety; the whole mutator-class gate and server-side branch protection retain
those roles.
This source-resident skill is projected by the Mosaic skill bridge after framework install/upgrade.
Do not create a live symlink manually.

View File

@@ -0,0 +1,35 @@
# Git provider wrappers
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The `comment` action of `pr-review.sh` additionally requires the returned resource be a **pull request** (a populated `pull_request_url`); a bare `issue_url` is rejected, so if issue `#N` exists but PR `#N` does not, an issue comment cannot be reported as a verified PR comment. (`issue-comment.sh` legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** — an exact, presence- and type-checked equality (a missing/`null` persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
**A review's pinned head is re-checked after verification (current-head TOCTOU).** The `commit_id` is pinned to the PR head read _before_ the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (`GET …/pulls/{n}`) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
## Credential handling
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
## `tea` invocation notes (Gitea)
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,26 @@
#!/bin/bash #!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea # issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment> # Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
# no-ops and still exits 0, so a caller trusting the exit code believes a
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
# emit the id of a record it created, so an exit code is the ONLY signal it
# offers — and that signal is untrustworthy. This script therefore does not
# write via tea at all: it POSTs the comment through the Gitea REST API (which
# returns the created comment object, including its id), then GETs that exact
# id back and fails closed unless it matches. Keying verification to the
# provider-returned created id means a concurrent comment cannot masquerade as
# this write and a no-op create simply yields no id to verify.
#
# --login override: the default login is resolved from the local `tea` login
# list for this repo's host (get_gitea_login). Pass --login <name> to override
# it for this invocation only. The REST write, the /user identity read, and the
# read-back are ALL performed with the token of the EFFECTIVE login (the
# override when given), so the write and its verification bind to the same
# identity — a --login override is never written under one credential and
# verified under a different default one.
set -e set -e
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments # Parse arguments
ISSUE_NUMBER="" ISSUE_NUMBER=""
COMMENT="" COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2" COMMENT="$2"
shift 2 shift 2
;; ;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help) -h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>" echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -i, --issue Issue number (required)" echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)" echo " -c, --comment Comment text (required)"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help" echo " -h, --help Show this help"
exit 0 exit 0
;; ;;
@@ -49,20 +75,273 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default) so that the single credential used for
# the write ALSO drives the /user identity read and the read-back — write token
# and read-back token are the same identity by construction (this is the
# credential-ordering fix: a --login override is no longer written under one
# credential and verified under a different default one). Falls back to the
# host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the write to the host default
# identity — otherwise a caller relying on a dedicated per-role credential would
# be told the write succeeded as requested while it was attributed to the shared
# default. Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/issue suffix.
GITEA_WEB_BASE="${configured_url%/}"
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back record to THIS invocation's writer so
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
return 1
fi
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# Post a comment to a Gitea issue via the supported REST API and verify it
# durably against a PROVIDER-RETURNED created id — never trust an exit code
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
# 0). The write is a direct POST that returns the created comment object, so we
# learn the exact id of THIS write; we then GET that exact id and require
# id == created id AND author == acting identity AND exact body AND that it
# belongs to this issue. Because verification is keyed to the id the create
# returned, a concurrent comment (even same identity, same body) CANNOT
# masquerade as this write, and a suppressed/no-op write yields no created id
# and fails closed — there is no fallback list scan that a concurrent record
# could satisfy. Prints the created comment id on success.
#
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local issue_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$issue_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
if not isinstance(comment, dict):
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
# empty); a comment posted to a PR's conversation carries
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
if comment.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("created comment body does not match")
if not (
_belongs(comment.get("issue_url"), expected_issue_path)
or _belongs(comment.get("pull_request_url"), expected_pr_path)
):
raise ValueError("created comment does not belong to this issue on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER" echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args) # Resolve the login this comment should be attributed to: the --login
# word-splitting) so the comment body — including Markdown backticks, $(...), # override when given, otherwise the detected default for this repo's host.
# and quotes — is passed verbatim and never re-split or shell-evaluated. # A --login override always wins. Otherwise name this repo host's login only
REPO_SLUG=$(get_repo_slug) # as a best effort: the login name merely selects a per-login token, and
GITEA_LOGIN_NAME=$(get_gitea_login) || { # gitea_resolve_api_for_login falls back to the host credential
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2 # (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and
# its read-back both use this credential, so the write is verified against
# the identity that actually performed it. Passing "explicit" when --login
# was supplied forbids the host-default fallback: an unresolvable explicit
# override fails closed instead of writing under the default identity.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
exit 1 exit 1
} }
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME" echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"
exit 1 exit 1

View File

@@ -1,16 +1,33 @@
#!/bin/bash #!/bin/bash
# pr-review.sh - Review a pull request on GitHub or Gitea # pr-review.sh - Review a pull request on GitHub or Gitea
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] # Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]
#
# Gitea reviews and comments are written through the supported REST API, not
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
# untrustworthy — signal it offers. approve/request-changes POST to
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
# action POSTs to /issues/{n}/comments (returns the created comment with its
# id). Each write is then verified by GETting that exact returned id, so a
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
source "$SCRIPT_DIR/detect-platform.sh" source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments # Parse arguments
PR_NUMBER="" PR_NUMBER=""
ACTION="" ACTION=""
COMMENT="" COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@@ -26,13 +43,18 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2" COMMENT="$2"
shift 2 shift 2
;; ;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help) -h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]" echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -n, --number PR number (required)" echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)" echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)" echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -h, --help Show this help" echo " -h, --help Show this help"
exit 0 exit 0
;; ;;
@@ -55,6 +77,448 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
# write is a direct POST that returns the created comment object, so we learn
# the exact id of THIS write; we GET that exact id and require id == created id
# AND author == acting identity AND exact body AND that it belongs to this PR.
# Keying to the returned id means no concurrent comment (even same identity /
# body) can masquerade as this write, and a no-op create yields no id and fails
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
# gitea_resolve_api_for_login). Prints the created comment id on success.
#
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local pr_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$pr_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
if not isinstance(comment, dict):
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
# issue_url empty), while a plain issue comment carries
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
# This is the pr-review `comment` action, so the comment MUST land on a pull
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
# comment, and accepting that issue_url would let the wrapper falsely report a
# verified PR comment (issue-comment.sh legitimately keeps the broader
# issue-or-PR acceptance; a PR review does not).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
if comment.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("created comment body does not match")
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default), so the one credential used to submit
# the review/comment ALSO drives the /user identity read and every read-back —
# write token and read-back token are the same identity by construction. This
# is the credential-ordering fix: a --login override is no longer submitted
# under one credential and verified under a different default one. Falls back to
# the host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the review/comment to the host default
# identity. Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/PR suffix.
GITEA_WEB_BASE="${configured_url%/}"
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back review to THIS action's reviewer so a
# concurrent review from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
return 1
fi
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# GET /pulls/{n} into a caller-owned response file and print its head commit
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
# response file, so it is safe to call from INSIDE another trapped function
# (the post-verify re-read below) without clobbering that function's cleanup
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
gitea_read_pr_head_into() {
local pr_number="$1" pr_file="$2" auth_config="$3" status
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/pulls/$pr_number"); then
echo "Error: Gitea PR head read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea PR head read failed with HTTP $status" >&2
return 1
fi
python3 - "$pr_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
pr = json.load(response)
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
if not isinstance(head_sha, str) or not head_sha:
raise ValueError("missing PR head sha")
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
raise SystemExit(1)
print(head_sha)
PY
}
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
# submitted against — and later verified as pinned to — this exact commit, so a
# stale review left over from an earlier push cannot be mistaken for this one.
# Prints the head SHA on success.
gitea_pr_head_sha() {
local pr_number="$1" pr_file auth_config
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$pr_file"
echo "Error: could not stage Gitea credential for PR head read" >&2
return 1
}
trap 'rm -f "$pr_file" "$auth_config"' RETURN
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
}
# Submit a review to a Gitea PR via the supported REST API and verify it against
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
# the id of the review it created and can silently no-op while exiting 0 (#865
# defect class), so this does NOT shell out to tea: it POSTs to
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
# commit_id, and the review body, which returns the created review object
# including its id. It then GETs that exact review id and requires
# id == created id AND author == acting identity AND state == expected AND
# commit_id == PR head. Keying to the returned id means no concurrent review
# (even same identity/state/head) can masquerade as this one, and a no-op
# submit yields no id and fails closed. Prints the created review id on success.
#
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
# $3 = review body (may be empty for APPROVED), $4 = acting login,
# $5 = PR head sha.
gitea_submit_review_verified() {
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
local payload write_file readback_file recheck_file auth_config
local write_status readback_status created_id live_head
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
import json
import os
print(json.dumps({
"event": os.environ["REVIEW_EVENT"],
"body": os.environ["REVIEW_BODY"],
"commit_id": os.environ["REVIEW_COMMIT"],
}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file" "$recheck_file"
echo "Error: could not stage Gitea credential for review submit" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
echo "Error: Gitea review submit transport failed" >&2
return 1
fi
# Gitea returns 200 (occasionally 201) with the created review object.
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
created_id = review.get("id") if isinstance(review, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("submit response carried no positive review id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
echo "Error: Gitea review read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
if not isinstance(review, dict):
raise ValueError("response is not a review object")
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
expected_state = os.environ["EXPECTED_STATE"]
acting_login = os.environ["ACTING_LOGIN"]
expected_head = os.environ["EXPECTED_HEAD_SHA"]
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
if review.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (review.get("user") or {}).get("login") != acting_login:
raise ValueError("created review is not authored by the acting identity")
if review.get("state") != expected_state:
raise ValueError("created review is not in the expected state")
if review.get("commit_id") != expected_head:
raise ValueError("created review is not pinned to the PR head commit")
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
# finalize/reuse a pending review id whose Content was authored elsewhere;
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
# review carrying different Content) fails closed even when id/author/state/
# head all line up. Require presence + string TYPE + exact equality rather
# than `(body or "")`: the old coalesce treated a missing/null persisted body
# as equal to an empty submitted one, so a non-empty submitted body that
# persisted as null (a suppressed/lost body) would have passed. When a
# non-empty body was submitted the persisted value MUST be that exact string;
# when an empty body was submitted the persisted value must be empty or
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
persisted_body = review.get("body")
if expected_body == "":
if persisted_body not in (None, ""):
raise ValueError("created review carries a body but none was submitted")
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
raise ValueError("created review body does not match the submitted body")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
# Current-head TOCTOU close-out: the review verified above is pinned to
# head_sha, but that head was read BEFORE the submit. Between then and now
# the PR branch may have advanced (a force-push or a new commit), which would
# leave this verified review attached to a now-superseded commit while the
# live tip carries unreviewed code — yet the wrapper would still report
# success. Re-read the LIVE PR head and require it STILL equals the submitted
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
# success line). This reuses the submit-scoped auth config + recheck file so
# it neither leaks the token to argv nor clobbers this function's cleanup.
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
echo "Error: could not re-read Gitea PR head after review verification" >&2
return 1
}
if [[ "$live_head" != "$head_sha" ]]; then
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
return 1
fi
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
case $ACTION in case $ACTION in
approve) approve)
@@ -85,24 +549,77 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in case $ACTION in
approve) approve)
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"} host=$(get_remote_host)
echo "Approved Gitea PR #$PR_NUMBER" # A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
# submit — the created review record carries it — so there is no
# separate detached comment to reconcile.
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
;; ;;
request-changes) request-changes)
if [[ -z "$COMMENT" ]]; then if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes" echo "Error: Comment required for request-changes"
exit 1 exit 1
fi fi
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT" host=$(get_remote_host)
echo "Requested changes on Gitea PR #$PR_NUMBER" # A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
;; ;;
comment) comment)
if [[ -z "$COMMENT" ]]; then if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required" echo "Error: Comment required"
exit 1 exit 1
fi fi
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args) host=$(get_remote_host)
echo "Added comment to Gitea PR #$PR_NUMBER" # A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;; ;;
*) *)
echo "Error: Unknown action: $ACTION" echo "Error: Unknown action: $ACTION"

View File

@@ -312,4 +312,901 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
# port mismatch while accepting an exact / default-port match. When PyYAML is
# available the same fixtures also assert the PyYAML path agrees (equivalence).
# ---------------------------------------------------------------------------
FIXTURE_XDG="$WORK_DIR/tokenfix"
NOYAML_DIR="$WORK_DIR/noyaml"
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
# Confirm the shim really does force ImportError, so the fallback is exercised.
if python3 -c 'import yaml' >/dev/null 2>&1; then
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
exit 1
fi
fi
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
token_fallback() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
# Resolve a token via the normal path (uses PyYAML when installed).
token_pyyaml() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
assert_token() {
local desc="$1" expected="$2" login="$3" host="$4" got
got=$(token_fallback "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
fi
}
# 1. Plain, well-formed entry resolves its token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
'
assert_token "plain scalar" "TOK_PLAIN" primary git.example
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
# resolves the entry's own token to None here, so the fallback must too.
write_fixture 'logins:
- name: primary
url: https://git.example
extra:
token: TOK_NESTED_ATTACKER
- name: other
url: https://git.example
token: TOK_OTHER
'
assert_token "nested sub-map token is not attributed" "" primary git.example
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
# PyYAML errors on this shape, so both fail closed.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_MISINDENT
'
assert_token "mis-indented token fails closed" "" primary git.example
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_INLINE # trailing note
'
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
# the fallback resolves no token. Both fail closed identically.
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
write_fixture 'logins:
- name: ported
url: https://git.example:8443
token: TOK_PORTED
'
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
assert_token "portless repo host rejects :8443 login" "" ported git.example
assert_token "wrong explicit port rejected" "" ported git.example:9443
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
write_fixture 'logins:
- name: defported
url: https://git.example
token: TOK_DEFPORT
'
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a
# NON-string (int / null / bool / float) must fail closed: PyYAML yields a
# non-str value that _accept rejects, so the fallback must NOT surface the
# stringified scalar as a credential. Each raw form fails closed IDENTICALLY
# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here).
assert_nonstring_token_fails_closed() {
local desc="$1" raw="$2"
write_fixture "logins:
- name: primary
url: https://git.example
token: ${raw}
"
assert_token "$desc" "" primary git.example
}
assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345"
assert_nonstring_token_fails_closed "unquoted null token fails closed" "null"
assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~"
assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes"
assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true"
assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14"
# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a
# non-string implicit form. The quotes force str typing in PyYAML, so the
# fallback must accept the literal (quote-stripped) contents as the token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: "12345"
'
assert_token "double-quoted digit token is a literal string" "12345" primary git.example
write_fixture "logins:
- name: primary
url: https://git.example
token: 'abc'
"
assert_token "single-quoted token is a literal string" "abc" primary git.example
# assert_fallback_fails_closed: the forced-fallback path MUST resolve no token
# (fail closed). Used for STRUCTURAL cases where PyYAML would resolve a DIFFERENT
# token (e.g. duplicate-key last-wins) — the fallback must never surface the
# wrong/stale token, so it fails closed instead; when PyYAML is present we also
# confirm it really does resolve a (divergent) token, proving the fallback is the
# strictly-more-conservative side and the case is a genuine fail-open guard.
assert_fallback_fails_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -z "$got" ]]; then
echo "FAIL [$desc]: expected PyYAML to resolve a divergent token" >&2
exit 1
fi
fi
}
# 10. tea's REAL on-disk shape: the `logins:` block SEQUENCE items sit at the
# SAME indentation as the key (dash at column 0), with extra scalar fields.
# The recognizer must resolve this exactly like PyYAML (regression guard so
# the stricter whole-document recognizer does not fail closed on real input).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_REAL
default: false
ssh_host: ""
- name: other
url: https://other.example
token: TOK_REAL_OTHER
preferences:
editor: false
flags: null
'
assert_token "tea dash-at-column-0 real shape resolves" "TOK_REAL" primary git.example
assert_token "tea real shape sibling resolves own token" "TOK_REAL_OTHER" other other.example
# 11. NESTED-SHADOW: a nested `logins:` (NOT at root scope) must not be mistaken
# for the real root logins. The recognizer parses whole-document structure,
# so it selects the ROOT logins token exactly as PyYAML does — never the
# nested attacker token. (A prior line scan matched the FIRST logins at ANY
# indent and returned ATTACKER.)
write_fixture 'outer:
logins:
- name: primary
url: https://git.example
token: ATTACKER_NESTED
logins:
- name: primary
url: https://git.example
token: ROOT_TOK
'
assert_token "nested logins shadow selects ROOT token" "ROOT_TOK" primary git.example
# 12. BLOCK-SCALAR-SHADOW: text inside a YAML literal/folded block ( | or > ) is
# an OPAQUE scalar to PyYAML (so `logins` is a string, not a list) and must
# not be scanned as live logins entries. Both fail closed.
write_fixture 'logins: |
- name: primary
url: https://git.example
token: ATTACKER_BLOCK
'
assert_token "block-scalar logins value fails closed" "" primary git.example
# A folded/literal block scalar anywhere is outside the recognizer's subset, so
# the fallback fails closed (conservative) even though PyYAML can still resolve
# the real root token past the opaque scalar. Fail-closed is the safe side.
write_fixture 'note: >
logins:
- name: primary
token: ATTACKER_FOLDED
logins:
- name: primary
url: https://git.example
token: ROOT_OK
'
assert_fallback_fails_closed "folded block scalar present fails closed" primary git.example
# 13. DUPLICATE-ROOT / DUPLICATE-FIELD: a duplicated `logins:` root key (PyYAML
# last-wins) or a duplicated field within a login must fail closed rather
# than take the FIRST (stale) value. PyYAML resolves the LAST; the fallback
# refuses to guess.
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_DUP
logins:
- name: primary
url: https://git.example
token: LAST_DUP
'
assert_fallback_fails_closed "duplicate root logins key fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_FIELD
token: SECOND_FIELD
'
assert_fallback_fails_closed "duplicate token field fails closed" primary git.example
# 14. MALFORMED-AFTER-VALID: a syntax error LATER in the file makes PyYAML reject
# the WHOLE document; the recognizer must too (not emit the earlier token).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: a: b: c
'
assert_token "malformed line after valid login fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: [unclosed
'
assert_token "unclosed flow after valid login fails closed" "" primary git.example
# 15. EXTRA-DOCUMENT: a multi-document file (--- separator, or ... end marker)
# makes PyYAML safe_load reject multi-document input; the recognizer fails
# closed on ANY document marker rather than emit the first doc's token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
---
logins:
- name: primary
url: https://git.example
token: SECOND_DOC
'
assert_token "second document (--- separator) fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
...
trailing: 1
'
assert_token "end marker then more content fails closed" "" primary git.example
# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed
# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin
# with an indicator a plain scalar may not start with. PyYAML then RAISES on
# the WHOLE document (constructor error / scanner error) and yields NO token,
# so the fallback must ALSO fail closed for the whole document -- even though
# the (unrelated) malformed key sits alongside an otherwise-valid logins
# block whose token is itself well-formed. A prior residual proved STRUCTURE
# and implicit TYPE but not constructor validity, so it ignored the malformed
# key and still emitted the valid login token (fail-open in the dangerous
# direction). assert_both_fail_closed asserts fallback == PyYAML == no token.
assert_both_fail_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2
exit 1
fi
fi
}
# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar,
# followed by an otherwise-valid logins block whose token is well-formed.
write_bad_key_fixture() {
write_fixture "bad: $1
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
}
# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the
# calendar field is out of range so PyYAML's datetime construction raises.
write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid
assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-13-01' # month 13 invalid
assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid
assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example
# Non-constructible INT-tagged scalars: match the int resolver, but the radix
# body is empty after underscore removal so int(base) raises.
write_bad_key_fixture '0b_'
assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x_'
assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x__'
assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example
# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%'
# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole
# document, so the fallback fails closed on the leading indicator.
write_bad_key_fixture '%broken'
assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example
write_bad_key_fixture ',bad'
assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example
# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '):
# PyYAML raises a scanner error on the whole document, so the fallback must fail
# closed rather than accept the indicator as a plain-scalar string.
write_bad_key_fixture '-'
assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example
write_bad_key_fixture '- x'
assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example
write_bad_key_fixture '? key'
assert_both_fail_closed "question-space (complex key) fails closed" primary git.example
# ...but an indicator NOT followed by whitespace is a valid plain scalar string,
# so the token still resolves (no over-broad fail-close).
write_bad_key_fixture '-x'
assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
write_bad_key_fixture ':x'
assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML
# keeps as a plain string) leaves the document valid, so BOTH still resolve the
# login token -- the fix must not fail closed on these.
write_bad_key_fixture '2023-01-15'
assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture '2023-01-15 10:00:00'
assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example
# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not
# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must
# agree (no spurious fail-close).
write_bad_key_fixture '0o_'
assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example
# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a
# string; either way it is constructible, so the token still resolves in both.
write_bad_key_fixture '4.e8'
assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example
# A valid radix int as an unrelated key must not fail closed.
write_bad_key_fixture '0x1f'
assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example
# 17. TAB / SCANNER PARITY: PyYAML raises a ScannerError on a tab used anywhere
# outside a quoted scalar -- leading, trailing, or embedded in a plain value,
# immediately after a key colon, before a key colon, or as indentation -- and
# yields NO token, accepting tabs ONLY inside single/double-quoted scalars
# (where the tab is preserved as string content). A prior fallback swallowed
# those tabs (via .strip()/.rstrip() normalization and [ \t] key separators)
# and still emitted the login token -- a fail-open in the dangerous direction.
# The recognizer now fails CLOSED for the whole document on any tab PyYAML
# rejects, while preserving the tabs PyYAML keeps (inside quotes). All tab
# positions were verified empirically against PyYAML 6.0.3 (ScannerError for
# each rejected position; string-preserved for quoted inner tabs).
TAB=$'\t'
# Fail-close: a tab in a plain value position (trailing / leading / embedded).
write_bad_key_fixture "l4o${TAB}"
assert_both_fail_closed "trailing tab in plain value fails closed" primary git.example
write_bad_key_fixture "${TAB}9"
assert_both_fail_closed "leading tab in plain value fails closed" primary git.example
write_bad_key_fixture "a${TAB}b"
assert_both_fail_closed "embedded tab in plain value fails closed" primary git.example
# Fail-close: a tab immediately after the key colon (no separating space).
write_fixture "bad:${TAB}9
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab immediately after key colon fails closed" primary git.example
# Fail-close: a tab used as indentation (before a sequence dash).
write_fixture "logins:
${TAB}- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab used as indentation fails closed" primary git.example
# Fail-close: a tab trailing a sequence-mapping field value.
write_fixture "logins:
- name: primary
url: https://git.example
token: TOK_PLAIN${TAB}
"
assert_both_fail_closed "tab trailing a seq field value fails closed" primary git.example
# NOT over-broad: a tab strictly INSIDE a quoted scalar is valid YAML (PyYAML
# keeps it as string content), so the document parses and the login token still
# resolves in BOTH paths -- double-quoted and single-quoted.
write_bad_key_fixture "\"a${TAB}b\""
assert_token "tab inside a double-quoted value still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture "'a${TAB}b'"
assert_token "tab inside a single-quoted value still resolves token" "TOK_PLAIN" primary git.example
# ...and a quoted token value carrying an inner tab resolves to the exact string
# (tab preserved), identical to PyYAML's construction.
write_fixture "logins:
- name: primary
url: https://git.example
token: \"T${TAB}OK\"
"
assert_token "quoted token with inner tab resolves verbatim" "T${TAB}OK" primary git.example
# 18. CONTROL-CHARACTER FAIL-CLOSE (#865 round-9 blocker 1): PyYAML's Reader
# scans the ENTIRE raw document stream (not merely scalar contents, and NOT
# scoped by quoting) for bytes outside its printable set and raises
# ReaderError -- a WHOLE-DOCUMENT reject -- the instant one is found,
# regardless of where it sits: an unrelated field's plain scalar, inside a
# double- or single-quoted scalar, or a comment. Verified empirically against
# real installed PyYAML 6.0.3 (see detect-platform.sh's _FORBIDDEN_CONTROL
# comment): every C0 control byte {0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F} plus DEL
# (0x7F) rejects in ALL THREE contexts (plain / double-quoted / single-quoted);
# only TAB(0x09), LF(0x0A), CR(0x0D) are accepted among the low byte range
# (TAB has its own narrower, position-aware coverage in section 17 above; LF/CR
# are line separators). A prior fallback ONLY guarded tabs and emitted the
# login token from documents PyYAML rejects over an UNRELATED field's control
# byte -- a dangerous fail-open (credential emission from a document PyYAML
# refuses). write_control_char_fixture writes the raw byte directly via
# printf's octal escape (never through a bash string/variable, which cannot
# hold an embedded NUL) so 0x00 is exercised faithfully alongside the rest.
write_control_char_fixture() {
local octal="$1" quote="${2:-}"
{
if [[ -n "$quote" ]]; then
printf 'bad: %sx' "$quote"
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
# own \NNN octal escape so the raw control byte reaches the file
# directly, never passing through a bash string (which truncates
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
printf "\\${octal}"
printf 'y%s\n' "$quote"
else
printf 'bad: x'
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
# own \NNN octal escape so the raw control byte reaches the file
# directly, never passing through a bash string (which truncates
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
printf "\\${octal}"
printf 'y\n'
fi
printf 'logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
} > "$FIXTURE_XDG/tea/config.yml"
}
# Plain (unquoted) unrelated-field placement: the full empirically-confirmed
# forbidden C0/DEL set.
for octal in 000 001 002 003 004 005 006 007 010 013 014 \
016 017 020 021 022 023 024 025 026 027 \
030 031 032 033 034 035 036 037 177; do
write_control_char_fixture "$octal"
assert_both_fail_closed "control byte \\$octal in unrelated plain field fails closed" primary git.example
done
# Inside-quote variants (double and single) for the six bytes called out
# explicitly in the round-9 blocker report: 0x00,0x01,0x07,0x0e,0x1f,0x7f.
for octal in 000 001 007 016 037 177; do
write_control_char_fixture "$octal" '"'
assert_both_fail_closed "control byte \\$octal inside double-quoted unrelated field fails closed" primary git.example
write_control_char_fixture "$octal" "'"
assert_both_fail_closed "control byte \\$octal inside single-quoted unrelated field fails closed" primary git.example
done
# Same forbidden byte inside a comment line -- PyYAML's Reader check is
# stream-wide, so it rejects here too, not merely inside live scalar content.
{
printf '# note'
printf '\007'
printf 'here\nlogins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
} > "$FIXTURE_XDG/tea/config.yml"
assert_both_fail_closed "control byte in a comment line fails closed" primary git.example
# NOT over-broad: TAB/LF/CR remain accepted where PyYAML already accepts them
# (covered by section 17's tab fixtures and the ordinary newline-delimited
# fixtures used throughout this file), so no additional assertion is needed
# here beyond confirming the forbidden-control guard does not fire on them.
# 19. UNSIGNED-EXPONENT FLOAT OVER-REJECTION (#865 round-9 blocker 2): PyYAML
# 6.0.3's implicit float resolver requires an EXPLICIT SIGN on the exponent
# ([eE][-+][0-9]+); an unsigned exponent is NOT matched, so PyYAML resolves
# the scalar as a plain STRING, not a float. A prior fallback's float
# recognizer accepted an OPTIONAL sign ([eE][-+]?[0-9]+), over-matching these
# spellings as floats and dropping the token PyYAML would emit verbatim
# (over-rejection). Verified empirically against real PyYAML 6.0.3.
for form in '1.0e10' '+1.0e10' '-1.0e10' '1.0E10' '.5e10' '4.e8'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${form}
"
assert_token "unsigned-exponent form '$form' is a PyYAML string, token resolves" "$form" primary git.example
done
# Parity guard: a genuine SIGNED-exponent float is still typed as a non-string
# float by PyYAML and must still fail closed (not regress into over-acceptance).
write_fixture 'logins:
- name: primary
url: https://git.example
token: 1.0e+10
'
assert_token "signed-exponent genuine float still fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: 1.0e-10
'
assert_token "signed-exponent (negative) genuine float still fails closed" "" primary git.example
# 20. RESIDUAL OVER-REJECTION found via round-9 differential fuzzing (folded into
# this round, not split off): a plain scalar starting with "?" NOT followed by
# whitespace (e.g. "?x") is a valid PyYAML string -- only a bare "?" or "? "
# (question mark followed by space/EOL) opens a complex mapping key and is
# illegal in a value position. A prior blanket-reject set treated EVERY
# leading "?" as illegal, over-rejecting a token PyYAML accepts verbatim.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ?x
'
assert_token "question-mark-not-space is a plain string, token resolves" "?x" primary git.example
# 21. PRINTABLE-BOUNDARY FAIL-CLOSE (#865 round-10 blocker 1): the round-9 guard
# used a hand-rolled C0/DEL subset that MISSED code points PyYAML's Reader
# also rejects -- the C1 block (U+0080-0084, U+0086-009F) and the BMP
# noncharacters U+FFFE/U+FFFF -- so the fallback still emitted the token from
# documents PyYAML rejects whole (fail-open). The guard now uses PyYAML
# 6.0.3's EXACT Reader.NON_PRINTABLE character class (see detect-platform.sh
# _FORBIDDEN_CONTROL). Verified empirically against real PyYAML 6.0.3:
# PRINTABLE = {0x09,0x0A,0x0D, 0x20-0x7E, 0x85(NEL), 0xA0-0xD7FF,
# 0xE000-0xFFFD, 0x10000-0x10FFFF}; everything else fails the whole document
# closed. write_codepoint_fixture emits a chosen Unicode code point's real
# UTF-8 bytes (via python3, since bash strings cannot faithfully carry many
# of these) into a selectable position, then the login block follows.
write_codepoint_fixture() {
# $1 = hex code point (e.g. 0x80); $2 = position: field|comment|token|nelterm
CP_HEX="$1" CP_POS="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
import sys
cp = int(__import__("os").environ["CP_HEX"], 16)
pos = __import__("os").environ["CP_POS"]
ch = chr(cp)
head = "logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n"
if pos == "field":
doc = head + "other: x" + ch + "y\n"
elif pos == "comment":
doc = head + "# note x" + ch + "y here\n"
elif pos == "token":
doc = "logins:\n - name: primary\n url: https://git.example\n token: T" + ch + "K\n"
elif pos == "nelterm":
# NEL (U+0085) used as the line terminator throughout: PyYAML treats it as a
# line break (printable, NOT a ReaderError) and resolves the token; the
# fallback's _split_logical_lines splits on NEL identically OUTSIDE a quote
# -> parity, token resolves. (Round 23 covers NEL/LS/PS INSIDE a quote, where
# PyYAML folds rather than breaks and a naive splitlines() would over-split.)
doc = ("logins:" + ch + " - name: primary" + ch
+ " url: https://git.example" + ch + " token: TOK_NEL" + ch)
else:
raise SystemExit("bad pos")
with open(sys.argv[1], "w", encoding="utf-8") as f:
f.write(doc)
PY
}
# C1-block + BMP-noncharacter code points fail the WHOLE document closed in an
# unrelated field and in a comment, exactly as PyYAML's ReaderError does.
for cphex in 0x80 0x81 0x84 0x86 0x9f 0xfffe 0xffff; do
write_codepoint_fixture "$cphex" field
assert_both_fail_closed "code point $cphex in unrelated field fails closed" primary git.example
write_codepoint_fixture "$cphex" comment
assert_both_fail_closed "code point $cphex in a comment fails closed" primary git.example
done
# NOT over-broad: printable code points PyYAML ACCEPTS must still resolve the
# token in BOTH paths -- NEL(0x85) as a line separator, U+00A0 (NBSP) inside a
# value, and an astral code point (U+1F600) inside the token value.
write_codepoint_fixture 0x85 nelterm
assert_token "NEL (U+0085) line-terminator resolves token" "TOK_NEL" primary git.example
write_codepoint_fixture 0xa0 field
assert_token "U+00A0 in unrelated value still resolves token" "TOK_PLAIN" primary git.example
write_codepoint_fixture 0x1f600 token
assert_token "astral U+1F600 inside token resolves verbatim" "$(printf 'T\360\237\230\200K')" primary git.example
# 22. INTERNAL-INDICATOR OVER-REJECTION (#865 round-10 blocker 2): the round-9
# recognizer blanket-rejected any plain scalar CONTAINING a flow indicator
# ([]{}*&!), but in BLOCK context PyYAML treats ',[]{}' as ordinary content
# and treats '!&*#...' as significant ONLY at the FIRST non-space char. So an
# INTERNAL indicator is legal plain-string content and PyYAML emits the token
# verbatim; the fallback dropped it (over-rejection). Verified empirically
# against real PyYAML 6.0.3. The fix removes the blanket internal scan while
# the leading-char guard and the ' #'/': '/trailing-':' guards keep the
# fail-OPEN direction shut.
for tv in 'a!b' 'a,b' 'a[b' 'a]b' 'a{b' 'a}b' 'a&b' 'a*b' 'a[b]c' 'a{b}c' 'a,b,c' 'a#b' 'a:b'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "internal-indicator token '$tv' resolves verbatim" "$tv" primary git.example
done
# Fail-OPEN direction stays shut: a LEADING indicator, a ' #' comment tail, an
# internal ': ' (colon-space) inline map, and a trailing ':' each make PyYAML
# resolve NO usable string token (tag/flow/anchor reject or None, comment strip,
# or a mapping), so BOTH must fail closed. (Leading tag/anchor/flow are the
# documented, round-9-approved structural fail-closed class; kept intact here.)
write_fixture 'logins:
- name: primary
url: https://git.example
token: !x
'
assert_both_fail_closed "leading '!' tag token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: [a]
'
assert_both_fail_closed "leading '[' flow-seq token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: a # trailing comment
'
# ' #' comment tail: PyYAML strips the comment -> token is the string 'a', which
# still resolves. This is the NOT-over-broad boundary partner of the guard.
assert_token "space-hash comment tail strips to plain token" "a" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: a: b
'
assert_both_fail_closed "internal colon-space (inline map) token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: ab:
'
assert_both_fail_closed "trailing colon (map indicator) token fails closed" primary git.example
# 23. EMBEDDED LINE-BREAK INSIDE A QUOTED SCALAR (#865 round-11 blocker A): the
# round-10 fallback split the raw document with str.splitlines(), which breaks
# at NEL(U+0085), LS(U+2028) and PS(U+2029) -- code points that are PRINTABLE
# to PyYAML's Reader. Inside a flow (quoted) scalar PyYAML does NOT break at
# these: it LINE-FOLDS a double/single-quoted scalar (NEL/LF/CR -> a single
# space; LS/PS -> the char verbatim), so it resolves ONE token, while
# splitlines() cut the value mid-quote and failed the whole document closed
# (over-rejection). The fallback now uses _split_logical_lines, which
# reproduces PyYAML's flow-folding. Verified empirically vs real PyYAML 6.0.3.
# write_break_fixture emits a chosen break code point in a selectable context.
write_break_fixture() {
# $1 = hex code point of the break; $2 = context: dq|sq|plain|comment|dq2
CP_HEX="$1" Q_STYLE="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
import sys, os
cp = int(os.environ["CP_HEX"], 16)
q = os.environ["Q_STYLE"]
ch = chr(cp)
head = "logins:\n - name: primary\n url: https://git.example\n token: "
if q == "dq":
doc = head + '"tok' + ch + 'en"'
elif q == "sq":
doc = head + "'tok" + ch + "en'"
elif q == "plain":
doc = head + "tok" + ch + "en"
elif q == "dq2":
# blank line inside a quoted scalar: PyYAML folds a two-break run to a literal
# newline, which the recognizer's key regex cannot carry -> endorsed
# fail-closed over-reject (see assert_fallback_fails_closed below).
doc = head + '"tok' + ch + ch + 'en"'
elif q == "comment":
doc = ("logins:\n - name: primary\n url: https://git.example\n"
" token: TOK_PLAIN\n# c" + ch + "x")
else:
raise SystemExit("bad q")
with open(sys.argv[1], "w", encoding="utf-8") as f:
f.write(doc + "\n")
PY
}
# NEL folds to a single space inside double- AND single-quoted scalars: the token
# resolves identically in both paths (fallback no longer over-splits).
write_break_fixture 0x85 dq
assert_token "NEL inside double-quote folds to space, token resolves" "tok en" primary git.example
write_break_fixture 0x85 sq
assert_token "NEL inside single-quote folds to space, token resolves" "tok en" primary git.example
# LS(U+2028)/PS(U+2029) are preserved VERBATIM by PyYAML's flow fold (they are
# not \n-class breaks); the fallback must surface them byte-for-byte.
write_break_fixture 0x2028 dq
assert_token "LS inside double-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
write_break_fixture 0x2028 sq
assert_token "LS inside single-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
write_break_fixture 0x2029 dq
assert_token "PS inside double-quote is verbatim" "$(printf 'tok\342\200\251en')" primary git.example
# Direction-sensitivity: the SAME code points UNQUOTED (a plain scalar) or in a
# COMMENT make PyYAML raise a scanner error, so both paths must fail closed. The
# fold rule applies ONLY inside a quoted scalar.
write_break_fixture 0x85 plain
assert_token "NEL in an unquoted plain scalar fails closed" "" primary git.example
write_break_fixture 0x2028 plain
assert_token "LS in an unquoted plain scalar fails closed" "" primary git.example
write_break_fixture 0x85 comment
assert_token "NEL in a comment fails closed" "" primary git.example
# Endorsed fail-closed over-reject: a blank line inside a quoted scalar folds to a
# literal newline that the recognizer cannot carry -- PyYAML resolves a
# (newline-bearing) token, the fallback fails closed (strictly safer).
write_break_fixture 0x85 dq2
assert_fallback_fails_closed "blank-line-in-quote (NEL run) fails closed" primary git.example
# 24. LEADING NON-SPECIFIC TAG / ANCHOR PROPERTY (#865 round-11 blocker B, revised
# in round 12): the round-10 recognizer blanket-rejected any scalar beginning
# with '!' or '&'. Round 11 taught it to strip a transparent NON-SPECIFIC tag
# ('! ' bang + SPACE) and a transparent plain ANCHOR ('&name ') so '! x' /
# '&a x' resolve the STRING 'x', matching PyYAML. Round 12 discovered that the
# anchor half of that was a HIGH fail-open: PyYAML's Composer tracks anchor
# NAMES in a document-scoped registry and raises ComposerError ("found
# duplicate anchor") the instant the SAME name is declared on a SECOND node
# ANYWHERE in the document (even an unrelated one) -- the fallback's
# per-scalar-only view has no such registry and would emit the later token.
# Round 12's fix: reject EVERY '&'-anchor property, unconditionally. The
# transparent NON-SPECIFIC TAG behavior ('! x' -> 'x') is unchanged and still
# verified below; only the anchor half now fails closed (deliberate
# conservative over-reject, verified safe both ways against real PyYAML 6.0.3).
for pair in '! x=x' '! x y=x y' '! "q"=q' "! 'q'=q"; do
tv="${pair%%=*}"; want="${pair#*=}"
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "tag property token '$tv' resolves node string" "$want" primary git.example
done
# Fail-OPEN direction stays shut. '!x' (bang + NON-space) is a tag HANDLE ->
# ConstructorError; '!foo x'/'* a'/'! !x' raise; a property over a NON-string node
# ('! 123'/'! true'/'! null') types non-str -> no usable token. All fail closed in
# BOTH paths.
for tv in '!x' '!foo x' '* a' '! !x' '! 123' '! true' '! null' '&a &b x'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "non-resolving property token '$tv' fails closed" "" primary git.example
done
# Round 12: a SINGLE, non-duplicated '&a x' is valid YAML that real PyYAML
# resolves to the string 'x' (round-11 behavior, and still true of the oracle).
# The fallback now rejects it anyway -- a deliberate, endorsed CONSERVATIVE
# over-reject (see the round-12 comment block above): fail-closed can only cost
# an emitted token PyYAML would have allowed, never emit one PyYAML rejects, and
# a per-scalar recognizer cannot safely prove document-wide anchor-name
# uniqueness. assert_fallback_fails_closed also confirms PyYAML really does
# resolve a token here, proving this is a genuine (safe-direction) divergence
# and not an accidental parity loss.
write_fixture 'logins:
- name: primary
url: https://git.example
token: &a x
'
assert_fallback_fails_closed "round-12: single non-duplicated anchor '&a x' now fails closed (conservative over-reject; PyYAML resolves x)" primary git.example
# Same over-reject for the combined tag+anchor forms round 11 used to resolve.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ! &b x
'
assert_fallback_fails_closed "round-12: '! &b x' (tag+anchor) now fails closed (conservative over-reject)" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: &b ! x
'
assert_fallback_fails_closed "round-12: '&b ! x' (anchor+tag) now fails closed (conservative over-reject)" primary git.example
# Endorsed fail-closed over-reject: an EXPLICIT tag ('!!str x', verbose
# '!<tag:yaml.org,2002:str> x') forces a string PyYAML resolves, but the fallback
# recognizes only the transparent non-specific tag and fails closed (safer).
for tv in '!!str x' '!<tag:yaml.org,2002:str> x'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_fallback_fails_closed "explicit-tag token '$tv' fails closed" primary git.example
done
# 25. #865 round 12 HIGH fail-open closure: DUPLICATE ANCHOR NAME across separate
# nodes. Real PyYAML's Composer tracks anchor names in a DOCUMENT-SCOPED
# registry and raises ComposerError ("found duplicate anchor ... first
# occurrence") the instant the SAME anchor name is declared a second time
# ANYWHERE in the document -- failing the WHOLE document closed, no token,
# regardless of how far the duplicate sits from the logins block. The round-11
# fallback tracked anchors only WITHIN a single scalar's `_strip_properties`
# call, so it had no visibility into a duplicate declared on an unrelated
# node and would still emit the (later) token: fail-open. The round-12 fix
# (reject every '&'-anchor property, unconditionally -- see section 24 above)
# closes this as a strict superset: since NO anchor is ever accepted, a
# duplicate anchor can never slip through. These cases exercise that
# document-wide duplicate-anchor invariant specifically (as opposed to
# section 24's single-anchor-on-the-token-field cases) and pair each fallback
# assertion with confirmation that real PyYAML also fails closed here (via
# ComposerError), proving this was a genuine fail-open, not a hypothetical.
write_fixture 'first: &same one
second: &same two
logins:
- name: primary
url: https://git.example
token: TOK_DUP_ROOT
'
assert_both_fail_closed "round-12: duplicate anchor name on two unrelated root nodes fails closed" primary git.example
write_fixture 'outer:
nested: &dup x
dup_root: &dup y
logins:
- name: primary
url: https://git.example
token: TOK_DUP_NESTED
'
assert_both_fail_closed "round-12: duplicate anchor name across a nested node and a root node fails closed" primary git.example
write_fixture 'first: &dup one
logins:
- name: primary
url: https://git.example
token: &dup TOK_DUP_TOKEN_NODE
'
assert_both_fail_closed "round-12: anchor name declared earlier and repeated on the token-bearing node fails closed" primary git.example
# Regression guard: the non-specific TAG half of section 24 ('! x' -> 'x') is
# UNCHANGED by the round-12 anchor fix and must still resolve.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ! x
'
assert_token "round-12 regression: '! x' (tag, no anchor) still resolves 'x'" "x" primary git.example
echo "Gitea login resolution regression harness passed" echo "Gitea login resolution regression harness passed"

View File

@@ -0,0 +1,571 @@
#!/usr/bin/env bash
# Regression harness for issue-comment.sh's Gitea comment write + verification
# (#865).
#
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
# record it created — so an exit code is worthless as proof of a durable write.
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
# the Gitea REST API (which returns the created comment object, including its
# id), then GETs THAT EXACT id back and requires it to match on id, author
# (acting identity), body, and issue. Because verification is keyed to the id
# the create returned, no concurrent comment can masquerade as this write, and a
# suppressed/no-op create yields no id and fails closed.
#
# This harness models a REAL server: the curl stub keeps persistent comment
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
# id, and the read-back GET reads that same state. There is no independently
# fabricated record for the wrapper to "find" — the only way verification
# passes is if the POST genuinely created the record the read-back retrieves.
# It proves the wrapper:
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
# 2. creates the comment via REST POST and learns the provider-returned id;
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
# 4. fails closed when the write is a no-op even though a concurrent
# SAME-IDENTITY comment with the same body already exists (the closed
# concurrency window — no fallback list scan can rescue a no-op);
# 5. fails closed when the created record is not authored by the acting
# identity;
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
# list enumeration (the stub exposes no comment-list endpoint, so any
# residual enumeration attempt would fail the run);
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
# lookup, and the read-back ALL under THAT login's token/identity — never
# the host default;
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
# success line) instead of silently downgrading to the host default
# identity — the token seam maps each bearer token to the identity it
# authenticates as, so a misattributed write is caught;
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
# that other host's credential cross-host;
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
# Full curl argv per invocation — proves the bearer token never rides in argv.
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
STATE_FILE="$WORK_DIR/comments.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
BODY='durable "note" -- marker'
ACTING_LOGIN="primary-reviewer"
FOREIGN_LOGIN="other-writer"
# A dedicated per-role --login override identity, with its own token stored in
# tea's config (exactly the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="delegated-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
# present, so the no-override default path resolves via the host credential
# fallback while an explicit --login must resolve from this file or fail closed.
# A second login is configured for a DIFFERENT host to exercise host-bound
# rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
handle.write(" url: https://git.uscllc.com\n")
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk comment state.
# GET /user -> acting identity
# POST /issues/7/comments -> CREATE + PERSIST, return created object
# GET /issues/comments/{id} -> read the persisted record by exact id
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
# authority, so any residual list enumeration attempt hits the unexpected-request
# guard and fails the test.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
# must NOT appear here — it is delivered via a curl --config file (#865 ITEM 3a),
# so only the config file PATH may show up.
printf '%s\n' "$*" >> "$ISSUE_COMMENT_CURL_ARGV_LOG"
output_file=""
method="GET"
url=""
data=""
auth_token=""
config_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-K|--config) config_file="$2"; shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) data="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
# Resolve the bearer token from the curl --config file (its real, secure source);
# fall back to an -H header only for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
auth_token="${auth_token%\"}"
fi
fi
path="${url%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as — the same
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
# read-back must all carry the SAME token, so the acting identity recorded here
# reveals which credential actually performed the request.
acting_identity=""
case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
# created object) even though a concurrent same-identity comment already exists
# in state. Nothing is persisted; there is no created id to verify.
if mode == "no-op-concurrent":
print("200")
print(json.dumps({}))
raise SystemExit(0)
author = foreign if mode == "author-mismatch" else acting
new_id = (max((c["id"] for c in comments), default=0)) + 1
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
# and a plain issue comment leaves pull_request_url empty. The URL-injection
# modes persist a record whose id/author/body are all correct but whose
# issue_url is forged, so ONLY the origin+path verification can catch them.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
if mode == "url-wrong-host":
issue_url = f"https://evil.example/{repo}/issues/7"
elif mode == "url-wrong-owner":
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
elif mode == "url-wrong-repo":
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
elif mode == "url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
record = {
"id": new_id,
"body": body,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
comments.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(comments, handle)
print("201")
print(json.dumps(record))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode, then run the wrapper against it.
seed_state() {
local mode="$1"
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
def comment(cid, text, author):
return {
"id": cid,
"body": text,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
if mode == "fresh-success":
# 50 pre-existing comments already exist; the comment this run creates
# becomes id 51, proving exact-id read-back works regardless of how many
# comments precede it (no list enumeration is involved).
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent":
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
# The wrapper's own write will be a no-op; it must still fail closed because
# no created id is returned — it must not scan and accept this record.
comments = [comment(55, body, acting)]
else: # author-mismatch
comments = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(comments, handle)
PY
}
run_comment() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
assert_no_temp_leak() {
local context="$1" leaked
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
# bearer token and must be unlinked on every exit path.
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-issue-comment-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: issue-comment temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
# via a curl --config file), and that --config auth was actually used. On the
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
exit 1
fi
}
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
# provider-returned id — no list enumeration is involved.
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
# The write is a REST POST, never a tea comment.
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
exit 1
fi
# Read-back is a DIRECT GET of the exact created id.
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
# Acting identity resolved via GET /user.
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
# No comment-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
exit 1
fi
# Default path (no --login): the host credential fallback resolves, and the
# write is performed AND self-verified under the host-default acting identity.
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
# Success path leaves no scratch temp files behind.
assert_no_temp_leak "fresh-success"
# ITEM 3a: the token drove the write/read-back chain but never appeared in curl
# argv — it was passed via a curl --config file.
assert_token_not_in_argv "fresh-success default-token"
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
# already present must FAIL CLOSED — the closed concurrency window.
if run_comment no-op-concurrent; then
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
exit 1
fi
# It must NOT have fallen back to a list scan that could find the concurrent id.
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
exit 1
fi
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
if run_comment author-mismatch; then
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind
# (proves the RETURN traps clean up on the error-return route, not just success).
assert_no_temp_leak "author-mismatch"
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
# and the read-back must ALL be performed under THAT login's token/identity, not
# the host default. The override login has id 1 (empty seed).
run_comment override-success --login "$OVERRIDE_LOGIN"
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
# The host-default identity must NOT have performed ANY request in this run.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
# no success line, and NO write performed.
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
echo "FAIL: unresolvable --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: unresolvable --login override still performed a write" >&2
exit 1
fi
# And it must not have silently fallen back to the host default identity.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
exit 1
fi
# Case 6: a --login override that IS present in tea config but whose URL is a
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
echo "FAIL: cross-host --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: cross-host --login override reported success" >&2
exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: cross-host --login override still performed a write" >&2
exit 1
fi
# It must not have silently downgraded to the host default identity either.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
# correct, but its provider-returned issue_url is forged. Verification pins the
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
# endswith/suffix test would wrongly accept the look-alike-host and
# prefix-injection variants.
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
if run_comment "$bad_mode"; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
exit 1
fi
assert_no_temp_leak "$bad_mode"
done
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
echo "issue-comment.sh REST create + exact-id read-back regression passed"

View File

@@ -0,0 +1,923 @@
#!/usr/bin/env bash
# Regression harness for pr-review.sh's Gitea review + comment writes (#865,
# #812, #835).
#
# The #865 defect class: tea 0.11.1 can silently no-op while exiting 0 and
# cannot emit the id of a record it creates, so its exit code is worthless as
# proof of a durable write. The wrapper therefore does NOT write reviews or
# comments via tea. approve/request-changes POST to /pulls/{n}/reviews (with the
# event, the PR head commit_id, and the review body) and read the created review
# back by its EXACT provider-returned id; the `comment` action POSTs to
# /issues/{n}/comments and reads that created comment back by its exact id.
# Because verification keys on the id the create returned, no concurrent record
# can masquerade as this write and a no-op create fails closed. tea is only ever
# consulted for the login list.
#
# The curl stub models a REAL server with persistent review/comment state on
# disk: a POST actually CREATES and PERSISTS a record and returns its id, and
# the read-back reads that same state. There is no independently fabricated
# record for the wrapper to "find" — verification passes only when the POST
# genuinely created the record the read-back retrieves.
#
# #865 Round-4: the curl stub also maps the presented bearer token to the
# identity it authenticates as and logs it per request, so tests can prove
# credential attribution. An explicit --login override must drive the entire
# write→read-back chain under THAT login's token (resolvable case) or FAIL
# CLOSED (unresolvable case) — never silently downgrade to the host-default
# identity. The host-default best-effort fallback is reserved for the
# no-override default path.
#
# #865 Round-5: the exact-id read-back is the SOLE authority — the wrapper does
# NO follow-up list enumeration (the stub exposes no review/comment list
# endpoint, so a residual enumeration would fail the run). A --login override is
# host-bound: an override configured for a DIFFERENT host than the repo remote
# FAILS CLOSED rather than leaking a cross-host credential. And every run leaves
# no scratch temp files behind on any exit path (POST/GET bodies + metadata).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
STATE_DIR="$WORK_DIR/state"
REVIEWS_FILE="$STATE_DIR/reviews.json"
COMMENTS_FILE="$STATE_DIR/comments.json"
SUBMIT_PAYLOAD_FILE="$STATE_DIR/review_payload.json"
# Counts GET /pulls/{n} calls within a single run so a race mode can advance the
# reported head between the pre-submit read and the post-verify re-read.
HEAD_CALLS_FILE="$STATE_DIR/head_calls"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
# Full curl argv per invocation — proves the bearer token never rides in argv.
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
ACTING_LOGIN="review-bot"
FOREIGN_LOGIN="other-writer"
HEAD_SHA="HEADSHA_FEEDFACE"
# A dedicated per-role --login override identity with its own token in tea's
# config (the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="primary-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Host-bound selection must reject it
# rather than send its token cross-host.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# tea config: the override login carries its own token here. The default login
# name ("mosaicstack") is deliberately absent, so the no-override default path
# resolves via the host credential fallback while an explicit --login must
# resolve from this file or fail closed. A second login is configured for a
# DIFFERENT host to exercise host-bound rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
handle.write(" url: https://git.uscllc.com\n")
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY
write_credentials() {
local configured_url="$1"
CONFIGURED_GITEA_URL="$configured_url" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
}
# tea stub: only ever answers the login list. The wrapper must never write a
# review or comment through tea (#865 defect class); any other tea invocation is
# an error.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL"
exit 0
fi
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk review/comment
# state.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, BEFORE any consumption. The bearer
# token must NOT appear here — it is delivered via a curl --config file, so only
# the config file PATH may show up. (#865 ITEM 3a credential-in-argv exposure.)
printf '%s\n' "$*" >> "$PR_REVIEW_CURL_ARGV_LOG"
output_file=""
method="GET"
payload=""
url=""
auth_token=""
config_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-K|--config) config_file="$2"; shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) payload="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
# Resolve the bearer token from the curl --config file (its real, secure source);
# only fall back to an -H header for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
auth_token="${auth_token%\"}"
fi
fi
path="${url%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as (as Gitea's
# /user does). The write, /user lookup, and read-back must all carry the SAME
# token, so the identity logged here reveals which credential performed each
# request — proving an explicit --login override is honored, not downgraded.
acting_identity=""
case "$auth_token" in
"$PR_REVIEW_DEFAULT_TOKEN") acting_identity="$PR_REVIEW_ACTING_LOGIN" ;;
"$PR_REVIEW_OVERRIDE_TOKEN") acting_identity="$PR_REVIEW_OVERRIDE_LOGIN" ;;
"$PR_REVIEW_CROSS_HOST_TOKEN") acting_identity="$PR_REVIEW_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$PR_REVIEW_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
emit() {
# Split a two-line "status\n<json body>" python result into the response.
local result="$1"
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
}
mode="${PR_REVIEW_TEST_MODE:-}"
if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(PR_REVIEW_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then
write_response 200 "$(PR_REVIEW_HEAD_SHA="$PR_REVIEW_HEAD_SHA" python3 - <<'PY'
import json
import os
head = os.environ["PR_REVIEW_HEAD_SHA"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
calls_path = os.environ.get("PR_REVIEW_HEAD_CALLS", "")
# Count GET /pulls/{n} calls within this run: call 1 is the pre-submit head read
# that pins the review; call 2+ is the post-verify re-read (current-head TOCTOU
# close-out). In the race mode the branch "advances" after the pin.
n = 1
if calls_path:
try:
with open(calls_path, encoding="utf-8") as handle:
n = int(handle.read() or "0") + 1
except (OSError, ValueError):
n = 1
with open(calls_path, "w", encoding="utf-8") as handle:
handle.write(str(n))
if mode == "head-advanced-race" and n >= 2:
head = "HEADSHA_ADVANCED_DEADBEEF"
print(json.dumps({"head": {"sha": head}}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
emit "$(PR_REVIEW_ACTING_LOGIN="${acting_identity:-$PR_REVIEW_ACTING_LOGIN}" PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"]
submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
# created object) even though a concurrent same-identity, same-state review at
# the same head already exists. Nothing is persisted; no created id to verify.
if mode == "no-op-concurrent-review":
print("200")
print(json.dumps({}))
raise SystemExit(0)
# review-body-reuse (#865 Blocker 4): Gitea v1.25.4's SubmitReview can finalize
# and REUSE a pending review id whose Content was authored earlier — NOT this
# submit's body. id/author/state/head all line up with the request; only the
# persisted body diverges, so only body verification catches it. The read-back
# GET returns this same divergent-body record.
if mode == "review-body-reuse":
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": "leftover-pending-content-not-this-submit",
"user": {"login": acting},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
# review-body-null (#865 ITEM 3b): a non-empty body was submitted but the
# persisted review carries body == null. id/author/state/head all line up; only
# strict presence + string-type body verification catches the lost body. The old
# `(body or "")` coalesce would have treated null as an empty string and passed.
if mode == "review-body-null":
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": None,
"user": {"login": acting},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
author = foreign if mode == "author-mismatch-review" else acting
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": submitted.get("body"),
"user": {"login": author},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/pulls/123/reviews/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
match = next((r for r in reviews if r["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
case "$mode" in
write-transport-failure)
echo "simulated transport failure" >&2
exit 7
;;
write-http-failure)
write_response 500 '{"message":"simulated rejection"}'
;;
*)
emit "$(PR_REVIEW_PAYLOAD="$payload" PR_REVIEW_TEST_MODE="$mode" python3 - <<'PY'
import json
import os
from urllib.parse import urlparse
state_path = os.environ["PR_REVIEW_COMMENTS"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
web_base = os.environ["PR_REVIEW_WEB_BASE"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
# REAL Gitea shape for a comment posted to a PR's conversation
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT
# require an API-shaped issue_url.
pr_url = f"{web_base}/pulls/123"
# comment-plain-issue (#865 ITEM 2): #123 is a plain ISSUE, not a PR. POST
# /issues/123/comments lands an issue comment whose issue_url is set and
# pull_request_url is empty. The pr-review `comment` action MUST reject this — it
# claimed a PR comment, so a bare issue_url is not acceptable proof.
if mode == "comment-plain-issue":
record = {
"id": 456,
"body": body,
"user": {"login": acting},
"issue_url": f"{web_base}/issues/123",
"pull_request_url": "",
}
with open(state_path, "w", encoding="utf-8") as handle:
json.dump([record], handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
# URL-injection modes (#865 Blocker 3): id/author/body are all correct but the
# provider-returned pull_request_url is forged, so ONLY origin+full-path
# verification can catch them.
_p = urlparse(web_base)
_origin = f"{_p.scheme}://{_p.netloc}"
_slug = _p.path # /<owner>/<repo>
if mode == "comment-url-wrong-host":
pr_url = f"https://evil.example{_slug}/pulls/123"
elif mode == "comment-url-wrong-owner":
pr_url = f"{_origin}/attacker/stack/pulls/123"
elif mode == "comment-url-wrong-repo":
pr_url = f"{_origin}/mosaicstack/other/pulls/123"
elif mode == "comment-url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
record = {
"id": 456,
"body": body,
"user": {"login": acting},
"issue_url": "",
"pull_request_url": pr_url,
}
with open(state_path, "w", encoding="utf-8") as handle:
json.dump([record], handle)
print("201")
print(json.dumps(record))
PY
)"
;;
esac
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/issues/comments/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_COMMENTS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
raise SystemExit(0)
if mode == "readback-failure":
# The server returns a DIFFERENT body than was created — a genuine
# provider-side mismatch the wrapper must reject.
match = dict(match, body="different-body")
print("200")
print(json.dumps(match))
PY
)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode before the wrapper runs.
seed_state() {
local mode="$1"
printf '[]' > "$COMMENTS_FILE"
rm -f "$SUBMIT_PAYLOAD_FILE" "$HEAD_CALLS_FILE"
PR_REVIEW_SEED_MODE="$mode" PR_REVIEW_SEED_ACTING="$ACTING_LOGIN" \
PR_REVIEW_SEED_HEAD="$HEAD_SHA" python3 - "$REVIEWS_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["PR_REVIEW_SEED_MODE"]
acting = os.environ["PR_REVIEW_SEED_ACTING"]
head = os.environ["PR_REVIEW_SEED_HEAD"]
def review(rid, state, commit, login):
return {"id": rid, "state": state, "commit_id": commit, "user": {"login": login}}
if mode == "many-prior-approve":
# 50 pre-existing reviews already exist; the review this run submits becomes
# id 51, proving exact-id read-back works regardless of how many reviews
# precede it (no list enumeration is involved).
reviews = [review(i, "COMMENT", "oldsha0000", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent-review":
# A concurrent SAME-IDENTITY APPROVED review at the CURRENT head already
# exists. The wrapper's own submit will be a no-op; it must fail closed
# because no created id is returned — it must not scan and accept this one.
reviews = [review(77, "APPROVED", head, acting)]
else:
reviews = [review(100, "COMMENT", "oldsha0000", acting)]
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
PY
}
run_review() {
local mode="$1" action="$2" comment="${3:-}"
local configured_url="${4:-https://git.mosaicstack.dev}"
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
local expected_repo="${6:-mosaicstack/stack}"
local login_override="${7:-}"
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
local expected_api_root="${configured_url%/}/api/v1"
local expected_web_base="${configured_url%/}/$expected_repo"
git -C "$REPO_DIR" remote set-url origin "$remote_url"
write_credentials "$configured_url"
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \
PR_REVIEW_LOGIN_URL="${configured_url%/}" \
PR_REVIEW_CURL_LOG="$CURL_LOG" \
PR_REVIEW_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
PR_REVIEW_AUTH_LOG="$AUTH_LOG" \
PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_FILE" \
PR_REVIEW_HEAD_CALLS="$HEAD_CALLS_FILE" \
PR_REVIEW_TEST_MODE="$mode" \
PR_REVIEW_EXPECTED_BODY="$comment" \
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
PR_REVIEW_API_ROOT="$expected_api_root" \
PR_REVIEW_WEB_BASE="$expected_web_base" \
PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
PR_REVIEW_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
PR_REVIEW_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
PR_REVIEW_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
PR_REVIEW_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
PR_REVIEW_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ${login_override:+--login "$login_override"}
) > "$OUTPUT_FILE" 2>&1
}
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
assert_no_temp_leak() {
local context="$1" leaked
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
# bearer token and must be unlinked on every exit path.
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-pr-review-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: pr-review temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
# via a curl --config file), and that --config auth was actually used. On the
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
exit 1
fi
}
assert_no_tea_write() {
# tea must only ever be used for the login list, never to write.
if grep -qvE '^login list --output json$' "$TEA_LOG"; then
echo "FAIL: wrapper invoked tea for something other than the login list" >&2
cat "$TEA_LOG" >&2
exit 1
fi
}
# Case 1: a plain approve submits a review via REST and verifies it by its exact
# provider-returned id (id 101), attributed to the acting identity, pinned to
# the PR head, with no separate comment.
run_review approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
# No review-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# No-override default path: the write, /user lookup, and read-back all resolve
# via the host-default credential and authenticate as the acting identity.
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $ACTING_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $ACTING_LOGIN\$" "$AUTH_LOG"
assert_no_tea_write
assert_no_temp_leak "approve"
# ITEM 3a: the host-default token drove this whole chain, yet never appeared in
# any curl argv — it was passed via a curl --config file.
assert_token_not_in_argv "approve default-token"
# The submitted review payload carries the event and the PR head commit_id.
PR_REVIEW_HEAD_SHA="$HEAD_SHA" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "APPROVED", payload
assert payload["commit_id"] == os.environ["PR_REVIEW_HEAD_SHA"], payload
PY
# A plain approve (no body) must not POST a comment.
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: plain approve unexpectedly posted a comment" >&2
exit 1
fi
# Case 2: a submitted review NOT authored by the acting identity must FAIL
# CLOSED — the exact-id read-back enforces authorship.
if run_review author-mismatch-review approve; then
echo "FAIL: approve accepted a review authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind.
assert_no_temp_leak "author-mismatch-review"
# Case 3: a no-op submit with a concurrent SAME-IDENTITY, same-state review at
# the current head already present must FAIL CLOSED — the closed concurrency
# window. The wrapper must not read back (or accept) the concurrent id 77.
if run_review no-op-concurrent-review approve; then
echo "FAIL: approve reported success when its submit no-opped but a concurrent review existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: approve accepted a concurrent review for a no-op submit (window not closed)" >&2
exit 1
fi
if grep -q '/pulls/123/reviews/77$' "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent review id 77 (illegitimate fallback)" >&2
exit 1
fi
# Case 4: a genuine matching review (id 51) created after 50 pre-existing reviews
# is still verified by its EXACT provider-returned id — no list enumeration is
# needed regardless of how many reviews precede it.
run_review many-prior-approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 51)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/51$' "$CURL_LOG"
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# Case 5: an approve WITH a body carries that body in the review submit itself —
# there is no separate detached comment POST.
run_review approve approve approve-note
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
PR_REVIEW_EXPECTED_BODY="approve-note" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: approve-with-body posted a separate comment instead of carrying the body on the review" >&2
exit 1
fi
# Case 6: request-changes requires a body and carries it on the REQUEST_CHANGES
# review submit.
run_review request-changes request-changes changes-required
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
PR_REVIEW_EXPECTED_BODY="changes-required" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "REQUEST_CHANGES", payload
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
assert_no_tea_write
# Case 7: the `comment` action creates a comment via REST and verifies it by its
# exact created id, attributed to the acting identity. This also exercises
# owner/repo + base-URL resolution across clone-URL shapes.
complex_body=$'durable "body"\n-- marker'
run_review comment-success comment "$complex_body"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_tea_write
assert_no_temp_leak "comment-success"
run_review http-success comment durable-body http://git.mosaicstack.dev
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
echo "Configured Gitea path prefix leaked into the repository slug" >&2
exit 1
fi
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an SSH remote's transport port must not be compared against the
# configured HTTP(S) API URL's port.
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an explicit default HTTP(S) port on the remote must equal an implicit
# (portless) configured URL.
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# Comment write/read-back failure modes must all fail closed.
if run_review write-transport-failure comment durable-body; then
echo "Expected provider transport failure to return nonzero" >&2
exit 1
fi
if run_review write-http-failure comment durable-body; then
echo "Expected non-201 provider write to return nonzero" >&2
exit 1
fi
if run_review readback-failure comment durable-body; then
echo "Expected mismatched provider read-back to return nonzero" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "Read-back mismatch reported durable success" >&2
exit 1
fi
# Case 8 (#865 Round-4): a RESOLVABLE explicit --login override must attribute
# the entire write→read-back chain to THAT login's token/identity, never the
# host-default identity. The override login carries its own token in the tea
# config, so /user, the review POST, and the exact-id read-back all authenticate
# as the override identity — and NOTHING is performed under the default identity.
run_review override-success approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$OVERRIDE_LOGIN"
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101 $OVERRIDE_LOGIN\$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override was silently downgraded to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_tea_write
# ITEM 3a: the override token likewise never leaked into curl argv.
assert_token_not_in_argv "override-success override-token"
# Case 9 (#865 Round-4): an UNRESOLVABLE explicit --login override (a name absent
# from the tea config) must FAIL CLOSED — nonzero exit, no success line, no review
# POST, and above all NO request performed under the host-default identity. The
# host-default best-effort fallback is reserved for the no-override path only.
if run_review override-unresolvable approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "nonexistent-typo-login"; then
echo "FAIL: an unresolvable --login override was not rejected (silently used the host default)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q '/pulls/123/reviews ' "$AUTH_LOG" && grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 10 (#865 Round-5): a --login override that IS present in tea config but
# whose URL is a DIFFERENT host than the repo remote must FAIL CLOSED (host-bound
# selection). The cross-host token must NEVER be sent to the repo host, and no
# review POST occurs.
if run_review cross-host approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$CROSS_HOST_LOGIN"; then
echo "FAIL: cross-host --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: cross-host --login override reported success" >&2
exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: cross-host --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Case 11 (#865 Blocker 4): SubmitReview finalizes/reuses a pending review id
# whose persisted body is NOT this submit's body. id/author/state/head all match
# the request, so ONLY body verification can catch the divergence — it must FAIL
# CLOSED. (Submit a non-empty body so the mismatch is meaningful.)
if run_review review-body-reuse approve real-submitted-review-body; then
echo "FAIL: review with a reused/foreign body was accepted (body not verified)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce the submitted review body" >&2
exit 1
fi
assert_no_temp_leak "review-body-reuse"
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
if run_review "$bad_mode" comment durable-body; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
exit 1
fi
assert_no_temp_leak "$bad_mode"
done
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
# pre-submit head read (which pins the review) and the post-verify re-read. The
# review is genuinely created and verified as pinned to the OLD head, but the
# live tip has moved on, so the wrapper must FAIL CLOSED rather than report a
# review that no longer covers the PR's current commit.
if run_review head-advanced-race approve; then
echo "FAIL: approve reported success though the PR head advanced after submit" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: current-head TOCTOU close-out did not fail closed on an advanced head" >&2
exit 1
fi
# The head was re-read after the submit/verify (2nd GET /pulls/123).
if [[ "$(grep -c '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG")" -lt 2 ]]; then
echo "FAIL: wrapper did not re-read the PR head after review verification" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "head-advanced-race"
# Case 17 (#865 ITEM 2): a claimed PR comment that actually lands as a plain
# ISSUE comment (issue #123 exists, PR #123 does not — issue_url set,
# pull_request_url empty) must FAIL CLOSED. The pr-review `comment` verifier
# requires a pull_request_url (kind=pulls) and rejects a bare issue_url.
if run_review comment-plain-issue comment durable-body; then
echo "FAIL: pr-review accepted a plain issue comment as a verified PR comment" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: a plain issue_url satisfied the PR comment verifier" >&2
exit 1
fi
assert_no_temp_leak "comment-plain-issue"
# Case 18 (#865 ITEM 3b): a non-empty review body submitted but persisted as null
# must FAIL CLOSED. id/author/state/head all match; only strict presence +
# string-type + exact body equality (not the old `(body or "")` coalesce) catches
# the lost body.
if run_review review-body-null approve real-submitted-review-body; then
echo "FAIL: review whose non-empty body persisted as null was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: a null persisted body passed strict review-body verification" >&2
exit 1
fi
assert_no_temp_leak "review-body-null"
echo "pr-review.sh REST review + comment create/read-back regression passed"

View File

@@ -6,10 +6,12 @@ from __future__ import annotations
import argparse import argparse
import copy import copy
import errno import errno
import hmac
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import json import json
import os import os
import secrets import secrets
import select
import signal import signal
import socket import socket
import stat import stat
@@ -20,6 +22,19 @@ import time
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
# This script is loaded both as an executable and through isolated stdlib tests.
# Keep its co-located receipt implementation importable in both modes.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from normative_fragments import build_payload_from_wire
from receipt_challenge import is_verbatim_receipt, latest_assistant_digest, receipt_for
from receipt_observer import (
FileTestReceiptObserver,
ReceiptObserver,
RuntimeReceiptObserver,
)
MAX_FRAME: Final = 64 * 1024 MAX_FRAME: Final = 64 * 1024
MAX_STATE: Final = 4 * 1024 * 1024 MAX_STATE: Final = 4 * 1024 * 1024
MAX_PENDING_TOKENS: Final = 256 MAX_PENDING_TOKENS: Final = 256
@@ -90,6 +105,14 @@ def valid_binding(binding: object) -> bool:
) )
def valid_receipt_evidence(evidence: object) -> bool:
return (
isinstance(evidence, dict)
and set(evidence) == {"h_latest_assistant"}
and is_hex_256(evidence["h_latest_assistant"])
)
def validate_state(value: object) -> dict[str, object]: def validate_state(value: object) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != {"version", "sessions", "tokens"}: if not isinstance(value, dict) or set(value) != {"version", "sessions", "tokens"}:
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
@@ -124,7 +147,11 @@ def validate_state(value: object) -> dict[str, object]:
for token_value, token in tokens.items(): for token_value, token in tokens.items():
if not is_hex_256(token_value) or not isinstance(token, dict): if not is_hex_256(token_value) or not isinstance(token, dict):
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
if set(token) != {"session_id", "runtime_generation", "binding", "consumed"}: token_fields = set(token)
if token_fields not in (
{"session_id", "runtime_generation", "binding", "consumed"},
{"session_id", "runtime_generation", "binding", "consumed", "evidence"},
):
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
session_id = token["session_id"] session_id = token["session_id"]
generation = token["runtime_generation"] generation = token["runtime_generation"]
@@ -137,6 +164,9 @@ def validate_state(value: object) -> dict[str, object]:
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
if not valid_binding(token["binding"]) or token["consumed"] is not False: if not valid_binding(token["binding"]) or token["consumed"] is not False:
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
evidence = token.get("evidence")
if evidence is not None and not valid_receipt_evidence(evidence):
raise BrokerFailure("STATE_INTEGRITY")
return value return value
@@ -275,8 +305,15 @@ class StateStore:
class Broker: class Broker:
def __init__(self, store: StateStore) -> None: def __init__(self, store: StateStore, observer: ReceiptObserver | None = None) -> None:
self.store = store self.store = store
# Production construction always has a transport-capable observer. Test
# fixtures may inject their controlled observer explicitly.
self.observer: ReceiptObserver = observer if observer is not None else RuntimeReceiptObserver()
# Set only by begin_verification after its mandatory revoke-first fence.
# It is preserved if later cycle admission is refused; all other broker
# actions retain the normal snapshot rollback behavior.
self._rejected_cycle_fence: tuple[dict[str, object], dict[str, dict[str, object]]] | None = None
# VERIFIED authority is deliberately volatile: broker restart revokes all # VERIFIED authority is deliberately volatile: broker restart revokes all
# leases while preserving WI-1 identity and pending-token integrity. # leases while preserving WI-1 identity and pending-token integrity.
self.leases: dict[str, dict[str, object]] = {} self.leases: dict[str, dict[str, object]] = {}
@@ -348,6 +385,9 @@ class Broker:
"runtime_generation": generation, "runtime_generation": generation,
"binding": copy.deepcopy(binding), "binding": copy.deepcopy(binding),
"consumed": False, "consumed": False,
# Receipt evidence is durably committed before this challenge may
# be consumed and promotion made externally visible.
"evidence": None,
} }
return token return token
@@ -357,16 +397,51 @@ class Broker:
raise BrokerFailure("STATE_INTEGRITY") raise BrokerFailure("STATE_INTEGRITY")
lease["state"] = LEASE_VERIFIED lease["state"] = LEASE_VERIFIED
def record_runtime_observation(
self, peer_pid: int, request: dict[str, object]
) -> dict[str, object]:
"""Record only an authenticated adapter's finalized assistant message.
This method is deliberately unreachable through ``Broker.handle`` and
its public broker socket. The production observer socket calls it after
SO_PEERCRED/ancestry authentication, preserving the S1 rule that a
broker request can never carry ``latest_assistant_message``.
"""
required = {
"action", "session_id", "runtime_generation", "runtime", "latest_assistant_message"
}
if set(request) != required or request.get("action") != "record_runtime_observation":
raise BrokerFailure("INVALID_OBSERVATION")
runtime = request.get("runtime")
message = request.get("latest_assistant_message")
if runtime not in READ_ONLY_TOOLS or not isinstance(message, str) or len(message.encode("utf-8")) > MAX_FRAME:
raise BrokerFailure("INVALID_OBSERVATION")
session_id, _ = self.authenticate(peer_pid, request)
generation = request["runtime_generation"]
lease = self.leases.get(session_id)
if (
not isinstance(lease, dict)
or lease.get("state") != LEASE_PENDING
or lease.get("runtime") != runtime
or lease.get("runtime_generation") != generation
or not isinstance(self.observer, RuntimeReceiptObserver)
):
raise BrokerFailure("OBSERVATION_UNAVAILABLE")
self.observer.record_latest_assistant_message(session_id, runtime, generation, message)
return {"ok": True}
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
if self.store.poisoned: if self.store.poisoned:
raise StateCommitUncertain() raise StateCommitUncertain()
previous = copy.deepcopy(self.store.value) previous = copy.deepcopy(self.store.value)
previous_leases = copy.deepcopy(self.leases) previous_leases = copy.deepcopy(self.leases)
self._rejected_cycle_fence = None
try: try:
response = self._handle(peer, request) response = self._handle(peer, request)
if self.store.value != previous: if self.store.value != previous:
self.store.commit() self.store.commit()
if request.get("action") == "promote_lease": if request.get("action") in {"promote_lease", "complete_recovery"}:
session_id = request.get("session_id") session_id = request.get("session_id")
if not isinstance(session_id, str): if not isinstance(session_id, str):
raise BrokerFailure("INVALID_IDENTITY") raise BrokerFailure("INVALID_IDENTITY")
@@ -376,9 +451,18 @@ class Broker:
except StateCommitUncertain: except StateCommitUncertain:
raise raise
except Exception: except Exception:
fence = self._rejected_cycle_fence
if fence is None:
self.store.value = previous self.store.value = previous
self.leases = previous_leases self.leases = previous_leases
else:
# A refused re-verification must never resurrect the preceding
# VERIFIED authority. Preserve only this post-revoke fence;
# every unrelated partial-write failure still rolls back.
self.store.value, self.leases = fence
raise raise
finally:
self._rejected_cycle_fence = None
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
peer_pid, peer_uid, peer_gid = peer peer_pid, peer_uid, peer_gid = peer
@@ -426,10 +510,76 @@ class Broker:
raise BrokerFailure("TOKEN_REPLAY") raise BrokerFailure("TOKEN_REPLAY")
del self.store.tokens()[token_value] del self.store.tokens()[token_value]
return {"ok": True} return {"ok": True}
if action == "begin_recovery":
# Recovery is the one ungated mutator, but it is not a second
# receipt protocol. It delegates to this exact normal-path
# transition, then marks its volatile pending cycle so completion
# can obtain the broker-minted challenge internally. Caller-supplied
# receipt text is never an input to recovery.
if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")):
raise BrokerFailure("INVALID_RECOVERY_REQUEST")
normal_request = dict(request)
normal_request["action"] = "begin_verification"
response = self._handle(peer, normal_request)
session_id = response.get("session_id")
if not isinstance(session_id, str):
# begin_verification deliberately does not return identity;
# recover it only after its authenticated shared transition.
candidate = request.get("session_id")
if not isinstance(candidate, str):
raise BrokerFailure("INVALID_IDENTITY")
session_id = candidate
lease = self.leases.get(session_id)
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
raise BrokerFailure("STATE_INTEGRITY")
lease["cycle_kind"] = "recovery"
response["state"] = "PENDING_DELIVERY"
return response
if action == "complete_recovery":
if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")):
raise BrokerFailure("INVALID_RECOVERY_REQUEST")
session_id, _ = self.authenticate(peer_pid, request)
lease = self.leases.get(session_id)
challenge = lease.get("receipt_challenge") if isinstance(lease, dict) else None
if (
not isinstance(lease, dict)
or lease.get("cycle_kind") != "recovery"
or lease.get("state") != LEASE_PENDING
or not isinstance(challenge, str)
):
raise BrokerFailure("INVALID_LEASE_TRANSITION")
try:
# Reuse the shipped observe -> evidence commit -> consume ->
# promote transition. The recovery caller supplies neither a
# normal-path receipt nor a challenge; the trusted observer
# and current broker cycle remain the sole evidence authority.
observed_request = {
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": request["runtime_generation"],
"receipt_challenge": challenge,
}
self._handle(peer, observed_request)
return self._handle(peer, {
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": request["runtime_generation"],
"receipt_challenge": challenge,
})
except Exception:
# A malformed, absent, or stale observed receipt must leave
# no pending recovery capability or live lease. A retry mints
# a new challenge through the shared begin transition.
self.revoke_session_authority(session_id)
self._rejected_cycle_fence = (
copy.deepcopy(self.store.value), copy.deepcopy(self.leases)
)
raise
if action == "begin_verification": if action == "begin_verification":
session_id, _ = self.authenticate(peer_pid, request) session_id, _ = self.authenticate(peer_pid, request)
runtime = request.get("runtime") runtime = request.get("runtime")
binding = request.get("binding") binding = request.get("binding")
construction = request.get("construction")
ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS) ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS)
if runtime not in READ_ONLY_TOOLS: if runtime not in READ_ONLY_TOOLS:
raise BrokerFailure("INVALID_RUNTIME") raise BrokerFailure("INVALID_RUNTIME")
@@ -443,47 +593,117 @@ class Broker:
raise BrokerFailure("INVALID_LEASE_TTL") raise BrokerFailure("INVALID_LEASE_TTL")
# Revoke-first is a broker operation, not advisory adapter order. # Revoke-first is a broker operation, not advisory adapter order.
self.revoke_session_authority(session_id) self.revoke_session_authority(session_id)
token = self.mint_token(session_id, request["runtime_generation"], binding) # If subsequent construction admission rejects, handle() restores
# this fence rather than the pre-cycle VERIFIED snapshot.
self._rejected_cycle_fence = (
copy.deepcopy(self.store.value), copy.deepcopy(self.leases)
)
try:
constructed = build_payload_from_wire(construction)
except ValueError as exc:
raise BrokerFailure("INVALID_CONSTRUCTION") from exc
if (
constructed.injectionDecision != "ACCEPTED"
or not constructed.promotion
or not isinstance(constructed.h_source, str)
or not isinstance(constructed.h_payload, str)
):
raise BrokerFailure("PAYLOAD_CONSTRUCTION_REFUSED")
if (
not hmac.compare_digest(binding["h_source"], constructed.h_source)
or not hmac.compare_digest(binding["h_payload"], constructed.h_payload)
):
raise BrokerFailure("PAYLOAD_BINDING_MISMATCH")
cycle_binding = copy.deepcopy(binding)
cycle_binding["runtime_generation"] = request["runtime_generation"]
challenge = self.mint_token(session_id, request["runtime_generation"], binding)
self.leases[session_id] = { self.leases[session_id] = {
"state": LEASE_PENDING, "state": LEASE_PENDING,
"runtime": runtime, "runtime": runtime,
"runtime_generation": request["runtime_generation"], "runtime_generation": request["runtime_generation"],
"binding": copy.deepcopy(binding), "binding": copy.deepcopy(binding),
"promotion_token": token, "receipt_challenge": challenge,
"ttl_seconds": ttl_seconds, "ttl_seconds": ttl_seconds,
} }
# The broker constructs both the bound challenge and the delivery
# text. The model's role is an exact copy, never hashing its output.
return { return {
"ok": True, "ok": True,
"state": LEASE_PENDING, "state": LEASE_PENDING,
"promotion_token": token, "receipt_challenge": challenge,
"receipt": receipt_for(challenge, cycle_binding),
"binding": cycle_binding,
} }
if action == "promote_lease": if action == "observe_receipt":
session_id, _ = self.authenticate(peer_pid, request) session_id, _ = self.authenticate(peer_pid, request)
promotion_token = request.get("promotion_token") challenge = request.get("receipt_challenge")
lease = self.leases.get(session_id) if "latest_assistant_message" in request:
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING: raise BrokerFailure("INVALID_RECEIPT")
raise BrokerFailure("INVALID_LEASE_TRANSITION") if not isinstance(challenge, str):
expected_token = lease.get("promotion_token") raise BrokerFailure("INVALID_RECEIPT")
if ( token = self.store.tokens().get(challenge)
not isinstance(promotion_token, str)
or not isinstance(expected_token, str)
or not secrets.compare_digest(promotion_token, expected_token)
):
raise BrokerFailure("PROMOTION_TOKEN_MISMATCH")
token = self.store.tokens().get(promotion_token)
if ( if (
not isinstance(token, dict) not isinstance(token, dict)
or token.get("session_id") != session_id or token.get("session_id") != session_id
or token.get("runtime_generation") != request.get("runtime_generation") or token.get("runtime_generation") != request.get("runtime_generation")
or token.get("binding") != lease.get("binding")
or token.get("consumed") is not False or token.get("consumed") is not False
): ):
raise BrokerFailure("PROMOTION_TOKEN_INVALID") raise BrokerFailure("RECEIPT_REPLAY")
del self.store.tokens()[promotion_token] lease = self.leases.get(session_id)
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
raise BrokerFailure("RECEIPT_REPLAY")
expected_challenge = lease.get("receipt_challenge")
binding = lease.get("binding")
if (
not isinstance(expected_challenge, str)
or not secrets.compare_digest(challenge, expected_challenge)
or not isinstance(binding, dict)
or token.get("binding") != binding
):
raise BrokerFailure("RECEIPT_REPLAY")
receipt_binding = copy.deepcopy(binding)
receipt_binding["runtime_generation"] = request["runtime_generation"]
message = self.observer.observe_latest_assistant_message(
session_id, str(lease["runtime"]), request["runtime_generation"], receipt_binding
)
if not isinstance(message, str):
raise BrokerFailure("RECEIPT_OBSERVATION_UNAVAILABLE")
# This accepts one exact current-cycle assistant entry only. It is
# deliberately not a transcript search and rejects quoted/extra text.
if not is_verbatim_receipt(message, challenge, receipt_binding):
raise BrokerFailure("RECEIPT_MISMATCH")
evidence = {"h_latest_assistant": latest_assistant_digest(message)}
token["evidence"] = evidence
lease["state"] = LEASE_PENDING_PROMOTION lease["state"] = LEASE_PENDING_PROMOTION
lease["evidence"] = copy.deepcopy(evidence)
return {"ok": True, "state": LEASE_PENDING_PROMOTION}
if action == "promote_lease":
session_id, _ = self.authenticate(peer_pid, request)
challenge = request.get("receipt_challenge")
if not isinstance(challenge, str):
raise BrokerFailure("INVALID_RECEIPT")
lease = self.leases.get(session_id)
if not isinstance(lease, dict) or lease.get("state") == LEASE_UNVERIFIED:
raise BrokerFailure("INVALID_LEASE_TRANSITION")
token = self.store.tokens().get(challenge)
if (
not isinstance(token, dict)
or token.get("session_id") != session_id
or token.get("runtime_generation") != request.get("runtime_generation")
or token.get("consumed") is not False
):
raise BrokerFailure("RECEIPT_REPLAY")
if lease.get("state") != LEASE_PENDING_PROMOTION:
raise BrokerFailure("INVALID_LEASE_TRANSITION")
expected_challenge = lease.get("receipt_challenge")
if not isinstance(expected_challenge, str) or not secrets.compare_digest(challenge, expected_challenge):
raise BrokerFailure("RECEIPT_REPLAY")
if token.get("binding") != lease.get("binding") or not valid_receipt_evidence(token.get("evidence")):
raise BrokerFailure("PROMOTION_TOKEN_INVALID")
del self.store.tokens()[challenge]
lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"]) lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"])
# handle() commits token consumption before finish_promotion() makes # handle() commits the evidence-backed consumption before
# VERIFIED externally visible: promote-last by construction. # finish_promotion() makes VERIFIED externally visible: promote-last.
return {"ok": True, "state": LEASE_PENDING_PROMOTION} return {"ok": True, "state": LEASE_PENDING_PROMOTION}
if action == "revoke_lease": if action == "revoke_lease":
session_id, _ = self.authenticate(peer_pid, request) session_id, _ = self.authenticate(peer_pid, request)
@@ -604,12 +824,65 @@ def handle_connection(
return return
def serve(socket_path: Path, state_path: Path) -> None: def handle_runtime_observation_connection(
connection: socket.socket,
broker: Broker,
broker_lock: threading.Lock,
) -> None:
"""Serve the authenticated production observer transport, never the broker API."""
with connection:
read_deadline = time.monotonic() + READ_DEADLINE_SECONDS
try:
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
peer = struct.unpack("3i", raw)
request = read_frame(connection, read_deadline)
except BrokerFailure as exc:
reply = {"ok": False, "code": exc.code}
except OSError:
return
else:
acquired = broker_lock.acquire(timeout=HANDLE_QUEUE_TIMEOUT_SECONDS)
if not acquired:
reply = {"ok": False, "code": "BROKER_BUSY"}
else:
try:
try:
reply = broker.record_runtime_observation(peer[0], request)
except BrokerFailure as exc:
reply = {"ok": False, "code": exc.code}
finally:
broker_lock.release()
try:
connection.settimeout(SEND_TIMEOUT_SECONDS)
connection.sendall((json.dumps(reply, separators=(",", ":")) + "\n").encode())
except OSError:
return
def serve(
socket_path: Path,
state_path: Path,
observer: ReceiptObserver | None = None,
observer_socket_path: Path | None = None,
) -> None:
secure_parent(socket_path) secure_parent(socket_path)
if socket_path.exists() or socket_path.is_symlink(): if socket_path.exists() or socket_path.is_symlink():
raise BrokerFailure("SOCKET_ALREADY_EXISTS") raise BrokerFailure("SOCKET_ALREADY_EXISTS")
runtime_observer = observer is None
if runtime_observer:
observer = RuntimeReceiptObserver()
observer_socket_path = observer_socket_path or socket_path.with_name("receipt-observer.sock")
if observer_socket_path == socket_path:
raise BrokerFailure("OBSERVER_SOCKET_CONFLICT")
secure_parent(observer_socket_path)
if observer_socket_path.exists() or observer_socket_path.is_symlink():
raise BrokerFailure("OBSERVER_SOCKET_ALREADY_EXISTS")
elif observer_socket_path is not None:
raise BrokerFailure("TEST_OBSERVER_SOCKET_CONFLICT")
store = StateStore(state_path) store = StateStore(state_path)
broker = Broker(store) broker = Broker(store, observer)
broker_lock = threading.Lock() broker_lock = threading.Lock()
slots = threading.BoundedSemaphore(MAX_IN_FLIGHT_CONNECTIONS) slots = threading.BoundedSemaphore(MAX_IN_FLIGHT_CONNECTIONS)
fatal_lock = threading.Lock() fatal_lock = threading.Lock()
@@ -622,15 +895,27 @@ def serve(socket_path: Path, state_path: Path) -> None:
server.bind(str(socket_path)) server.bind(str(socket_path))
os.chmod(socket_path, 0o600) os.chmod(socket_path, 0o600)
owned = (socket_path.stat().st_dev, socket_path.stat().st_ino) owned = (socket_path.stat().st_dev, socket_path.stat().st_ino)
observer_server: socket.socket | None = None
observer_owned: tuple[int, int] | None = None
if runtime_observer and observer_socket_path is not None:
observer_server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
observer_server.bind(str(observer_socket_path))
os.chmod(observer_socket_path, 0o600)
observer_owned = (observer_socket_path.stat().st_dev, observer_socket_path.stat().st_ino)
stopping = False stopping = False
def stop(_signum: int, _frame: object) -> None: def stop(_signum: int, _frame: object) -> None:
nonlocal stopping nonlocal stopping
stopping = True stopping = True
server.close() server.close()
if observer_server is not None:
observer_server.close()
def process_connection(connection: socket.socket) -> None: def process_connection(connection: socket.socket, is_observer: bool) -> None:
try: try:
if is_observer:
handle_runtime_observation_connection(connection, broker, broker_lock)
else:
handle_connection(connection, broker, broker_lock) handle_connection(connection, broker, broker_lock)
except Exception as exc: except Exception as exc:
with fatal_lock: with fatal_lock:
@@ -646,41 +931,54 @@ def serve(socket_path: Path, state_path: Path) -> None:
signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop) signal.signal(signal.SIGINT, stop)
server.listen(MAX_IN_FLIGHT_CONNECTIONS) server.listen(MAX_IN_FLIGHT_CONNECTIONS)
server.settimeout(0.1) server.setblocking(False)
if observer_server is not None:
observer_server.listen(MAX_IN_FLIGHT_CONNECTIONS)
observer_server.setblocking(False)
print("READY", flush=True) print("READY", flush=True)
try: try:
while not stopping: while not stopping:
failure = fatal_error() failure = fatal_error()
if failure is not None: if failure is not None:
raise failure raise failure
if not slots.acquire(timeout=0.1): listeners = [server, *([observer_server] if observer_server is not None else [])]
try:
ready, _, _ = select.select(listeners, [], [], 0.1)
except (OSError, ValueError):
if stopping:
break
raise
for listener in ready:
if not slots.acquire(blocking=False):
continue continue
try: try:
connection, _ = server.accept() connection, _ = listener.accept()
except socket.timeout: except BlockingIOError:
slots.release() slots.release()
continue continue
except OSError: except OSError:
slots.release() slots.release()
failure = fatal_error()
if failure is not None:
raise failure
if stopping: if stopping:
break break
raise raise
try: try:
executor.submit(process_connection, connection) executor.submit(process_connection, connection, listener is observer_server)
except Exception: except Exception:
slots.release() slots.release()
connection.close() connection.close()
raise raise
finally: finally:
server.close() server.close()
if observer_server is not None:
observer_server.close()
executor.shutdown(wait=True) executor.shutdown(wait=True)
for path, inode in ((socket_path, owned), (observer_socket_path, observer_owned)):
if path is None or inode is None:
continue
try: try:
current = socket_path.stat() current = path.stat()
if (current.st_dev, current.st_ino) == owned: if (current.st_dev, current.st_ino) == inode:
socket_path.unlink() path.unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
@@ -689,8 +987,17 @@ def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True, type=Path) parser.add_argument("--socket", required=True, type=Path)
parser.add_argument("--state", required=True, type=Path) parser.add_argument("--state", required=True, type=Path)
parser.add_argument("--observer-socket", type=Path)
parser.add_argument("--test-observer-file", type=Path)
arguments = parser.parse_args() arguments = parser.parse_args()
serve(arguments.socket, arguments.state) if arguments.observer_socket is not None and arguments.test_observer_file is not None:
raise BrokerFailure("TEST_OBSERVER_SOCKET_CONFLICT")
observer = (
FileTestReceiptObserver(arguments.test_observer_file)
if arguments.test_observer_file is not None
else None
)
serve(arguments.socket, arguments.state, observer, arguments.observer_socket)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -12,6 +12,8 @@ from collections.abc import Callable, Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
from lease_generation import initialize_runtime_generation
MAX_FRAME: Final = 64 * 1024 MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5 BROKER_TIMEOUT_SECONDS: Final = 1.5
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions" CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
@@ -44,6 +46,7 @@ def main(
environ: Mapping[str, str] | None = None, environ: Mapping[str, str] | None = None,
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe, execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation,
) -> int: ) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi")) parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
@@ -83,6 +86,8 @@ def main(
or any(character not in "0123456789abcdef" for character in session_id) or any(character not in "0123456789abcdef" for character in session_id)
): ):
raise ValueError("registration refused") raise ValueError("registration refused")
generation_file = socket_path.parent / f"generation-{session_id}.state"
initialize_generation(generation_file, generation)
except (KeyError, ValueError, OSError, json.JSONDecodeError): except (KeyError, ValueError, OSError, json.JSONDecodeError):
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1 return 1
@@ -90,7 +95,14 @@ def main(
environment = dict(source_environment) environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
environment["MOSAIC_LEASE_GENERATION_FILE"] = str(generation_file)
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
# Matches daemon.py's production default; deployments using a distinct
# observer socket may set this authenticated transport path explicitly.
environment.setdefault(
"MOSAIC_RECEIPT_OBSERVER_SOCKET",
str(socket_path.with_name("receipt-observer.sock")),
)
try: try:
execute(command[0], command, environment) execute(command[0], command, environment)
except OSError: except OSError:

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Private monotonic runtime-generation state shared by runtime hook processes."""
from __future__ import annotations
import fcntl
import os
import stat
from collections.abc import Mapping
from pathlib import Path
from typing import Final
MAX_GENERATION: Final = (1 << 63) - 1
MAX_GENERATION_BYTES: Final = 32
def parse_generation(value: object) -> int:
if not isinstance(value, str) or not value.isascii() or not value.isdigit():
raise ValueError("invalid runtime generation")
generation = int(value)
if generation < 0 or generation > MAX_GENERATION:
raise ValueError("invalid runtime generation")
return generation
def _validate_descriptor(descriptor: int) -> None:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise ValueError("runtime generation state is not a regular file")
if metadata.st_uid != os.geteuid():
raise ValueError("runtime generation state has the wrong owner")
if stat.S_IMODE(metadata.st_mode) & 0o077:
raise ValueError("runtime generation state permissions are not private")
if metadata.st_size > MAX_GENERATION_BYTES:
raise ValueError("runtime generation state is oversized")
def _read_descriptor(descriptor: int) -> int:
os.lseek(descriptor, 0, os.SEEK_SET)
raw = os.read(descriptor, MAX_GENERATION_BYTES + 1)
if len(raw) > MAX_GENERATION_BYTES:
raise ValueError("runtime generation state is oversized")
try:
return parse_generation(raw.decode("ascii").strip())
except UnicodeDecodeError as exc:
raise ValueError("invalid runtime generation state") from exc
def _write_descriptor(descriptor: int, generation: int) -> None:
payload = f"{generation}\n".encode("ascii")
os.lseek(descriptor, 0, os.SEEK_SET)
os.ftruncate(descriptor, 0)
remaining = memoryview(payload)
while remaining:
written = os.write(descriptor, remaining)
if written <= 0:
raise OSError("runtime generation write made no progress")
remaining = remaining[written:]
os.fsync(descriptor)
def initialize_runtime_generation(path: Path, generation: int) -> None:
if generation < 0 or generation > MAX_GENERATION:
raise ValueError("invalid runtime generation")
descriptor = os.open(
path,
os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_CLOEXEC | os.O_NOFOLLOW,
0o600,
)
try:
os.fchmod(descriptor, 0o600)
_validate_descriptor(descriptor)
_write_descriptor(descriptor, generation)
finally:
os.close(descriptor)
def read_runtime_generation(environ: Mapping[str, str]) -> int:
state_path = environ.get("MOSAIC_LEASE_GENERATION_FILE")
if not state_path:
return parse_generation(environ["MOSAIC_RUNTIME_GENERATION"])
descriptor = os.open(state_path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
_validate_descriptor(descriptor)
return _read_descriptor(descriptor)
finally:
os.close(descriptor)
def bump_runtime_generation(environ: Mapping[str, str]) -> int:
state_path = environ.get("MOSAIC_LEASE_GENERATION_FILE")
if not state_path:
raise ValueError("runtime generation file is required for same-PID rollover")
descriptor = os.open(state_path, os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX)
_validate_descriptor(descriptor)
current = _read_descriptor(descriptor)
if current >= MAX_GENERATION:
raise ValueError("runtime generation exhausted")
generation = current + 1
_write_descriptor(descriptor, generation)
return generation
finally:
os.close(descriptor)

View File

@@ -8,12 +8,23 @@ import json
import os import os
import socket import socket
import sys import sys
import re
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import BinaryIO, Final from typing import BinaryIO, Final
# Isolated (`python -I`) adapter invocations must still import co-located
# framework modules; never depend on the caller's PYTHONPATH.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from lease_generation import read_runtime_generation
MAX_FRAME: Final = 64 * 1024 MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5 BROKER_TIMEOUT_SECONDS: Final = 1.5
RECOVERY_TOOL: Final = "mosaic_context_recover"
_LITERAL_ABSOLUTE_PATH: Final = re.compile(r"/[A-Za-z0-9._/-]+\Z")
_SHELL_ACTIVE: Final = frozenset("$`~*?[]{}<>;|&" + '"' + "'" + "\\" + "\n\r\t")
def deny(code: str) -> int: def deny(code: str) -> int:
@@ -21,7 +32,7 @@ def deny(code: str) -> int:
return 2 return 2
def read_tool_name(stream: BinaryIO | None = None) -> str: def read_tool_request(stream: BinaryIO | None = None) -> dict[str, object]:
source = sys.stdin.buffer if stream is None else stream source = sys.stdin.buffer if stream is None else stream
raw = source.read(MAX_FRAME + 1) raw = source.read(MAX_FRAME + 1)
if len(raw) > MAX_FRAME: if len(raw) > MAX_FRAME:
@@ -32,7 +43,61 @@ def read_tool_name(stream: BinaryIO | None = None) -> str:
tool_name = value.get("tool_name") tool_name = value.get("tool_name")
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256: if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
raise ValueError("INVALID_GATE_INPUT") raise ValueError("INVALID_GATE_INPUT")
return tool_name return value
def read_tool_name(stream: BinaryIO | None = None) -> str:
"""Backward-compatible strict extraction for callers that need only the name."""
return str(read_tool_request(stream)["tool_name"])
def recovery_invocation_name(request: dict[str, object], recovery_command: Path | None) -> str:
"""Map only a byte-literal Claude recovery argv to ``RECOVERY_TOOL``.
Claude's Bash tool evaluates its raw command with a real shell. Therefore
the gate never attempts a second shell parser: any quote, expansion,
redirection, operator, glob, newline, or non-space whitespace is refused
before tokenizing. The remaining plain-space split is an exact argv proof,
not a best-effort interpretation of shell syntax.
"""
tool_name = request["tool_name"]
if tool_name != "Bash" or recovery_command is None:
return str(tool_name)
tool_input = request.get("tool_input")
if not isinstance(tool_input, dict) or set(tool_input) != {"command"}:
return str(tool_name)
command = tool_input.get("command")
if (
not isinstance(command, str)
or not command
or any(character in _SHELL_ACTIVE for character in command)
or command.startswith(" ")
or command.endswith(" ")
or " " in command
):
return str(tool_name)
argv = command.split(" ")
if " ".join(argv) != command or len(argv) < 3 or argv[0] != "python3":
return str(tool_name)
if argv[1] != str(recovery_command):
return str(tool_name)
phase = argv[2]
if phase == "complete" and len(argv) == 3:
return RECOVERY_TOOL
if phase != "begin" or len(argv) != 9:
return str(tool_name)
if argv[3::2] != ["--construction", "--compaction-epoch", "--request-epoch"]:
return str(tool_name)
construction, compaction_epoch, request_epoch = argv[4::2]
if (
_LITERAL_ABSOLUTE_PATH.fullmatch(construction) is None
or not compaction_epoch.isdecimal()
or not request_epoch.isdecimal()
):
return str(tool_name)
return RECOVERY_TOOL
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]: def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
@@ -64,19 +129,20 @@ def main(
environ: Mapping[str, str] | None = None, environ: Mapping[str, str] | None = None,
stream: BinaryIO | None = None, stream: BinaryIO | None = None,
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
resolve_generation: Callable[[Mapping[str, str]], int] = read_runtime_generation,
) -> int: ) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi")) parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("--recovery-command", type=Path)
arguments = parser.parse_args(argv) arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ source_environment = os.environ if environ is None else environ
try: try:
tool_name = read_tool_name(stream) request_input = read_tool_request(stream)
tool_name = recovery_invocation_name(request_input, arguments.recovery_command)
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"] socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"] session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"]) generation = resolve_generation(source_environment)
if generation < 0:
raise ValueError("INVALID_GENERATION")
reply = request( reply = request(
Path(socket_value), Path(socket_value),
{ {

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Fail-closed construction of verbatim-hashed normative fragments.
This is the single construction path for the Claude and Pi adapters. The
payload contains only versioned source metadata and exact validated source
bytes. ``h_payload`` is derived afterwards and is deliberately not representable
as a payload input, preventing cryptographic self-reference.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import struct
from collections.abc import Sequence
from typing import Final
MAX_FRAGMENT_BYTES: Final = 64 * 1024
HASH_DOMAIN_SEPARATOR: Final = b"MOSAIC/H_PAYLOAD/v1\x00"
SOURCE_DOMAIN_SEPARATOR: Final = b"MOSAIC/H_SOURCE/v1\x00"
_PAYLOAD_VERSION_LABEL: Final = b"MOSAIC/B_PAYLOAD/v1"
class NormativeFragment:
"""A source identity, its expected digest, and its exact resolved bytes."""
def __init__(self, source_id: str, content: bytes | None, expected_sha256: str) -> None:
self.source_id = source_id
self.content = content
self.expected_sha256 = expected_sha256
class ConstructionResult:
"""A source-admission decision and, only when admitted, derived payload values.
``promotion`` means the construction has produced the only values a later
receipt protocol may use to attempt promotion. It never performs broker
promotion itself. A REFUSED result has no payload/hash values, so it cannot
advance to that later protocol.
"""
def __init__(
self,
injection_decision: str,
promotion: bool,
source_reason: str | None,
b_payload: bytes | None,
h_payload: str | None,
h_source: str | None,
) -> None:
self.injectionDecision = injection_decision
self.promotion = promotion
self.source_reason = source_reason
self.b_payload = b_payload
self.h_payload = h_payload
self.h_source = h_source
def length_frame(parts: Sequence[bytes]) -> bytes:
"""Encode a finite ordered byte sequence with unambiguous 64-bit framing."""
framed = bytearray(struct.pack(">Q", len(parts)))
for part in parts:
if not isinstance(part, bytes):
raise TypeError("length framing requires bytes")
framed.extend(struct.pack(">Q", len(part)))
framed.extend(part)
return bytes(framed)
def _sha256_hex(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _valid_digest(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
def _source_reason(fragment: NormativeFragment) -> str | None:
if not isinstance(fragment.source_id, str) or not fragment.source_id:
return "missing"
if fragment.content is None:
return "missing"
if not isinstance(fragment.content, bytes):
return "missing"
if len(fragment.content) > MAX_FRAGMENT_BYTES:
return "oversize"
if not _valid_digest(fragment.expected_sha256):
return "hash-mismatch"
actual = _sha256_hex(fragment.content)
if not hmac.compare_digest(actual, fragment.expected_sha256):
return "hash-mismatch"
return None
def _refused(reason: str) -> ConstructionResult:
return ConstructionResult(
injection_decision="REFUSED",
promotion=False,
source_reason=reason,
b_payload=None,
h_payload=None,
h_source=None,
)
def build_payload(
*,
manifest_version: int,
generator_version: str,
fragments: Sequence[NormativeFragment],
) -> ConstructionResult:
"""Construct B_payload then H_payload after fail-closed source validation.
The sequence order is caller-supplied resolved-source order and is encoded
directly. Changing source order, source identity, metadata, or any exact
fragment byte therefore changes the framed B_payload and its derived hash.
"""
if type(manifest_version) is not int or manifest_version < 0:
return _refused("missing")
if not isinstance(generator_version, str) or not generator_version:
return _refused("missing")
validated = list(fragments)
if not validated:
return _refused("missing")
for fragment in validated:
if not isinstance(fragment, NormativeFragment):
return _refused("missing")
reason = _source_reason(fragment)
if reason is not None:
return _refused(reason)
source_identities = [
length_frame([
fragment.source_id.encode("utf-8"),
fragment.expected_sha256.encode("ascii"),
])
for fragment in validated
]
h_source = _sha256_hex(SOURCE_DOMAIN_SEPARATOR + length_frame(source_identities))
payload_parts = [
_PAYLOAD_VERSION_LABEL,
str(manifest_version).encode("ascii"),
generator_version.encode("utf-8"),
*source_identities,
*(fragment.content for fragment in validated),
]
# h_payload is intentionally not an argument or field in payload_parts.
b_payload = length_frame(payload_parts)
h_payload = _sha256_hex(HASH_DOMAIN_SEPARATOR + length_frame([b_payload]))
return ConstructionResult(
injection_decision="ACCEPTED",
promotion=True,
source_reason=None,
b_payload=b_payload,
h_payload=h_payload,
h_source=h_source,
)
def build_payload_from_wire(value: object) -> ConstructionResult:
"""Decode a bounded broker request then invoke the sole payload builder.
Wire inputs contain only source bytes and their claimed source digests. The
authoritative ``build_payload`` implementation remains the only code that
admits those bytes and derives ``h_source``/``h_payload``.
"""
if not isinstance(value, dict) or set(value) != {
"manifest_version", "generator_version", "fragments"
}:
raise ValueError("invalid construction")
raw_fragments = value["fragments"]
if not isinstance(raw_fragments, list):
raise ValueError("invalid construction")
fragments: list[NormativeFragment] = []
for raw_fragment in raw_fragments:
if not isinstance(raw_fragment, dict) or set(raw_fragment) != {
"source_id", "content_base64", "expected_sha256"
}:
raise ValueError("invalid construction")
source_id = raw_fragment["source_id"]
encoded = raw_fragment["content_base64"]
expected_sha256 = raw_fragment["expected_sha256"]
if not isinstance(source_id, str) or not isinstance(encoded, str) or not isinstance(expected_sha256, str):
raise ValueError("invalid construction")
try:
content = base64.b64decode(encoded.encode("ascii"), validate=True)
except (UnicodeEncodeError, ValueError) as exc:
raise ValueError("invalid construction") from exc
fragments.append(NormativeFragment(source_id, content, expected_sha256))
return build_payload(
manifest_version=value["manifest_version"],
generator_version=value["generator_version"],
fragments=fragments,
)
def build_for_claude(**kwargs: object) -> ConstructionResult:
"""Claude adapter entrypoint; delegates to the sole shared constructor."""
return build_payload(**kwargs) # type: ignore[arg-type]
def build_for_pi(**kwargs: object) -> ConstructionResult:
"""Pi adapter entrypoint; delegates to the sole shared constructor."""
return build_payload(**kwargs) # type: ignore[arg-type]

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Authenticated adapter-to-daemon transport for finalized assistant receipts.
This is not a broker request client. It writes only to the daemon-owned observer
socket, which authenticates SO_PEERCRED/ancestry before retaining a message for
the broker's ReceiptObserver seam.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import stat
import sys
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
def read_json(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid observer input")
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("invalid observer input")
return value
def assistant_text(entry: object) -> str | None:
if not isinstance(entry, dict):
return None
message = entry.get("message", entry)
if not isinstance(message, dict) or message.get("role") != "assistant":
return None
content = message.get("content")
if isinstance(content, str):
return content
if not isinstance(content, list):
return None
parts: list[str] = []
for item in content:
if not isinstance(item, dict) or item.get("type") != "text" or not isinstance(item.get("text"), str):
return None
parts.append(item["text"])
return "".join(parts)
def claude_latest_entry(value: dict[str, object]) -> str:
transcript_path = value.get("transcript_path")
if not isinstance(transcript_path, str) or not transcript_path:
raise ValueError("invalid Claude observer input")
path = Path(transcript_path)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_TRANSCRIPT_BYTES:
raise ValueError("unsafe Claude transcript")
raw = os.read(descriptor, MAX_TRANSCRIPT_BYTES + 1)
finally:
os.close(descriptor)
if len(raw) > MAX_TRANSCRIPT_BYTES:
raise ValueError("oversized Claude transcript")
for line in reversed(raw.decode("utf-8").splitlines()):
try:
text = assistant_text(json.loads(line))
except json.JSONDecodeError as exc:
raise ValueError("invalid Claude transcript") from exc
if text is not None:
return text
raise ValueError("Claude transcript has no assistant entry")
def pi_message_end(value: dict[str, object]) -> str:
if set(value) != {"latest_assistant_message"} or not isinstance(value["latest_assistant_message"], str):
raise ValueError("invalid Pi observer input")
return value["latest_assistant_message"]
def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
if len(payload) > MAX_FRAME:
raise ValueError("observer request too large")
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(socket_path))
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid observer reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid observer reply")
return value
def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("--latest-entry", action="store_true")
arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ
try:
source = read_json(sys.stdin)
if arguments.runtime == "claude":
if not arguments.latest_entry:
raise ValueError("Claude observer requires --latest-entry")
message = claude_latest_entry(source)
else:
if arguments.latest_entry:
raise ValueError("Pi observer is message_end only")
message = pi_message_end(source)
if len(message.encode("utf-8")) > MAX_FRAME:
raise ValueError("assistant message too large")
reply = observer_request(Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]), {
"action": "record_runtime_observation",
"session_id": source_environment["MOSAIC_LEASE_SESSION_ID"],
"runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]),
"runtime": arguments.runtime,
"latest_assistant_message": message,
})
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
return 2
return 0 if reply == {"ok": True} else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Broker-side construction and verification for one-time receipt challenges."""
from __future__ import annotations
import hashlib
import hmac
import struct
from typing import Final
LATEST_ASSISTANT_DOMAIN_SEPARATOR: Final = b"MOSAIC/H_LATEST_ASSISTANT/v1\x00"
def _length_frame(value: bytes) -> bytes:
return struct.pack(">Q", len(value)) + value
def receipt_for(challenge: str, binding: dict[str, object]) -> str:
"""Return the sole receipt text a model may copy for this broker cycle."""
return (
"MOSAIC-RECEIPT{"
f"challenge={challenge}; "
f"H_payload={binding['h_payload']}; "
f"gen={binding['runtime_generation']}; "
f"cep={binding['compaction_epoch']}"
"}"
)
def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]) -> bool:
"""Require the exact one current-cycle receipt, not a transcript substring."""
expected = receipt_for(challenge, binding)
return hmac.compare_digest(message, expected)
def latest_assistant_digest(message: str) -> str:
"""Record the broker-computed digest of the exact observed assistant entry."""
encoded = message.encode("utf-8")
return hashlib.sha256(LATEST_ASSISTANT_DOMAIN_SEPARATOR + _length_frame(encoded)).hexdigest()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Trusted latest-assistant-message observer boundary for receipt promotion.
Production adapters deliver finalized assistant content over the daemon-owned
observer socket after the daemon authenticates their peer against the broker's
kernel-anchored session identity. The broker request protocol never accepts
assistant-message content. Claude supplies its latest assistant entry; Pi
supplies finalized assistant content at ``message_end``.
"""
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
from typing import Protocol
class ReceiptObserver(Protocol):
def observe_latest_assistant_message(
self,
session_id: str,
runtime: str,
runtime_generation: int,
binding: dict[str, object],
) -> str | None: ...
class UnavailableReceiptObserver:
"""Fail-closed only for direct unit construction without daemon transport."""
def observe_latest_assistant_message(
self,
_session_id: str,
_runtime: str,
_runtime_generation: int,
_binding: dict[str, object],
) -> str | None:
return None
class RuntimeReceiptObserver:
"""Daemon-owned production observer populated only by authenticated adapters."""
def __init__(self) -> None:
self._messages: dict[tuple[str, str, int], str] = {}
def record_latest_assistant_message(
self,
session_id: str,
runtime: str,
runtime_generation: int,
message: str,
) -> None:
self._messages[(session_id, runtime, runtime_generation)] = message
def observe_latest_assistant_message(
self,
session_id: str,
runtime: str,
runtime_generation: int,
_binding: dict[str, object],
) -> str | None:
return self._messages.get((session_id, runtime, runtime_generation))
class TestReceiptObserver:
"""Deterministic controlled observer used only by byte-build tests."""
def __init__(self) -> None:
self._messages: dict[tuple[str, int], str] = {}
def record_latest_assistant_message(
self, session_id: str, runtime_generation: int, message: str
) -> None:
self._messages[(session_id, runtime_generation)] = message
def observe_latest_assistant_message(
self,
session_id: str,
_runtime: str,
runtime_generation: int,
_binding: dict[str, object],
) -> str | None:
return self._messages.get((session_id, runtime_generation))
class FileTestReceiptObserver:
"""Private fixture-file observer for isolated out-of-process test drivers only."""
def __init__(self, path: Path) -> None:
self.path = path
def observe_latest_assistant_message(
self,
session_id: str,
_runtime: str,
runtime_generation: int,
_binding: dict[str, object],
) -> str | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self.path, flags)
except OSError:
return None
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_uid != os.geteuid()
or metadata.st_size > 64 * 1024
):
return None
raw = os.read(descriptor, 64 * 1024 + 1)
finally:
os.close(descriptor)
if len(raw) > 64 * 1024:
return None
try:
value = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if (
not isinstance(value, dict)
or set(value) != {"session_id", "runtime_generation", "latest_assistant_message"}
or value["session_id"] != session_id
or value["runtime_generation"] != runtime_generation
or not isinstance(value["latest_assistant_message"], str)
):
return None
return value["latest_assistant_message"]

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Constrained recovery command: the sole ungated Mosaic mutator.
This is deliberately a thin client of the broker's recovery entrypoint. It
never accepts receipt text or a caller-provided challenge: the broker mints the
fresh challenge, delivers its exact receipt envelope, and later asks the
trusted ReceiptObserver seam to observe that same pending cycle.
"""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Final
# The out-of-process recovery command is intentionally runnable with `python
# -I`; locate its shipped construction module without caller-controlled paths.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from normative_fragments import build_payload_from_wire
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
if len(payload) > MAX_FRAME:
raise ValueError("recovery request too large")
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(socket_path))
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def load_construction(path: Path) -> dict[str, object]:
raw = path.read_bytes()
if len(raw) > MAX_FRAME:
raise ValueError("construction exceeds broker frame limit")
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("construction must be an object")
return value
def identity(environ: Mapping[str, str]) -> tuple[Path, str, int, str]:
socket_path = Path(environ["MOSAIC_LEASE_BROKER_SOCKET"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
generation = int(environ["MOSAIC_RUNTIME_GENERATION"])
runtime = environ["MOSAIC_LEASE_RUNTIME"]
if generation < 0 or runtime not in {"claude", "pi"}:
raise ValueError("invalid runtime identity")
return socket_path, session_id, generation, runtime
def begin(
construction_path: Path,
compaction_epoch: int,
request_epoch: int,
environ: Mapping[str, str],
) -> dict[str, object]:
if compaction_epoch < 0 or request_epoch < 0:
raise ValueError("epochs must be non-negative")
construction = load_construction(construction_path)
# Invoke the shared WI-5 construction before asking the broker to repeat
# its authoritative admission/build. No digest or receipt enters via CLI.
built = build_payload_from_wire(construction)
if (
built.injectionDecision != "ACCEPTED"
or not built.promotion
or not isinstance(built.h_source, str)
or not isinstance(built.h_payload, str)
):
raise ValueError("payload construction refused")
socket_path, session_id, generation, runtime = identity(environ)
return broker_request(socket_path, {
"action": "begin_recovery",
"session_id": session_id,
"runtime_generation": generation,
"runtime": runtime,
"binding": {
"compaction_epoch": compaction_epoch,
"request_epoch": request_epoch,
"h_source": built.h_source,
"h_payload": built.h_payload,
"schema_version": 1,
},
"construction": construction,
})
def complete(environ: Mapping[str, str]) -> dict[str, object]:
socket_path, session_id, generation, _runtime = identity(environ)
# No receipt or challenge argument exists: recovery completion can only use
# the broker's current recovery cycle and its trusted observer seam.
return broker_request(socket_path, {
"action": "complete_recovery",
"session_id": session_id,
"runtime_generation": generation,
})
def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
subcommands = parser.add_subparsers(dest="phase", required=True)
begin_parser = subcommands.add_parser("begin", help="mint and deliver a fresh recovery receipt")
begin_parser.add_argument("--construction", required=True, type=Path)
begin_parser.add_argument("--compaction-epoch", required=True, type=int)
begin_parser.add_argument("--request-epoch", required=True, type=int)
subcommands.add_parser("complete", help="observe and promote only the current recovery receipt")
arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ
try:
reply = (
begin(
arguments.construction,
arguments.compaction_epoch,
arguments.request_epoch,
source_environment,
)
if arguments.phase == "begin"
else complete(source_environment)
)
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic constrained recovery refused: {error}", file=sys.stderr)
return 2
print(json.dumps(reply, separators=(",", ":")))
return 0 if reply.get("ok") is True else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Revoke a runtime lease from a compaction or lifecycle observer."""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Final
from lease_generation import bump_runtime_generation, read_runtime_generation
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
if len(payload) > MAX_FRAME:
raise ValueError("invalid revoke request")
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(socket_path))
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def main(
argv: Sequence[str] | None = None,
*,
environ: Mapping[str, str] | None = None,
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("--reason", required=True)
parser.add_argument("--bump-generation", action="store_true")
arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ
try:
if not arguments.reason or len(arguments.reason) > 128:
raise ValueError("invalid revoke reason")
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
if (
len(session_id) != 64
or any(character not in "0123456789abcdef" for character in session_id)
):
raise ValueError("invalid broker session")
generation = (
bump_runtime_generation(source_environment)
if arguments.bump_generation
else read_runtime_generation(source_environment)
)
reply = request(
socket_path,
{
"action": "revoke_lease",
"session_id": session_id,
"runtime_generation": generation,
"reason": arguments.reason,
"runtime": arguments.runtime,
},
)
if reply.get("ok") is not True or reply.get("state") != "UNVERIFIED":
raise ValueError("revocation refused")
except (KeyError, ValueError, OSError, json.JSONDecodeError):
# A fired observer must remain fail-closed even if the broker transport
# is unavailable. Advancing the private local generation fences every
# later tool check; the broker revokes the old lease when it next sees
# that higher generation. Explicit rollover already advanced it above.
if not arguments.bump_generation:
try:
bump_runtime_generation(source_environment)
except (KeyError, ValueError, OSError):
pass
print("Mosaic lease revocation failed; lifecycle transition denied.", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -50,6 +50,21 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
exit 0 exit 0
fi fi
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
# freshly created git worktree (pnpm workspaces do NOT share node_modules
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
# Fail legibly here instead, before that raw error has a chance to surface.
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
echo "deps not installed — run pnpm install" >&2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
exit 1
fi
# Call the main QA handler with extracted parameters # Call the main QA handler with extracted parameters
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
# share node_modules across worktrees). Before the fix, the gate-entry seam
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
# MultiEdit in runtime/claude/settings.json) silently let a raw
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
# indistinguishable from a real test/lint failure (false-red).
#
# Asserts:
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
# at all produces the raw "not found" for a gate binary — this is the
# defect the fix prevents from reaching the operator un-annotated.
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
# the legible sentinel "deps not installed — run pnpm install" instead
# of silently proceeding (exit 0) into a would-be raw not-found.
# 3. With node_modules/.bin present but empty, same legible-sentinel
# behavior (covers `git worktree add` immediately followed by an
# as-yet-incomplete/interrupted install).
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
# proceeds normally (exit 0) — the preflight does not false-positive.
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail=0
fail_msg() {
echo "FAIL: $*" >&2
fail=1
}
run_hook() {
local file_path="$1"
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
}
make_fixture_repo() {
local dir="$1"
mkdir -p "$dir"
git -C "$dir" init -q .
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
}
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
# node_modules/.bin is entirely absent (this is what the preflight now
# intercepts before any gate command runs).
RED_DIR="$TMP_DIR/red-fixture"
make_fixture_repo "$RED_DIR"
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
case "$RED_OUTPUT" in
*"not found"*) ;;
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
esac
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
MISSING_DIR="$TMP_DIR/missing-bin"
make_fixture_repo "$MISSING_DIR"
echo "console.log(1)" > "$MISSING_DIR/x.ts"
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
case "$OUTPUT" in
*"deps not installed"*"pnpm install"*) ;;
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
esac
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
EMPTY_DIR="$TMP_DIR/empty-bin"
make_fixture_repo "$EMPTY_DIR"
mkdir -p "$EMPTY_DIR/node_modules/.bin"
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
case "$OUTPUT" in
*"deps not installed"*"pnpm install"*) ;;
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
esac
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
OK_DIR="$TMP_DIR/installed-bin"
make_fixture_repo "$OK_DIR"
mkdir -p "$OK_DIR/node_modules/.bin"
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
chmod +x "$OK_DIR/node_modules/.bin/tsc"
echo "console.log(1)" > "$OK_DIR/x.ts"
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
case "$OUTPUT" in
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
*) ;;
esac
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
# skipped before the deps check, regardless of node_modules state).
NONJS_DIR="$TMP_DIR/nonjs"
make_fixture_repo "$NONJS_DIR"
echo "# doc" > "$NONJS_DIR/README.md"
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
case "$OUTPUT" in
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
*) ;;
esac
if [[ "$fail" -eq 0 ]]; then
echo "deps-preflight regression passed (5/5 scenarios)"
fi
exit "$fail"

View File

@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh" "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",

View File

@@ -601,15 +601,23 @@ describe('ensureClaudexMutatorGateSettings', () => {
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as { const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
preserved: boolean; preserved: boolean;
hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> }; hooks: Record<string, Array<{ matcher: string; hooks: Array<{ command: string }> }>>;
}; };
const commands = settings.hooks.PreToolUse.flatMap((entry) => const commands = settings.hooks['PreToolUse']!.flatMap((entry) =>
entry.hooks.map((hook) => hook.command), entry.hooks.map((hook) => hook.command),
); );
expect(settings.preserved).toBe(true); expect(settings.preserved).toBe(true);
expect(commands).toContain( expect(commands).toContain(
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
); );
expect(
settings.hooks['PreCompact']?.some((entry) =>
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
),
).toBe(true);
expect(settings.hooks['SessionStart']?.map((entry) => entry.matcher)).toEqual(
expect.arrayContaining(['compact', 'resume|clear']),
);
expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600); expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600);
} finally { } finally {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });

View File

@@ -287,17 +287,19 @@ export function buildClaudexEnv(
const CLAUDEX_MUTATOR_GATE_COMMAND = const CLAUDEX_MUTATOR_GATE_COMMAND =
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude'; 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude';
const CLAUDEX_PRE_COMPACT_COMMAND =
'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason pre-compact';
const CLAUDEX_POST_COMPACT_COMMAND =
'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason session-start-compact';
const CLAUDEX_ROLLOVER_COMMAND =
'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason session-start-rollover --bump-generation';
const CLAUDEX_MUTATOR_GATE_HOOK = { const CLAUDEX_MANDATORY_LEASE_HOOKS = [
matcher: '.*', { event: 'PreToolUse', matcher: '.*', command: CLAUDEX_MUTATOR_GATE_COMMAND },
hooks: [ { event: 'PreCompact', matcher: '.*', command: CLAUDEX_PRE_COMPACT_COMMAND },
{ { event: 'SessionStart', matcher: 'compact', command: CLAUDEX_POST_COMPACT_COMMAND },
type: 'command', { event: 'SessionStart', matcher: 'resume|clear', command: CLAUDEX_ROLLOVER_COMMAND },
command: CLAUDEX_MUTATOR_GATE_COMMAND, ] as const;
timeout: 3,
},
],
};
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -331,26 +333,31 @@ export function ensureClaudexMutatorGateSettings(configDir: string): void {
throw new Error('claudex: isolated settings hooks must be an object (fail closed).'); throw new Error('claudex: isolated settings hooks must be an object (fail closed).');
} }
const hooks = hooksValue ?? {}; const hooks = hooksValue ?? {};
const preToolUseValue = hooks['PreToolUse']; for (const mandatory of CLAUDEX_MANDATORY_LEASE_HOOKS) {
if (preToolUseValue !== undefined && !Array.isArray(preToolUseValue)) { const eventValue = hooks[mandatory.event];
throw new Error('claudex: isolated PreToolUse hooks must be an array (fail closed).'); if (eventValue !== undefined && !Array.isArray(eventValue)) {
throw new Error(`claudex: isolated ${mandatory.event} hooks must be an array (fail closed).`);
} }
const preToolUse = preToolUseValue ?? []; const eventHooks = eventValue ?? [];
const gatePresent = preToolUse.some( const hookPresent = eventHooks.some(
(entry) => (entry) =>
isRecord(entry) && isRecord(entry) &&
entry['matcher'] === '.*' && entry['matcher'] === mandatory.matcher &&
Array.isArray(entry['hooks']) && Array.isArray(entry['hooks']) &&
entry['hooks'].some( entry['hooks'].some(
(hook) => (hook) =>
isRecord(hook) && isRecord(hook) && hook['type'] === 'command' && hook['command'] === mandatory.command,
hook['type'] === 'command' &&
hook['command'] === CLAUDEX_MUTATOR_GATE_COMMAND,
), ),
); );
if (!gatePresent) preToolUse.unshift(CLAUDEX_MUTATOR_GATE_HOOK); if (!hookPresent) {
eventHooks.unshift({
matcher: mandatory.matcher,
hooks: [{ type: 'command', command: mandatory.command, timeout: 3 }],
});
}
hooks[mandatory.event] = eventHooks;
}
hooks['PreToolUse'] = preToolUse;
settings['hooks'] = hooks; settings['hooks'] = hooks;
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
chmodSync(settingsPath, 0o600); chmodSync(settingsPath, 0o600);

View File

@@ -8,6 +8,7 @@ import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readlinkSync, readlinkSync,
readFileSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
@@ -26,6 +27,9 @@ import {
const LEGACY_SYNC_SCRIPT = fileURLToPath( const LEGACY_SYNC_SCRIPT = fileURLToPath(
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url), new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
); );
const CONTEXT_REFRESH_SOURCE_SKILL = fileURLToPath(
new URL('../../framework/skills/mosaic-context-refresh/SKILL.md', import.meta.url),
);
describe('Claude skill bridge', () => { describe('Claude skill bridge', () => {
let root: string; let root: string;
@@ -360,6 +364,25 @@ describe('Claude skill bridge', () => {
}); });
describe('syncClaudeSkills', () => { describe('syncClaudeSkills', () => {
it('projects the durable context-refresh skill through the #824 bridge in a tmp root', () => {
expect(existsSync(CONTEXT_REFRESH_SOURCE_SKILL)).toBe(true);
const source = readFileSync(CONTEXT_REFRESH_SOURCE_SKILL, 'utf8');
expect(source).toContain('recover-context.py');
expect(source).toContain('middle drop is not represented as receipt-detectable');
const skillDir = createSkill('mosaic-context-refresh');
writeFileSync(join(skillDir, 'SKILL.md'), source);
const result = syncClaudeSkills(paths);
expect(result).toEqual({
registered: ['mosaic-context-refresh'],
repaired: [],
unchanged: [],
conflicts: [],
});
expectCorrectLink('mosaic-context-refresh');
});
it('generically creates every missing canonical link and repairs managed broken links', () => { it('generically creates every missing canonical link and repairs managed broken links', () => {
createSkill('added-after-setup'); createSkill('added-after-setup');
createSkill('another-new-skill'); createSkill('another-new-skill');

View File

@@ -0,0 +1,939 @@
import { readFile, readdir } from 'node:fs/promises';
import { dirname, extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseRosterV2, validateRosterV2Semantics } from './roster-v2.js';
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const repositoryRoot = resolve(packageRoot, '..', '..');
const fleetDocs = join(repositoryRoot, 'docs', 'fleet');
const frameworkFleet = join(packageRoot, 'framework', 'fleet');
const REQUIRED_FLEET_PAGES = [
'README.md',
'concepts/desired-vs-observed-state.md',
'concepts/identity-class-runtime.md',
'concepts/role-authority-and-leases.md',
'concepts/generated-env-launch-chain.md',
'reference/roster-v2.schema.json',
'reference/roster-v2-fields.md',
'reference/cli.md',
'reference/role-classes.md',
'reference/lifecycle-transitions.md',
'reference/status-and-drift.md',
'how-to/create-update-delete-agent.md',
'how-to/start-stop-restart.md',
'how-to/configure-tess-interaction.md',
'how-to/configure-ultron-validator.md',
'how-to/customize-roles.md',
'operations/reconcile-and-recover.md',
'operations/env-quarantine.md',
'operations/systemd-tmux-troubleshooting.md',
'operations/backup-restore.md',
'operations/upgrade-assets.md',
'migration/v1-to-v2.md',
'migration/example-profile-disposition.md',
'migration/legacy-class-aliases.md',
] as const;
async function markdownFiles(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const paths = await Promise.all(
entries.map(async (entry): Promise<string[]> => {
const path = join(root, entry.name);
if (entry.isDirectory()) return markdownFiles(path);
return extname(entry.name) === '.md' ? [path] : [];
}),
);
return paths.flat().sort();
}
function localMarkdownTargets(source: string): string[] {
const link =
/\[[^\]]*\]\(\s*(?:<([^>]+)>|((?:\\.|[^()\s]|\([^()]*\))+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
return [...source.matchAll(link)]
.map((match): string => match[1] ?? match[2] ?? '')
.filter(
(target): boolean =>
target !== '' &&
!target.startsWith('http://') &&
!target.startsWith('https://') &&
!target.startsWith('mailto:'),
);
}
function markdownHeadingAnchors(source: string): Set<string> {
const anchors = new Set<string>();
let fence: { readonly marker: string; readonly length: number } | undefined;
for (const line of source.split('\n')) {
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
if (fence === undefined && fenceMatch !== null) {
const run = fenceMatch[1] ?? '';
fence = { marker: run[0] ?? '', length: run.length };
continue;
}
if (fence !== undefined) {
const closingRun = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)?.[1];
if (
closingRun !== undefined &&
closingRun[0] === fence.marker &&
closingRun.length >= fence.length
) {
fence = undefined;
}
continue;
}
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)?.[1];
if (heading === undefined) continue;
const base = heading
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/<[^>]*>/g, '')
.replace(/[`*_~]/g, '')
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-');
let anchor = base;
let duplicate = 0;
while (anchors.has(anchor)) {
duplicate += 1;
anchor = `${base}-${duplicate}`;
}
anchors.add(anchor);
}
return anchors;
}
function markdownLinkViolations(
sourcePath: string,
source: string,
documents: Readonly<Record<string, string>>,
): string[] {
const violations: string[] = [];
for (const target of localMarkdownTargets(source)) {
const [encodedPath = '', encodedFragment] = target.split('#', 2);
const targetPath = decodeURIComponent(encodedPath);
const normalizedTarget = resolve('/', dirname(sourcePath), targetPath).slice(1);
const targetSource = documents[normalizedTarget];
if (targetSource === undefined) {
violations.push(`${sourcePath} -> ${target}: missing file`);
continue;
}
if (encodedFragment !== undefined) {
const fragment = decodeURIComponent(encodedFragment);
if (fragment === '' || !markdownHeadingAnchors(targetSource).has(fragment)) {
violations.push(`${sourcePath} -> ${target}: missing heading`);
}
}
}
return violations;
}
type CodeSurfaceCategory = 'ConcreteCommand' | 'Synopsis' | 'DataProfile' | 'InlineLiteral';
interface CodeSurface {
readonly category: CodeSurfaceCategory;
readonly path: string;
readonly line: number;
readonly block?: number;
readonly info?: string;
readonly source: string;
}
interface SurfaceDiagnostic {
readonly path: string;
readonly line: number;
readonly block?: number;
readonly category?: CodeSurfaceCategory;
readonly code: string;
}
interface FenceProfile {
readonly path: string;
readonly block: number;
readonly info: string;
readonly category: Exclude<CodeSurfaceCategory, 'InlineLiteral'>;
readonly recordSchemas?: readonly string[];
}
const FENCE_PROFILES = [
{
path: 'FLEET-LAUNCH.md',
block: 1,
info: 'dotenv',
category: 'DataProfile',
recordSchemas: ['DATA.DOTENV.FLEET_LAUNCH'],
},
{
path: 'TASKS.md',
block: 1,
info: 'text-table',
category: 'DataProfile',
recordSchemas: ['DATA.TEXT_TABLE.FLEET_TASKS'],
},
{
path: 'backlog-conventions.md',
block: 1,
info: 'text-diagram',
category: 'DataProfile',
recordSchemas: ['DATA.TEXT_DIAGRAM.BACKLOG_FLOW'],
},
{
path: 'backlog-conventions.md',
block: 2,
info: 'fleet-command',
category: 'ConcreteCommand',
recordSchemas: [
'CMD.BACKLOG.CREATE.1',
'CMD.BACKLOG.CREATE.2',
'CMD.BACKLOG.CLAIM',
'CMD.BACKLOG.COMPLETE',
'CMD.BACKLOG.LIST_READY',
'CMD.BACKLOG.RECLAIM',
],
},
{
path: 'f4-matrix-connector.md',
block: 1,
info: 'typescript',
category: 'DataProfile',
recordSchemas: ['DATA.TYPESCRIPT.MATRIX_CONNECTOR_TYPE'],
},
{
path: 'f4-matrix-connector.md',
block: 2,
info: 'yaml',
category: 'DataProfile',
recordSchemas: ['DATA.YAML.MATRIX_CONNECTOR_CONFIG'],
},
{
path: 'how-to/configure-tess-interaction.md',
block: 1,
info: 'yaml',
category: 'DataProfile',
recordSchemas: ['DATA.YAML.INTERACTION_AGENT'],
},
{
path: 'how-to/configure-ultron-validator.md',
block: 1,
info: 'yaml',
category: 'DataProfile',
recordSchemas: ['DATA.YAML.VALIDATOR_AGENT'],
},
{
path: 'how-to/create-update-delete-agent.md',
block: 1,
info: 'fleet-synopsis',
category: 'Synopsis',
recordSchemas: ['SYN.AGENT.GET', 'SYN.PLAN.CREATE', 'SYN.PLAN.UPDATE', 'SYN.PLAN.DELETE'],
},
{
path: 'how-to/create-update-delete-agent.md',
block: 2,
info: 'fleet-command',
category: 'ConcreteCommand',
recordSchemas: ['CMD.AGENT.CREATE_JSON'],
},
{
path: 'how-to/create-update-delete-agent.md',
block: 3,
info: 'fleet-synopsis',
category: 'Synopsis',
recordSchemas: ['SYN.AGENT.UPDATE_COMPLETE', 'SYN.AGENT.DELETE'],
},
{
path: 'how-to/create-update-delete-agent.md',
block: 4,
info: 'json',
category: 'DataProfile',
recordSchemas: ['DATA.JSON.PARTIAL_FAILURE'],
},
{
path: 'how-to/customize-roles.md',
block: 1,
info: 'markdown',
category: 'DataProfile',
recordSchemas: ['DATA.MARKDOWN.ROLE_TEMPLATE'],
},
{
path: 'how-to/customize-roles.md',
block: 2,
info: 'markdown',
category: 'DataProfile',
recordSchemas: ['DATA.MARKDOWN.ROLE_EXAMPLE'],
},
{
path: 'how-to/start-stop-restart.md',
block: 1,
info: 'fleet-synopsis',
category: 'Synopsis',
recordSchemas: [
'SYN.APPLY.DRY',
'SYN.APPLY',
'SYN.RECONCILE',
'SYN.START.REQUIRED',
'SYN.STOP.REQUIRED',
'SYN.RESTART.REQUIRED',
'SYN.STATUS.OPTIONAL',
'SYN.VERIFY',
'SYN.DOCTOR',
],
},
{
path: 'migration/example-profile-disposition.md',
block: 1,
info: 'fleet-command',
category: 'ConcreteCommand',
recordSchemas: ['CMD.PNPM.MIGRATION_TEST'],
},
{
path: 'migration/v1-to-v2.md',
block: 1,
info: 'fleet-command',
category: 'ConcreteCommand',
recordSchemas: ['CMD.MIGRATE.PREVIEW'],
},
{
path: 'migration/v1-to-v2.md',
block: 2,
info: 'json',
category: 'DataProfile',
recordSchemas: ['DATA.JSON.LIFECYCLE_OBSERVATIONS'],
},
{
path: 'operations/reconcile-and-recover.md',
block: 1,
info: 'json',
category: 'DataProfile',
recordSchemas: ['DATA.JSON.RECOVERY_RESULT'],
},
{
path: 'reference/agent-mutations.md',
block: 1,
info: 'fleet-synopsis',
category: 'Synopsis',
recordSchemas: [
'SYN.AGENT.GET',
'SYN.PLAN.GENERIC',
'SYN.AGENT.CREATE',
'SYN.AGENT.UPDATE',
'SYN.AGENT.DELETE_DRY',
],
},
{
path: 'reference/agent-mutations.md',
block: 2,
info: 'json',
category: 'DataProfile',
recordSchemas: ['DATA.JSON.MUTATION_RESULT'],
},
{
path: 'reference/cli.md',
block: 1,
info: 'fleet-synopsis',
category: 'Synopsis',
recordSchemas: [
'SYN.APPLY',
'SYN.RECONCILE',
'SYN.START.OPTIONAL',
'SYN.STOP.OPTIONAL',
'SYN.RESTART.OPTIONAL',
'SYN.STATUS.OPTIONAL',
'SYN.VERIFY',
'SYN.DOCTOR',
'SYN.MIGRATE.PREVIEW',
],
},
{
path: 'reference/generated-env-boundary.md',
block: 1,
info: 'dotenv',
category: 'DataProfile',
recordSchemas: ['DATA.DOTENV.GENERATED_ENV'],
},
{
path: 'reference/roster-v2-fields.md',
block: 1,
info: 'yaml',
category: 'DataProfile',
recordSchemas: ['DATA.YAML.ROSTER_FIELDS'],
},
] as const satisfies readonly FenceProfile[];
const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
'CMD.BACKLOG.CREATE.1': /^mosaic fleet backlog create --id A1 --title "schema" --priority 5$/,
'CMD.BACKLOG.CREATE.2':
/^mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9$/,
'CMD.BACKLOG.CLAIM': /^mosaic fleet backlog claim --owner worker-1 --ttl 600 --json$/,
'CMD.BACKLOG.COMPLETE': /^mosaic fleet backlog complete --id A1$/,
'CMD.BACKLOG.LIST_READY': /^mosaic fleet backlog list --ready-only --json$/,
'CMD.BACKLOG.RECLAIM': /^mosaic fleet backlog reclaim --json$/,
'CMD.AGENT.CREATE_JSON':
/^mosaic fleet create --expected-generation [1-9][0-9]* --agent '\{\n(?:[ -~]*\n)*\}'$/,
'CMD.MIGRATE.PREVIEW':
/^mosaic fleet migrate-v1 preview \\\n --source [A-Za-z0-9_./@:+,=-]+ \\\n --decisions [A-Za-z0-9_./@:+,=-]+ \\\n --observations [A-Za-z0-9_./@:+,=-]+$/,
'CMD.PNPM.MIGRATION_TEST':
/^pnpm --filter @mosaicstack\/mosaic test -- v1-v2-migration\.spec\.ts \\\n -t "[A-Za-z0-9 _./@:+,=-]+"$/,
};
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
'DATA.DOTENV.FLEET_LAUNCH':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.TEXT_TABLE.FLEET_TASKS':
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
' create\n \u2502\n \u25bc\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u25ba ready \u2500\u2500\u2500\u2500\u2500 claim \u2500\u2500\u2500\u2500\u2500\u25ba claimed \u2500\u2500\u2500\u2500\u2500 complete \u2500\u2500\u2500\u2500\u2500\u25ba done\n \u2502 \u2502 \u2502\n \u2502 block reclaim (TTL expiry or --id)\n \u2502 \u25bc \u2502\n \u2502 blocked \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 (back to ready)\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 (reclaim / re-create can return a card to ready)',
'DATA.TYPESCRIPT.MATRIX_CONNECTOR_TYPE':
"interface OrchestratorConnector {\n readonly kind: 'tmux' | 'discord' | 'matrix';\n send(message: OutboundMessage): Promise<SendResult>; // orchestrator \u2192 human\n subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human \u2192 orchestrator\n health(): Promise<ConnectorHealth>; // reachable + authenticated\n}",
'DATA.YAML.MATRIX_CONNECTOR_CONFIG':
"connector:\n kind: matrix\n matrix:\n homeserver_url: https://matrix.example.internal\n user_id: '@mos:example.internal'\n room_id: '!abc:example.internal'",
'DATA.YAML.INTERACTION_AGENT':
'name: interaction-example\nalias: Interaction Example\nclass: interaction\ntool_policy: interaction\nlifecycle:\n enabled: true\n desired_state: stopped',
'DATA.YAML.VALIDATOR_AGENT':
'name: validator-example\nalias: Validator Example\nclass: validator\ntool_policy: validator\nlifecycle:\n enabled: true\n desired_state: stopped',
'DATA.JSON.PARTIAL_FAILURE':
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
'DATA.MARKDOWN.ROLE_TEMPLATE':
"# Code \u2014 local role definition\n\nThe local code role (`class: code`) follows the operator's repository conventions.",
'DATA.MARKDOWN.ROLE_EXAMPLE':
'# Release notes \u2014 local role definition\n\nThe release-notes role (`class: release-notes`) prepares operator-reviewed release copy.',
'DATA.JSON.LIFECYCLE_OBSERVATIONS':
'{\n "coder0": { "systemd": "inactive", "tmux": "missing" }\n}',
'DATA.JSON.RECOVERY_RESULT':
'{\n "applied": false,\n "authoritativeRoster": "unchanged",\n "projections": "incomplete",\n "lifecycle": "not-applied",\n "recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }\n}',
'DATA.JSON.MUTATION_RESULT':
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
'DATA.DOTENV.GENERATED_ENV':
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
'DATA.YAML.ROSTER_FIELDS':
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
};
const SYNOPSIS_RECORDS: Readonly<Record<string, string>> = {
'SYN.AGENT.GET': 'mosaic fleet get <name>',
'SYN.PLAN.CREATE': "mosaic fleet plan create --expected-generation <n> --agent '<json>'",
'SYN.PLAN.UPDATE': "mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'",
'SYN.PLAN.DELETE': 'mosaic fleet plan delete <name> --expected-generation <n>',
'SYN.AGENT.UPDATE_COMPLETE':
"mosaic fleet update <name> --expected-generation <n> --agent '<complete JSON agent payload>'",
'SYN.AGENT.DELETE': 'mosaic fleet delete <name> --expected-generation <n>',
'SYN.PLAN.GENERIC':
"mosaic fleet plan <create|update|delete> [<name>] --expected-generation <n> [--agent '<json>'] [--persisted-start]",
'SYN.AGENT.CREATE':
"mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]",
'SYN.AGENT.UPDATE':
"mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]",
'SYN.AGENT.DELETE_DRY': 'mosaic fleet delete <name> --expected-generation <n> [--dry-run]',
'SYN.APPLY.DRY': 'mosaic fleet apply --expected-generation <n> --dry-run',
'SYN.APPLY': 'mosaic fleet apply --expected-generation <n>',
'SYN.RECONCILE': 'mosaic fleet reconcile --expected-generation <n>',
'SYN.START.REQUIRED': 'mosaic fleet start <name> --expected-generation <n>',
'SYN.STOP.REQUIRED': 'mosaic fleet stop <name> --expected-generation <n>',
'SYN.RESTART.REQUIRED': 'mosaic fleet restart <name> --expected-generation <n>',
'SYN.START.OPTIONAL': 'mosaic fleet start [<name>] --expected-generation <n> [--dry-run]',
'SYN.STOP.OPTIONAL': 'mosaic fleet stop [<name>] --expected-generation <n> [--dry-run]',
'SYN.RESTART.OPTIONAL': 'mosaic fleet restart [<name>] --expected-generation <n> [--dry-run]',
'SYN.STATUS.OPTIONAL': 'mosaic fleet status [<name>]',
'SYN.VERIFY': 'mosaic fleet verify',
'SYN.DOCTOR': 'mosaic fleet doctor',
'SYN.MIGRATE.PREVIEW':
'mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>',
};
function profileFor(path: string, block: number): FenceProfile | undefined {
return FENCE_PROFILES.find(
(profile): boolean => profile.path === path && profile.block === block,
);
}
function publicDiagnostic(
surface: Pick<CodeSurface, 'path' | 'line' | 'block' | 'category'>,
code: string,
): SurfaceDiagnostic {
return {
path: surface.path,
line: surface.line,
...(surface.block === undefined ? {} : { block: surface.block }),
...(surface.category === undefined ? {} : { category: surface.category }),
code,
};
}
function codeSurfaceDiagnostics(path: string, source: string): SurfaceDiagnostic[] {
const diagnostics: SurfaceDiagnostic[] = [];
let inFence = false;
let block = 0;
for (const [index, line] of source.split('\n').entries()) {
const lineNumber = index + 1;
if (inFence) {
if (line === '```') inFence = false;
else if (/^(?:`{3,}|~{3,})/.test(line)) {
diagnostics.push({ path, line: lineNumber, block, code: 'fence-conflict' });
}
continue;
}
if (/^(?: {0,3}(?:(?:> ?)|(?:(?:[-+*]|[0-9]{1,9}[.)]) +)))+(`{3,}|~{3,})/.test(line)) {
diagnostics.push({ path, line: lineNumber, code: 'fence-context' });
continue;
}
if (/^(?: {4,}|\t).*\S/.test(line)) {
diagnostics.push({ path, line: lineNumber, code: 'indented-code' });
continue;
}
const marker = line.match(/^(`{3,}|~{3,})(.*)$/);
if (marker !== null) {
block += 1;
const info = marker[2] ?? '';
if (marker[1] !== '```' || /[`~]/.test(info)) {
diagnostics.push({ path, line: lineNumber, block, code: 'fence-marker' });
continue;
}
if (info === '' || !FENCE_PROFILES.some((profile): boolean => profile.info === info)) {
diagnostics.push({ path, line: lineNumber, block, code: 'fence-info' });
continue;
}
const profile = profileFor(path, block);
if (profile === undefined || profile.info !== info) {
diagnostics.push({ path, line: lineNumber, block, code: 'profile-unknown' });
continue;
}
inFence = true;
continue;
}
if (/`{2,}/.test(line)) {
diagnostics.push({ path, line: lineNumber, code: 'inline-delimiter' });
continue;
}
const withoutClosedInlineSpans = line.replace(/`[^`\n]+`/g, '');
if (withoutClosedInlineSpans.includes('`')) {
diagnostics.push({ path, line: lineNumber, code: 'inline-delimiter' });
continue;
}
if (/<\/?(?:pre|code|script|style|xmp|listing)(?=$|\s|[>/])/i.test(line)) {
diagnostics.push({ path, line: lineNumber, code: 'raw-code-container' });
}
for (const match of line.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
if (!/^[A-Za-z0-9_./@:+,=-]{1,256}$/.test(match[1] ?? '')) {
diagnostics.push({
path,
line: lineNumber,
category: 'InlineLiteral',
code: 'inline-literal',
});
}
}
}
if (inFence)
diagnostics.push({ path, line: source.split('\n').length, block, code: 'fence-unclosed' });
return diagnostics;
}
function codeSurfaces(path: string, source: string): CodeSurface[] {
const diagnostics = codeSurfaceDiagnostics(path, source);
if (diagnostics.length > 0) return [];
const surfaces: CodeSurface[] = [];
const lines = source.split('\n');
let fence:
| {
readonly block: number;
readonly line: number;
readonly info: string;
readonly body: string[];
}
| undefined;
let block = 0;
for (const [index, line] of lines.entries()) {
const lineNumber = index + 1;
if (fence !== undefined) {
if (line === '```') {
const profile = profileFor(path, fence.block);
if (profile !== undefined && profile.info === fence.info) {
surfaces.push({
category: profile.category,
path,
line: fence.line,
block: fence.block,
info: fence.info,
source: fence.body.join('\n'),
});
}
fence = undefined;
} else fence.body.push(line);
continue;
}
const opener = line.match(/^```([a-z-]+)$/);
if (opener !== null) {
block += 1;
fence = { block, line: lineNumber, info: opener[1] ?? '', body: [] };
continue;
}
for (const match of line.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
surfaces.push({
category: 'InlineLiteral',
path,
line: lineNumber,
source: match[1] ?? '',
});
}
}
return surfaces;
}
function closedGrammarViolationKinds(surface: CodeSurface): string[] {
const kinds = new Set<string>();
const credentialFormat =
/(?:\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{35}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b|\bglpat-[A-Za-z0-9_-]{20,}\b|\bnpm_[A-Za-z0-9]{20,}\b|\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}\b|\bsk-proj-[A-Za-z0-9_-]{20,}\b|\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bxox[baprs]-[A-Za-z0-9-]{10,}\b|\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:]+:[^\s/@]+@)/;
if (credentialFormat.test(surface.source)) kinds.add('credential-format');
if (/\b(?:Tess|Ultron)\b/.test(surface.source)) kinds.add('identity');
if (surface.category === 'InlineLiteral') {
if (!/^[A-Za-z0-9_./@:+,=-]{1,256}$/.test(surface.source)) kinds.add('inline-literal');
return [...kinds].sort();
}
const profile = profileFor(surface.path, surface.block ?? 0);
if (
profile === undefined ||
profile.info !== surface.info ||
profile.category !== surface.category
) {
kinds.add('profile-unknown');
return [...kinds].sort();
}
if (surface.category === 'DataProfile') {
const schema = profile.recordSchemas?.[0] ?? '';
if (profile.recordSchemas?.length !== 1 || surface.source !== DATA_PROFILE_BODIES[schema]) {
kinds.add('data-profile');
}
return [...kinds].sort();
}
if (/^(?:# |\$ |> )/m.test(surface.source)) kinds.add('comment-or-prompt');
if (/(?:^|\s)[A-Za-z_][A-Za-z0-9_]*\+?=[^\s]*/.test(surface.source)) kinds.add('assignment');
const schemas = profile.recordSchemas ?? [];
if (surface.category === 'Synopsis') {
const records = surface.source.split('\n').filter((record): boolean => record !== '');
if (
records.length !== schemas.length ||
records.some((record, index): boolean => record !== SYNOPSIS_RECORDS[schemas[index] ?? ''])
) {
kinds.add('synopsis-schema');
}
} else if (schemas.length === 1) {
if (!COMMAND_RECORDS[schemas[0] ?? '']?.test(surface.source)) kinds.add('command-schema');
} else {
const records = surface.source.split('\n').filter((record): boolean => record !== '');
if (
records.length !== schemas.length ||
records.some((record, index): boolean => !COMMAND_RECORDS[schemas[index] ?? '']?.test(record))
) {
kinds.add('command-schema');
}
}
return [...kinds].sort();
}
function surfaceDiagnostics(surfaces: readonly CodeSurface[]): SurfaceDiagnostic[] {
return surfaces.flatMap((surface): SurfaceDiagnostic[] =>
closedGrammarViolationKinds(surface).map(
(kind): SurfaceDiagnostic => publicDiagnostic(surface, kind),
),
);
}
const CLOSED_GRAMMAR_REJECTION_FIXTURES = [
'sudo systemctl restart example',
'env -S apt-get install example',
"sh -c 'apt-get --version'",
'su root',
'command -- apt-get install example',
'mosaic fleet verify; reboot',
'# mosaic fleet verify',
'$ mosaic fleet verify',
'FOO=value mosaic fleet verify',
] as const;
describe('closed documentation publication grammar', (): void => {
it('classifies only the four approved code-surface categories', (): void => {
const categories: readonly CodeSurfaceCategory[] = [
'ConcreteCommand',
'Synopsis',
'DataProfile',
'InlineLiteral',
];
expect(new Set(categories)).toEqual(
new Set(['ConcreteCommand', 'Synopsis', 'DataProfile', 'InlineLiteral']),
);
});
it.each(CLOSED_GRAMMAR_REJECTION_FIXTURES)(
'rejects shell-shaped input without parsing shell grammar',
(source): void => {
const surface: CodeSurface = {
category: 'ConcreteCommand',
path: 'migration/v1-to-v2.md',
line: 1,
block: 1,
info: 'fleet-command',
source,
};
expect(closedGrammarViolationKinds(surface)).toContain('command-schema');
},
);
it('rejects DSL comments, prompt prefixes, assignments, and root-prompt ambiguity', (): void => {
for (const source of [
'# mosaic fleet verify',
'$ mosaic fleet verify',
'> mosaic fleet verify',
'ROOT=1 mosaic fleet verify',
]) {
const surface: CodeSurface = {
category: 'Synopsis',
path: 'reference/cli.md',
line: 1,
block: 1,
info: 'fleet-synopsis',
source,
};
expect(closedGrammarViolationKinds(surface)).not.toEqual([]);
}
});
it('rejects unmatched single-backtick delimiters on either side', (): void => {
for (const source of ['`literal', 'literal`', 'text `literal', 'literal` text']) {
expect(codeSurfaceDiagnostics('fixture.md', source)).toContainEqual({
path: 'fixture.md',
line: 1,
code: 'inline-delimiter',
});
}
});
it('counts every invalid column-one fence candidate before later profile selection', (): void => {
for (const firstCandidate of [
'````fleet-synopsis',
'```fleet-synopsis```',
'~~~fleet-synopsis~~~',
]) {
expect(
codeSurfaceDiagnostics(
'reference/cli.md',
`${firstCandidate}\n\`\`\`unknown\n\`\`\`fleet-synopsis\nmosaic fleet verify\n\`\`\``,
),
).toEqual([
{ path: 'reference/cli.md', line: 1, block: 1, code: 'fence-marker' },
{ path: 'reference/cli.md', line: 2, block: 2, code: 'fence-info' },
{ path: 'reference/cli.md', line: 3, block: 3, code: 'profile-unknown' },
{ path: 'reference/cli.md', line: 5, block: 4, code: 'fence-info' },
]);
}
});
it('rejects inline delimiter runs instead of silently omitting them', (): void => {
for (const source of ['``literal``', 'text ```literal```', 'text ``literal`` text']) {
expect(codeSurfaceDiagnostics('fixture.md', source)).toContainEqual({
path: 'fixture.md',
line: 1,
code: 'inline-delimiter',
});
}
});
it('rejects every nonblank line with four leading spaces or a leading tab', (): void => {
for (const source of [
' mosaic fleet verify',
' mosaic fleet verify',
' \tmosaic fleet verify',
'\t mosaic fleet verify',
]) {
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
{ path: 'fixture.md', line: 1, code: 'indented-code' },
]);
}
});
it('rejects raw HTML code-container tag prefixes at every boundary', (): void => {
for (const source of [
'<pre',
'<pre ',
'<pre>',
'<pre/',
'<code',
'<code ',
'<code>',
'<code/',
]) {
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
{ path: 'fixture.md', line: 1, code: 'raw-code-container' },
]);
}
expect(codeSurfaceDiagnostics('fixture.md', '<prelude>prose</prelude>')).toEqual([]);
});
it('rejects nested blockquote and list fence contexts', (): void => {
for (const source of [
'>> ```fleet-command',
'> > ```fleet-command',
'> - ```fleet-command',
'- > ```fleet-command',
]) {
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
{ path: 'fixture.md', line: 1, code: 'fence-context' },
]);
}
});
it('requires optional metavariables to use bracketed angle notation', (): void => {
const accepted: CodeSurface = {
category: 'Synopsis',
path: 'reference/cli.md',
line: 1,
block: 1,
info: 'fleet-synopsis',
source: Object.values(SYNOPSIS_RECORDS)
.filter((record): boolean =>
[
'SYN.APPLY',
'SYN.RECONCILE',
'SYN.START.OPTIONAL',
'SYN.STOP.OPTIONAL',
'SYN.RESTART.OPTIONAL',
'SYN.STATUS.OPTIONAL',
'SYN.VERIFY',
'SYN.DOCTOR',
'SYN.MIGRATE.PREVIEW',
].includes(
Object.entries(SYNOPSIS_RECORDS).find(([, value]): boolean => value === record)?.[0] ??
'',
),
)
.join('\n'),
};
expect(closedGrammarViolationKinds(accepted)).toEqual([]);
expect(
closedGrammarViolationKinds({
...accepted,
source: accepted.source.replace('[<name>]', '[name]'),
}),
).toContain('synopsis-schema');
});
it('emits only location, category, and closed diagnostic codes', (): void => {
const sensitiveFixture = ['sk-ant-api03-', 'a'.repeat(80)].join('');
const diagnostic = surfaceDiagnostics([
{
category: 'InlineLiteral',
path: 'fixture.md',
line: 7,
source: sensitiveFixture,
},
]);
expect(diagnostic).toEqual([
{
path: 'fixture.md',
line: 7,
category: 'InlineLiteral',
code: 'credential-format',
},
]);
expect(JSON.stringify(diagnostic)).not.toContain(sensitiveFixture);
});
});
describe('fleet operator documentation', (): void => {
it('ships every accepted information-architecture page', async (): Promise<void> => {
await expect(
Promise.all(REQUIRED_FLEET_PAGES.map((path) => readFile(join(fleetDocs, path), 'utf8'))),
).resolves.toHaveLength(REQUIRED_FLEET_PAGES.length);
});
it('resolves every local Markdown link and heading fragment in the fleet book and sitemap', async (): Promise<void> => {
const files = [...(await markdownFiles(fleetDocs)), join(repositoryRoot, 'docs', 'SITEMAP.md')];
const documents: Record<string, string> = {};
for (const file of files) {
const relative = file.slice(repositoryRoot.length + 1);
documents[relative] = await readFile(file, 'utf8');
}
const violations: string[] = [];
for (const [sourcePath, source] of Object.entries(documents)) {
for (const target of localMarkdownTargets(source)) {
const encodedPath = target.split('#', 1)[0] ?? '';
const targetPath = resolve(
dirname(join(repositoryRoot, sourcePath)),
decodeURIComponent(encodedPath),
);
const relativeTarget = targetPath.slice(repositoryRoot.length + 1);
if (documents[relativeTarget] === undefined) {
try {
documents[relativeTarget] = await readFile(targetPath, 'utf8');
} catch {
// The deterministic validator below records the missing target without exposing content.
}
}
}
violations.push(...markdownLinkViolations(sourcePath, source, documents));
}
expect(violations).toEqual([]);
});
it('validates the canonical documentation example through the production compiler and resolver', async (): Promise<void> => {
const source = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
const roster = parseRosterV2(source, 'yaml');
const validated = await validateRosterV2Semantics(roster, {
rolesDir: join(frameworkFleet, 'roles'),
overrideDir: join(fleetDocs, 'examples', 'roles.local'),
});
expect(validated.generation).toBe(1);
expect(validated.agents.map((agent) => agent.canonicalClass)).toEqual([
'code',
'interaction',
'validator',
]);
});
it('keeps every rendered fleet code surface in one closed category without exposing sensitive values', async (): Promise<void> => {
const diagnostics: SurfaceDiagnostic[] = [];
const surfaces: CodeSurface[] = [];
for (const file of await markdownFiles(fleetDocs)) {
const source = await readFile(file, 'utf8');
const relative = file.slice(fleetDocs.length + 1);
diagnostics.push(...codeSurfaceDiagnostics(relative, source));
surfaces.push(...codeSurfaces(relative, source));
}
diagnostics.push(...surfaceDiagnostics(surfaces));
expect(diagnostics).toEqual([]);
expect(
surfaces.filter((surface): boolean => surface.category === 'ConcreteCommand'),
).toHaveLength(4);
expect(surfaces.filter((surface): boolean => surface.category === 'Synopsis')).toHaveLength(5);
expect(surfaces.filter((surface): boolean => surface.category === 'DataProfile')).toHaveLength(
15,
);
expect(
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
).toHaveLength(858);
expect(surfaces).toHaveLength(882);
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
const auxiliary: CodeSurface = {
category: 'DataProfile',
path: 'examples/roster-v2.yaml',
line: 1,
source: rosterSource,
};
expect(
closedGrammarViolationKinds(auxiliary).filter((kind): boolean => kind !== 'profile-unknown'),
).toEqual([]);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,7 @@ const PROBE_PATHS = [
'install.sh', 'install.sh',
'framework-manifest.txt', 'framework-manifest.txt',
'guides/E2E-DELIVERY.md', 'guides/E2E-DELIVERY.md',
'skills/mosaic-context-refresh/SKILL.md',
'tools/git/pr-create.sh', 'tools/git/pr-create.sh',
'tools/_lib/manifest.sh', 'tools/_lib/manifest.sh',
'defaults/SOUL.md', 'defaults/SOUL.md',
@@ -97,6 +98,7 @@ const PROBE_PATHS = [
'memory/note.md', 'memory/note.md',
'sources/skills/x.md', 'sources/skills/x.md',
'credentials/c.json', 'credentials/c.json',
'skills-local/custom/SKILL.md',
'tools/_lib/credentials.json', 'tools/_lib/credentials.json',
'fleet/roster.yaml', 'fleet/roster.yaml',
'fleet/roster.json', 'fleet/roster.json',

View File

@@ -304,6 +304,11 @@ describe('manifest completeness against shipped framework tree', () => {
expect(misclassified).toEqual([]); expect(misclassified).toEqual([]);
}); });
it('shipped canonical skills are framework-owned while local skills are operator-owned', () => {
expect(resolveOwnership(manifest, 'skills/mosaic-context-refresh/SKILL.md')).toBe('framework');
expect(resolveOwnership(manifest, 'skills-local/custom/SKILL.md')).toBe('operator');
});
it('the operator-owned surface from #791 resolves to operator', () => { it('the operator-owned surface from #791 resolves to operator', () => {
const operatorPaths = [ const operatorPaths = [
'agents/coder0.conf', 'agents/coder0.conf',
@@ -313,6 +318,7 @@ describe('manifest completeness against shipped framework tree', () => {
'SOUL.local.md', 'SOUL.local.md',
'USER.local.md', 'USER.local.md',
'STANDARDS.local.md', 'STANDARDS.local.md',
'skills-local/custom/SKILL.md',
'tools/_lib/credentials.json', 'tools/_lib/credentials.json',
'fleet/roster.yaml', 'fleet/roster.yaml',
'fleet/roster.json', 'fleet/roster.json',

View File

@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { chmod, writeFile } from 'node:fs/promises';
import { createConnection, type Socket } from 'node:net'; import { createConnection, type Socket } from 'node:net';
const DEFAULT_TIMEOUT_MS = 3_000; const DEFAULT_TIMEOUT_MS = 3_000;
@@ -185,3 +186,51 @@ export async function requestBrokerReply<T extends object>(
options, options,
); );
} }
export interface ReceiptChallengeCycle {
sessionId: string;
runtimeGeneration: number;
receiptChallenge: string;
receipt: string;
}
export interface ReceiptChallengeReply {
ok: boolean;
code?: string;
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'PENDING_PROMOTION' | 'VERIFIED';
}
/**
* Complete the shipped begin -> trusted-observer -> consume -> promote path.
* The private fixture is read by the daemon's injected test observer; the
* observation request itself never carries assistant-message content.
*/
export async function observeAndPromoteReceiptChallenge(
socketPath: string,
observerFixturePath: string,
cycle: ReceiptChallengeCycle,
): Promise<ReceiptChallengeReply> {
await writeFile(
observerFixturePath,
`${JSON.stringify({
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
latest_assistant_message: cycle.receipt,
})}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
await chmod(observerFixturePath, 0o600);
const observed = await requestBrokerReply<ReceiptChallengeReply>(socketPath, {
action: 'observe_receipt',
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
receipt_challenge: cycle.receiptChallenge,
});
if (observed.ok !== true || observed.state !== 'PENDING_PROMOTION') return observed;
return await requestBrokerReply<ReceiptChallengeReply>(socketPath, {
action: 'promote_lease',
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
receipt_challenge: cycle.receiptChallenge,
});
}

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""RED-first WI-6 contracts against the shipped constrained recovery entrypoint."""
from __future__ import annotations
import base64
import hashlib
import importlib.util
import os
import tempfile
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
DAEMON_PATH = TOOLS / "daemon.py"
FRAGMENTS_PATH = TOOLS / "normative_fragments.py"
OBSERVER_PATH = TOOLS / "receipt_observer.py"
def load_module(name: str, path: Path):
assert path.is_file(), f"shipped module is missing: {path}"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {name}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DAEMON = load_module("lease_broker_recovery_daemon", DAEMON_PATH)
FRAGMENTS = load_module("lease_broker_recovery_fragments", FRAGMENTS_PATH)
OBSERVER = load_module("lease_broker_recovery_observer", OBSERVER_PATH)
class RecoveryFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
root = Path(self.temporary.name)
os.chmod(root, 0o700)
self.peer = (os.getpid(), os.getuid(), os.getgid())
self.observer = OBSERVER.TestReceiptObserver()
self.broker = DAEMON.Broker(DAEMON.StateStore(root / "state.json"), observer=self.observer)
registered = self.broker.handle(self.peer, {
"action": "register_anchor",
"runtime_generation": 11,
})
self.session_id = registered["session_id"]
def tearDown(self) -> None:
self.temporary.cleanup()
def construction_and_binding(self) -> tuple[dict[str, object], dict[str, object]]:
content = b"Mosaic recovery authority\n"
construction = {
"manifest_version": 1,
"generator_version": "wi6-recovery-test",
"fragments": [{
"source_id": "authority/recovery",
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": hashlib.sha256(content).hexdigest(),
}],
}
built = FRAGMENTS.build_payload_from_wire(construction)
self.assertEqual(built.injectionDecision, "ACCEPTED")
self.assertTrue(built.promotion)
return construction, {
"compaction_epoch": 17,
"request_epoch": 23,
"h_source": built.h_source,
"h_payload": built.h_payload,
"schema_version": 1,
}
def begin_normal(self) -> dict[str, object]:
construction, binding = self.construction_and_binding()
return self.broker.handle(self.peer, {
"action": "begin_verification",
"session_id": self.session_id,
"runtime_generation": 11,
"runtime": "pi",
"binding": binding,
"construction": construction,
})
def begin_recovery(self) -> dict[str, object]:
construction, binding = self.construction_and_binding()
return self.broker.handle(self.peer, {
"action": "begin_recovery",
"session_id": self.session_id,
"runtime_generation": 11,
"runtime": "pi",
"binding": binding,
"construction": construction,
})
def complete_recovery(self) -> dict[str, object]:
return self.broker.handle(self.peer, {
"action": "complete_recovery",
"session_id": self.session_id,
"runtime_generation": 11,
})
def assert_recovery_refused_unverified(self, code: str) -> None:
with self.assertRaisesRegex(DAEMON.BrokerFailure, code):
self.complete_recovery()
self.assertEqual(self.broker.leases[self.session_id]["state"], DAEMON.LEASE_UNVERIFIED)
denied = self.broker.handle(self.peer, {
"action": "authorize_tool",
"session_id": self.session_id,
"runtime_generation": 11,
"runtime": "pi",
"tool_name": "bash",
})
self.assertEqual(denied["decision"], "deny")
class ConstrainedRecoveryContractTest(RecoveryFixture):
def test_recovery_mints_a_fresh_challenge_distinct_from_normal_path(self) -> None:
normal = self.begin_normal()
recovery = self.begin_recovery()
self.assertEqual(recovery["state"], "PENDING_DELIVERY")
self.assertNotEqual(normal["receipt_challenge"], recovery["receipt_challenge"])
self.assertIn(recovery["receipt_challenge"], recovery["receipt"])
def test_c4_normal_path_receipt_cannot_be_replayed_through_recovery(self) -> None:
normal = self.begin_normal()
recovery = self.begin_recovery()
self.assertNotEqual(normal["receipt_challenge"], recovery["receipt_challenge"])
self.observer.record_latest_assistant_message(self.session_id, 11, normal["receipt"])
self.assert_recovery_refused_unverified("RECEIPT_MISMATCH")
def test_t27_observable_partial_delivery_variants_never_promote(self) -> None:
variants = {
"absent": None,
"malformed": "MOSAIC-RECEIPT{malformed}",
"prefix-truncated": None,
"observable-adapter-mutation": None,
# Tail-only is represented only by this concrete malformed/incomplete
# delivery. It is not a category-wide tail-only detection claim.
"tail-only-malformed": "H_payload=tail-only",
}
for name, observed in variants.items():
with self.subTest(name=name):
recovery = self.begin_recovery()
if name == "prefix-truncated":
observed = recovery["receipt"][:-1]
elif name == "observable-adapter-mutation":
observed = recovery["receipt"].replace("H_payload=", "H_payload=0", 1)
if observed is not None:
self.observer.record_latest_assistant_message(self.session_id, 11, observed)
self.assert_recovery_refused_unverified(
"RECEIPT_OBSERVATION_UNAVAILABLE" if observed is None else "RECEIPT_MISMATCH"
)
def test_negative_capability_tail_preserving_middle_drop_is_not_receipt_detectable(self) -> None:
recovery = self.begin_recovery()
# The observer seam receives the exact terminal message, not the delivered
# payload bytes. A middle drop that preserves this tail is therefore T-C
# and deliberately NOT represented as receipt-detectable; WI-7 server-side
# evidence owns that residual. This is not an assertion that it is caught.
self.observer.record_latest_assistant_message(self.session_id, 11, recovery["receipt"])
promoted = self.complete_recovery()
self.assertEqual(promoted["state"], DAEMON.LEASE_VERIFIED)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""RED-first framework-firewall and portability contracts for shipped skills."""
from __future__ import annotations
import importlib.util
import re
import unittest
from pathlib import Path
MOSAIC = Path(__file__).parents[2]
REPOSITORY = MOSAIC.parents[1]
SKILLS = MOSAIC / "framework/skills"
REFRESH_SKILL = SKILLS / "mosaic-context-refresh/SKILL.md"
GATE_PATH = MOSAIC / "framework/tools/lease-broker/mutator-gate.py"
COMPACTION_THREAT = REPOSITORY / "docs/architecture/compaction-revocation.md"
RECEIPT_PROTOCOL = REPOSITORY / "docs/architecture/lease-broker-protocol.md"
OPERATOR_HOME = re.compile(r"/home/[^/\s]+/")
RECOVERY_PLACEHOLDER = "/absolute/path/to/mosaic/tools/lease-broker/recover-context.py"
CONSTRUCTION_PLACEHOLDER = "/absolute/path/to/mosaic-context-refresh-construction.json"
def load_gate():
spec = importlib.util.spec_from_file_location("framework_skill_portability_gate", GATE_PATH)
if spec is None or spec.loader is None:
raise RuntimeError("unable to load mutator gate")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
GATE = load_gate()
class FrameworkSkillPortabilityTest(unittest.TestCase):
def test_shipped_framework_skills_contain_no_operator_home_path(self) -> None:
offenders = [
str(path.relative_to(SKILLS))
for path in SKILLS.rglob("SKILL.md")
if OPERATOR_HOME.search(path.read_text(encoding="utf-8"))
]
self.assertEqual(offenders, [])
def test_shipped_recovery_template_resolves_to_a_literal_install_path_and_admits(self) -> None:
source = REFRESH_SKILL.read_text(encoding="utf-8")
self.assertIn(RECOVERY_PLACEHOLDER, source)
self.assertIn(CONSTRUCTION_PLACEHOLDER, source)
resolved_recovery = "/opt/mosaic/tools/lease-broker/recover-context.py"
resolved_construction = "/opt/mosaic/recovery/construction.json"
rendered = source.replace(RECOVERY_PLACEHOLDER, resolved_recovery).replace(
CONSTRUCTION_PLACEHOLDER, resolved_construction
)
match = re.search(r"```bash\s*\n\s*(.*?)\n\s*```", rendered, flags=re.DOTALL)
self.assertIsNotNone(match)
command = match.group(1) if match is not None else ""
self.assertEqual(
GATE.recovery_invocation_name(
{"tool_name": "Bash", "tool_input": {"command": command}},
Path(resolved_recovery),
),
GATE.RECOVERY_TOOL,
)
def test_t30_dual_hook_miss_matches_the_amended_threat_table(self) -> None:
threat_table = COMPACTION_THREAT.read_text(encoding="utf-8")
receipt_protocol = RECEIPT_PROTOCOL.read_text(encoding="utf-8")
self.assertRegex(
threat_table,
r"\| Both observers are missed, lease unexpired\s*\|\s*\*\*ALLOWED\*\* inside the bounded residual stale window\.",
)
self.assertRegex(
threat_table,
r"\| Both observers are missed, lease expired\s*\|\s*\*\*DENIED\*\* by monotonic TTL expiry\.",
)
self.assertRegex(
threat_table,
r"`enable_status_check=False` \(status checks not\s+enforced\)",
)
self.assertRegex(
receipt_protocol,
r"detects an \*\*ABSENT\*\* or \*\*PREFIX-TRUNCATED\*\* terminal\s+token",
)
required_threat_text = (
"## T-C server-side branch-protection posture",
"`main` is push-blocked and PR-only-merge",
"Status-check enforcement and approval enforcement are **RECOMMENDED**.",
"## Current-vs-required gap (recorded, not enacted)",
"`enable_push=False` (push-block present)",
"`require_approvals=0` (approvals not enforced)",
"`block_on_official_review=False` (official review not enforced)",
)
required_receipt_text = (
"## Receipt boundary and T-C residual (R1)",
"**MIDDLE-DROP** that preserves the tail",
"**NOT receipt-detectable**",
"server-side protected-branch controls",
)
for contract_text in required_threat_text:
with self.subTest(document="threat-table", contract_text=contract_text):
self.assertIn(contract_text, threat_table)
for contract_text in required_receipt_text:
with self.subTest(document="receipt-protocol", contract_text=contract_text):
self.assertIn(contract_text, receipt_protocol)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""RED-first contract tests for verbatim-hashed normative fragments."""
from __future__ import annotations
import hashlib
import importlib.util
import tempfile
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).parents[2] / "framework/tools/lease-broker/normative_fragments.py"
def shipped_module():
# Each test reaches the shipped implementation; no test doubles or local
# reimplementation of construction are allowed on this admission surface.
assert MODULE_PATH.is_file(), f"shipped construction module is missing: {MODULE_PATH}"
spec = importlib.util.spec_from_file_location("normative_fragments", MODULE_PATH)
if spec is None or spec.loader is None:
raise RuntimeError("unable to load normative fragment construction")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fragment(module, source_id: str, content: bytes):
return module.NormativeFragment(
source_id=source_id,
content=content,
expected_sha256=hashlib.sha256(content).hexdigest(),
)
def valid_fragments(module):
return [
fragment(module, "authority/constitution", b"Constitution\n"),
fragment(module, "authority/runtime", b"Runtime\n"),
]
class NormativeFragmentsTest(unittest.TestCase):
def test_t24_claude_and_pi_builders_are_byte_identical_and_one_way(self) -> None:
module = shipped_module()
fragments = valid_fragments(module)
claude = module.build_for_claude(
manifest_version=1,
generator_version="wi4-test",
fragments=fragments,
)
pi = module.build_for_pi(
manifest_version=1,
generator_version="wi4-test",
fragments=fragments,
)
self.assertEqual(claude.injectionDecision, "ACCEPTED")
self.assertTrue(claude.promotion)
self.assertEqual(claude.b_payload, pi.b_payload)
self.assertEqual(claude.h_payload, pi.h_payload)
self.assertEqual(
claude.h_payload,
hashlib.sha256(module.HASH_DOMAIN_SEPARATOR + module.length_frame([claude.b_payload])).hexdigest(),
)
self.assertNotIn(b"h_payload", claude.b_payload)
mutated_fragment = module.build_for_claude(
manifest_version=1,
generator_version="wi4-test",
fragments=[
fragment(module, "authority/constitution", b"Constitution changed\n"),
fragment(module, "authority/runtime", b"Runtime\n"),
],
)
mutated_metadata = module.build_for_claude(
manifest_version=2,
generator_version="wi4-test",
fragments=fragments,
)
self.assertNotEqual(claude.h_payload, mutated_fragment.h_payload)
self.assertNotEqual(claude.h_payload, mutated_metadata.h_payload)
def test_length_framing_and_domain_separation_prevent_ambiguous_construction(self) -> None:
module = shipped_module()
left = module.length_frame([b"ab", b"c"])
right = module.length_frame([b"a", b"bc"])
self.assertNotEqual(left, right)
self.assertNotEqual(
hashlib.sha256(module.HASH_DOMAIN_SEPARATOR + left).hexdigest(),
hashlib.sha256(module.HASH_DOMAIN_SEPARATOR + right).hexdigest(),
)
self.assertNotEqual(
hashlib.sha256(module.HASH_DOMAIN_SEPARATOR + left).hexdigest(),
hashlib.sha256(b"other-context\x00" + left).hexdigest(),
)
def test_source_invalidation_missing_refuses_real_construction_and_promotion(self) -> None:
module = shipped_module()
missing = module.NormativeFragment(
source_id="authority/missing",
content=None,
expected_sha256=hashlib.sha256(b"missing").hexdigest(),
)
result = module.build_for_claude(
manifest_version=1,
generator_version="wi4-test",
fragments=[missing],
)
self.assertEqual(result.injectionDecision, "REFUSED")
self.assertFalse(result.promotion)
self.assertEqual(result.source_reason, "missing")
self.assertIsNone(result.b_payload)
self.assertIsNone(result.h_payload)
def test_source_invalidation_oversize_refuses_real_construction_and_promotion(self) -> None:
module = shipped_module()
content = b"x" * (module.MAX_FRAGMENT_BYTES + 1)
oversize = fragment(module, "authority/oversize", content)
result = module.build_for_pi(
manifest_version=1,
generator_version="wi4-test",
fragments=[oversize],
)
self.assertEqual(result.injectionDecision, "REFUSED")
self.assertFalse(result.promotion)
self.assertEqual(result.source_reason, "oversize")
self.assertIsNone(result.b_payload)
self.assertIsNone(result.h_payload)
def test_source_invalidation_hash_mismatch_refuses_real_construction_and_promotion(self) -> None:
module = shipped_module()
mismatch = module.NormativeFragment(
source_id="authority/hash-mismatch",
content=b"trusted bytes",
expected_sha256=hashlib.sha256(b"different bytes").hexdigest(),
)
result = module.build_for_claude(
manifest_version=1,
generator_version="wi4-test",
fragments=[mismatch],
)
self.assertEqual(result.injectionDecision, "REFUSED")
self.assertFalse(result.promotion)
self.assertEqual(result.source_reason, "hash-mismatch")
self.assertIsNone(result.b_payload)
self.assertIsNone(result.h_payload)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""RED-first contracts for the shipped receipt challenge and observer seam."""
from __future__ import annotations
import base64
import copy
import hashlib
import importlib.util
import os
import tempfile
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
DAEMON_PATH = TOOLS / "daemon.py"
FRAGMENTS_PATH = TOOLS / "normative_fragments.py"
OBSERVER_PATH = TOOLS / "receipt_observer.py"
def load_module(name: str, path: Path):
assert path.is_file(), f"shipped module is missing: {path}"
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {name}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DAEMON = load_module("lease_broker_receipt_daemon", DAEMON_PATH)
FRAGMENTS = load_module("lease_broker_normative_fragments", FRAGMENTS_PATH)
class BrokerFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
root = Path(self.temporary.name)
os.chmod(root, 0o700)
self.peer = (os.getpid(), os.getuid(), os.getgid())
self.broker = DAEMON.Broker(DAEMON.StateStore(root / "state.json"))
registered = self.broker.handle(self.peer, {
"action": "register_anchor",
"runtime_generation": 7,
})
self.session_id = registered["session_id"]
self.assertIsInstance(self.session_id, str)
def tearDown(self) -> None:
self.temporary.cleanup()
def construction(self) -> tuple[dict[str, object], dict[str, object]]:
content = b"Constitution\n"
expected_sha256 = hashlib.sha256(content).hexdigest()
construction = {
"manifest_version": 1,
"generator_version": "wi5-receipt-test",
"fragments": [{
"source_id": "authority/constitution",
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": expected_sha256,
}],
}
result = FRAGMENTS.build_payload(
manifest_version=construction["manifest_version"],
generator_version=construction["generator_version"],
fragments=[FRAGMENTS.NormativeFragment("authority/constitution", content, expected_sha256)],
)
self.assertEqual(result.injectionDecision, "ACCEPTED")
self.assertTrue(result.promotion)
return construction, {
"compaction_epoch": 3,
"request_epoch": 8,
"h_source": result.h_source,
"h_payload": result.h_payload,
"schema_version": 1,
}
def begin(self, binding: dict[str, object], construction: dict[str, object]) -> dict[str, object]:
response = self.broker.handle(self.peer, {
"action": "begin_verification",
"session_id": self.session_id,
"runtime_generation": 7,
"runtime": "pi",
"binding": binding,
"construction": construction,
})
self.assertEqual(response["state"], DAEMON.LEASE_PENDING)
self.assertIsInstance(response.get("receipt_challenge"), str)
self.assertIsInstance(response.get("receipt"), str)
return response
class BuildPayloadAdmissionTest(BrokerFixture):
def test_b3_forged_h_source_or_h_payload_is_refused_against_shipped_build_payload(self) -> None:
construction, trusted = self.construction()
for field in ("h_source", "h_payload"):
with self.subTest(field=field):
forged = dict(trusted)
forged[field] = "f" * 64
with self.assertRaisesRegex(DAEMON.BrokerFailure, "PAYLOAD_BINDING_MISMATCH"):
self.begin(forged, construction)
class ReceiptObserverTest(BrokerFixture):
def setUp(self) -> None:
super().setUp()
observers = load_module("lease_broker_test_observer", OBSERVER_PATH)
self.observer = observers.TestReceiptObserver()
self.broker = DAEMON.Broker(self.broker.store, observer=self.observer)
def record(self, message: str) -> None:
self.observer.record_latest_assistant_message(self.session_id, 7, message)
def observe(self, challenge: str, **untrusted: object) -> dict[str, object]:
return self.broker.handle(self.peer, {
"action": "observe_receipt",
"session_id": self.session_id,
"runtime_generation": 7,
"receipt_challenge": challenge,
**untrusted,
})
def promote(self, challenge: str) -> dict[str, object]:
return self.broker.handle(self.peer, {
"action": "promote_lease",
"session_id": self.session_id,
"runtime_generation": 7,
"receipt_challenge": challenge,
})
def test_b2_echoed_request_observation_is_refused_but_observer_source_promotes(self) -> None:
construction, binding = self.construction()
cycle = self.begin(binding, construction)
challenge = cycle["receipt_challenge"]
receipt = cycle["receipt"]
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_RECEIPT"):
self.observe(challenge, latest_assistant_message=receipt)
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_OBSERVATION_UNAVAILABLE"):
self.observe(challenge)
self.record(receipt)
self.assertEqual(self.observe(challenge)["state"], DAEMON.LEASE_PENDING_PROMOTION)
self.assertEqual(self.promote(challenge)["state"], DAEMON.LEASE_VERIFIED)
self.assertEqual(self.broker.handle(self.peer, {
"action": "authorize_tool",
"session_id": self.session_id,
"runtime_generation": 7,
"runtime": "pi",
"tool_name": "bash",
})["decision"], "allow")
def test_rejected_begin_keeps_revoke_first_fence_for_all_construction_refusals(self) -> None:
construction, binding = self.construction()
refusal_cases = {
"INVALID_CONSTRUCTION": {"bad": "construction"},
"PAYLOAD_CONSTRUCTION_REFUSED": {
**construction,
"fragments": [{
**construction["fragments"][0],
"expected_sha256": "0" * 64,
}],
},
"PAYLOAD_BINDING_MISMATCH": None,
}
for expected_code, rejected_construction in refusal_cases.items():
with self.subTest(expected_code=expected_code):
verified = self.begin(binding, construction)
self.record(verified["receipt"])
self.observe(verified["receipt_challenge"])
self.assertEqual(self.promote(verified["receipt_challenge"])["state"], DAEMON.LEASE_VERIFIED)
rejected_binding = copy.deepcopy(binding)
if expected_code == "PAYLOAD_BINDING_MISMATCH":
rejected_binding["h_payload"] = "f" * 64
rejected_construction = construction
with self.assertRaisesRegex(DAEMON.BrokerFailure, expected_code):
self.begin(rejected_binding, rejected_construction)
self.assertEqual(
self.broker.leases[self.session_id]["state"], DAEMON.LEASE_UNVERIFIED
)
denied = self.broker.handle(self.peer, {
"action": "authorize_tool",
"session_id": self.session_id,
"runtime_generation": 7,
"runtime": "pi",
"tool_name": "bash",
})
self.assertEqual(denied["decision"], "deny")
self.assertEqual(denied["state"], DAEMON.LEASE_UNVERIFIED)
def test_t26_stale_epoch_receipt_cannot_promote_against_shipped_binding(self) -> None:
construction, stale_binding = self.construction()
stale = self.begin(stale_binding, construction)
current_binding = dict(stale_binding)
current_binding["compaction_epoch"] = 4
current_binding["request_epoch"] = 9
current = self.begin(current_binding, construction)
self.assertNotEqual(stale["receipt_challenge"], current["receipt_challenge"])
self.record(stale["receipt"])
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_MISMATCH"):
self.observe(current["receipt_challenge"])
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
self.promote(current["receipt_challenge"])
def test_t29_altered_model_hash_cannot_promote_against_shipped_binding(self) -> None:
construction, binding = self.construction()
cycle = self.begin(binding, construction)
expected = cycle["receipt"]
altered_hash = "f" * 64
self.assertNotEqual(altered_hash, cycle["binding"]["h_payload"])
altered = expected.replace(cycle["binding"]["h_payload"], altered_hash, 1)
self.assertNotEqual(altered, expected)
self.record(altered)
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_MISMATCH"):
self.observe(cycle["receipt_challenge"])
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
self.promote(cycle["receipt_challenge"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""RED-first adversarial Claude B1 gate contracts.
This private harness drives the shipped gate and daemon out of process. It
never contacts a live broker/runtime and executes a shell payload only after a
regression has already (incorrectly) received the recovery exemption.
"""
from __future__ import annotations
import json
import os
import re
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
FRAMEWORK = Path(__file__).parents[2] / "framework"
DAEMON = TOOLS / "daemon.py"
GATE = TOOLS / "mutator-gate.py"
RECOVERY = TOOLS / "recover-context.py"
SKILL = FRAMEWORK / "skills/mosaic-context-refresh/SKILL.md"
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(3.0)
connection.connect(str(socket_path))
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
connection.shutdown(socket.SHUT_WR)
response = bytearray()
while True:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if not response.endswith(b"\n") or response.count(b"\n") != 1:
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
reply = json.loads(response[:-1])
if not isinstance(reply, dict):
raise AssertionError("broker response is not an object")
return reply
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if socket_path.exists():
return
if process.poll() is not None:
output = process.stdout.read() if process.stdout is not None else ""
raise RuntimeError(f"daemon exited before READY: {output}")
time.sleep(0.02)
raise TimeoutError("daemon did not create private socket")
class ClaudeRecoveryGateAdversarialTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
os.chmod(self.root, 0o700)
self.socket = self.root / "broker.sock"
self.daemon = subprocess.Popen(
[sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(self.socket), "--state", str(self.root / "state.json")],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
wait_ready(self.daemon, self.socket)
registered = request(self.socket, {"action": "register_anchor", "runtime_generation": 1})
self.session_id = registered["session_id"]
self.environment = {
**os.environ,
"MOSAIC_LEASE_BROKER_SOCKET": str(self.socket),
"MOSAIC_LEASE_SESSION_ID": self.session_id,
"MOSAIC_RUNTIME_GENERATION": "1",
"MOSAIC_LEASE_RUNTIME": "claude",
}
# This private path is only a gate-classifier identity; if a regression
# blesses a payload, bash invokes no installed/live recovery command.
self.recovery_path = self.root / "recover-context.py"
self.canonical = (
f"python3 {self.recovery_path} begin "
f"--construction {self.root / 'construction.json'} --compaction-epoch 0 --request-epoch 0"
)
def tearDown(self) -> None:
if self.daemon.poll() is None:
self.daemon.terminate()
try:
self.daemon.wait(timeout=3.0)
except subprocess.TimeoutExpired:
self.daemon.kill()
self.daemon.wait()
if self.daemon.stdout is not None:
self.daemon.stdout.close()
self.temporary.cleanup()
def gate(
self, command: str, recovery_command: Path | str | None = None
) -> subprocess.CompletedProcess[str]:
configured_recovery = self.recovery_path if recovery_command is None else recovery_command
return subprocess.run(
[
sys.executable,
"-I",
"-S",
"-B",
str(GATE),
"--runtime",
"claude",
"--recovery-command",
str(configured_recovery),
],
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
text=True,
capture_output=True,
env=self.environment,
check=False,
)
def command_for(self, position: str, value: str) -> str:
argv = [
"python3",
str(self.recovery_path),
"begin",
"--construction",
str(self.root / "construction.json"),
"--compaction-epoch",
"0",
"--request-epoch",
"0",
]
positions = {
"executable": 0,
"path": 1,
"phase": 2,
"construction_flag": 3,
"construction": 4,
"compaction_epoch_flag": 5,
"compaction_epoch": 6,
"request_epoch_flag": 7,
"request_epoch": 8,
}
try:
argv[positions[position]] = value
except KeyError as exc:
raise AssertionError(f"unknown argv position {position}") from exc
return " ".join(argv)
def test_canonical_literal_and_shipped_skill_invocation_are_ungated(self) -> None:
self.assertEqual(self.gate(self.canonical).returncode, 0)
source = SKILL.read_text(encoding="utf-8")
recovery_placeholder = "/absolute/path/to/mosaic/tools/lease-broker/recover-context.py"
construction_placeholder = "/absolute/path/to/mosaic-context-refresh-construction.json"
self.assertIn(recovery_placeholder, source)
self.assertIn(construction_placeholder, source)
resolved_recovery = "/opt/mosaic/tools/lease-broker/recover-context.py"
rendered = source.replace(recovery_placeholder, resolved_recovery).replace(
construction_placeholder, "/opt/mosaic/recovery/construction.json"
)
match = re.search(r"```bash\s*\n\s*(.*?)\n\s*```", rendered, flags=re.DOTALL)
self.assertIsNotNone(match)
shipped = match.group(1) if match is not None else ""
self.assertEqual(self.gate(shipped, resolved_recovery).returncode, 0)
def test_every_shell_active_vector_in_every_argv_position_falls_through_to_bash_deny(self) -> None:
positions = (
"executable",
"path",
"phase",
"construction_flag",
"construction",
"compaction_epoch_flag",
"compaction_epoch",
"request_epoch_flag",
"request_epoch",
)
marker = self.root / "PWNED"
marker_vector = f"$(touch${{IFS}}{marker})"
vectors = {
"command-substitution": marker_vector,
"backtick": f"`touch${{IFS}}{marker}`",
"process-substitution": f"<(touch${{IFS}}{marker})",
"parameter-expansion": "${IFS}",
"home-expansion": "${HOME}",
"arithmetic-expansion": "$((1+1))",
"brace-expansion": "{a,b}",
"tilde-expansion": "~",
"glob": "*",
"redirection": ">",
"semicolon": f";touch${{IFS}}{marker}",
"and": f"&&touch${{IFS}}{marker}",
"pipe": f"|touch${{IFS}}{marker}",
"embedded-newline": "literal\nnext",
"quoting-trick": "'literal'",
}
for position in positions:
for kind, vector in vectors.items():
with self.subTest(position=position, kind=kind):
marker.unlink(missing_ok=True)
command = self.command_for(position, vector)
gated = self.gate(command)
if gated.returncode == 0 and kind in {"command-substitution", "backtick", "process-substitution", "semicolon", "and", "pipe"}:
subprocess.run(["bash", "-c", command], cwd=self.root, env=self.environment, check=False)
self.assertEqual(gated.returncode, 2, f"unexpected recovery exemption: {command!r}")
self.assertFalse(marker.exists(), f"shell payload executed: {command!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""RED-first B1/B2 runtime-boundary contracts for constrained recovery.
Every runtime process in these tests is a fresh child against a private daemon
and Unix sockets. They do not activate a live Mosaic daemon or model stream.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
FRAMEWORK = Path(__file__).parents[2] / "framework"
DAEMON = TOOLS / "daemon.py"
GATE = TOOLS / "mutator-gate.py"
RECOVERY = TOOLS / "recover-context.py"
OBSERVER_CLIENT = TOOLS / "receipt-observer-client.py"
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
deadline = time.monotonic() + 5.0
while True:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(3.0)
try:
connection.connect(str(socket_path))
except ConnectionRefusedError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
continue
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
connection.shutdown(socket.SHUT_WR)
response = bytearray()
while True:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
break
if not response.endswith(b"\n") or response.count(b"\n") != 1:
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
reply = json.loads(response[:-1])
if not isinstance(reply, dict):
raise AssertionError("broker response is not an object")
return reply
def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if socket_path.exists():
return
if process.poll() is not None:
output = process.stdout.read() if process.stdout is not None else ""
raise RuntimeError(f"daemon exited before READY: {output}")
time.sleep(0.02)
raise TimeoutError("daemon did not create private broker socket")
class RuntimeBoundaryFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
os.chmod(self.root, 0o700)
self.socket = self.root / "broker.sock"
self.observer_socket = self.root / "observer.sock"
self.state = self.root / "state.json"
self.children: list[subprocess.Popen[str]] = []
def tearDown(self) -> None:
for child in self.children:
if child.poll() is None:
child.terminate()
try:
child.wait(timeout=3.0)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
if child.stdout is not None:
child.stdout.close()
self.temporary.cleanup()
def start_daemon(self, *, production_observer: bool) -> None:
arguments = [sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(self.socket), "--state", str(self.state)]
if production_observer:
arguments.extend(["--observer-socket", str(self.observer_socket)])
process = subprocess.Popen(
arguments,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
self.children.append(process)
wait_ready(process, self.socket)
def register(self) -> str:
reply = request(self.socket, {"action": "register_anchor", "runtime_generation": 1})
session_id = reply.get("session_id")
self.assertTrue(reply.get("ok"))
self.assertIsInstance(session_id, str)
return session_id
def environment(self, session_id: str) -> dict[str, str]:
return {
**os.environ,
"MOSAIC_LEASE_BROKER_SOCKET": str(self.socket),
"MOSAIC_RECEIPT_OBSERVER_SOCKET": str(self.observer_socket),
"MOSAIC_LEASE_SESSION_ID": session_id,
"MOSAIC_RUNTIME_GENERATION": "1",
"MOSAIC_LEASE_RUNTIME": "pi",
}
def construction(self) -> Path:
content = b"WI-6 B2 production observer fixture\n"
path = self.root / "construction.json"
path.write_text(json.dumps({
"manifest_version": 1,
"generator_version": "wi6-repair-runtime-boundary",
"fragments": [{
"source_id": "authority/wi6-repair",
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": hashlib.sha256(content).hexdigest(),
}],
}), encoding="utf-8")
os.chmod(path, 0o600)
return path
class RecoveryRuntimeBoundaryTest(RuntimeBoundaryFixture):
def test_b1_claude_exact_recovery_command_is_invocable_unverified_but_bash_is_not(self) -> None:
self.start_daemon(production_observer=False)
session_id = self.register()
environment = self.environment(session_id)
recovery_command = f"python3 {RECOVERY} complete"
mapped = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": recovery_command}}),
text=True,
capture_output=True,
env={**environment, "MOSAIC_LEASE_RUNTIME": "claude"},
check=False,
)
self.assertEqual(mapped.returncode, 0, mapped.stderr)
mapped_begin = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
input=json.dumps({
"tool_name": "Bash",
"tool_input": {
"command": (
f"python3 {RECOVERY} begin --construction /tmp/construction.json "
"--compaction-epoch 1 --request-epoch 1"
)
},
}),
text=True,
capture_output=True,
env={**environment, "MOSAIC_LEASE_RUNTIME": "claude"},
check=False,
)
self.assertEqual(mapped_begin.returncode, 0, mapped_begin.stderr)
ordinary_bash = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "echo not-recovery"}}),
text=True,
capture_output=True,
env={**environment, "MOSAIC_LEASE_RUNTIME": "claude"},
check=False,
)
self.assertEqual(ordinary_bash.returncode, 2)
def test_b1_pi_registers_only_the_broker_exempt_recovery_tool(self) -> None:
extension = PI_EXTENSION.read_text(encoding="utf-8")
self.assertIn("const RECOVERY_TOOL = 'mosaic_context_recover'", extension)
self.assertIn("name: RECOVERY_TOOL", extension)
self.assertIn("checkPiMutatorGate(RECOVERY_TOOL)", extension)
self.assertNotIn("toolName === 'bash' ? RECOVERY_TOOL", extension)
def test_b2_production_observer_promotes_over_private_transport_and_rejects_broker_supplied_message(self) -> None:
self.start_daemon(production_observer=True)
session_id = self.register()
environment = self.environment(session_id)
begin = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "begin", "--construction", str(self.construction()), "--compaction-epoch", "1", "--request-epoch", "1"],
text=True,
capture_output=True,
env=environment,
check=False,
)
self.assertEqual(begin.returncode, 0, begin.stderr)
cycle = json.loads(begin.stdout)
receipt = cycle.get("receipt")
self.assertIsInstance(receipt, str)
# S1: production transport is not a broker request field. The public
# broker endpoint keeps rejecting caller-supplied assistant evidence.
rejected_begin = request(self.socket, {
"action": "begin_recovery",
"session_id": session_id,
"runtime_generation": 1,
"receipt": receipt,
})
self.assertEqual(rejected_begin, {"ok": False, "code": "INVALID_RECOVERY_REQUEST"})
rejected_observe = request(self.socket, {
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": 1,
"receipt_challenge": cycle["receipt_challenge"],
"latest_assistant_message": receipt,
})
self.assertEqual(rejected_observe, {"ok": False, "code": "INVALID_RECEIPT"})
rejected_complete = request(self.socket, {
"action": "complete_recovery",
"session_id": session_id,
"runtime_generation": 1,
"latest_assistant_message": receipt,
})
self.assertEqual(rejected_complete, {"ok": False, "code": "INVALID_RECOVERY_REQUEST"})
recorded = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", "pi"],
input=json.dumps({"latest_assistant_message": receipt}),
text=True,
capture_output=True,
env=environment,
check=False,
)
self.assertEqual(recorded.returncode, 0, recorded.stderr)
complete = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "complete"],
text=True,
capture_output=True,
env=environment,
check=False,
)
self.assertEqual(complete.returncode, 0, complete.stderr)
self.assertEqual(json.loads(complete.stdout).get("state"), "VERIFIED")
# The independent Claude transport selects one latest assistant entry
# from its hook transcript; it is not a Pi/message_end fallback.
claude_environment = {**environment, "MOSAIC_LEASE_RUNTIME": "claude"}
claude_begin = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "begin", "--construction", str(self.construction()), "--compaction-epoch", "2", "--request-epoch", "2"],
text=True,
capture_output=True,
env=claude_environment,
check=False,
)
self.assertEqual(claude_begin.returncode, 0, claude_begin.stderr)
claude_receipt = json.loads(claude_begin.stdout)["receipt"]
transcript = self.root / "claude-transcript.jsonl"
transcript.write_text(
json.dumps({"message": {"role": "assistant", "content": claude_receipt}}) + "\n",
encoding="utf-8",
)
claude_recorded = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", "claude", "--latest-entry"],
input=json.dumps({"transcript_path": str(transcript)}),
text=True,
capture_output=True,
env=claude_environment,
check=False,
)
self.assertEqual(claude_recorded.returncode, 0, claude_recorded.stderr)
claude_complete = subprocess.run(
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "complete"],
text=True,
capture_output=True,
env=claude_environment,
check=False,
)
self.assertEqual(claude_complete.returncode, 0, claude_complete.stderr)
self.assertEqual(json.loads(claude_complete.stdout).get("state"), "VERIFIED")
if __name__ == "__main__":
unittest.main()

View File

@@ -6,42 +6,73 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { afterEach, describe, expect, test } from 'vitest'; import { afterEach, describe, expect, test } from 'vitest';
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
import { requestBrokerReply } from '../lease-broker/broker-test-client.js'; import {
observeAndPromoteReceiptChallenge,
requestBrokerReply,
} from '../lease-broker/broker-test-client.js';
interface BrokerReply { interface BrokerReply {
ok: boolean; ok: boolean;
code?: string; code?: string;
decision?: 'allow' | 'deny'; decision?: 'allow' | 'deny';
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'VERIFIED'; state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'PENDING_PROMOTION' | 'VERIFIED';
session_id?: string; session_id?: string;
promotion_token?: string; receipt_challenge?: string;
receipt?: string;
}
interface PendingReceiptCycle {
sessionId: string;
runtimeGeneration: number;
receiptChallenge: string;
receipt: string;
} }
interface BrokerPaths { interface BrokerPaths {
socket: string; socket: string;
observerFixture: string;
} }
const frameworkRoot = new URL('../../framework/', import.meta.url).pathname; const frameworkRoot = new URL('../../framework/', import.meta.url).pathname;
const repositoryRoot = new URL('../../../../', import.meta.url).pathname;
const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py'); const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py');
const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py'); const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py');
const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.py'); const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.py');
const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py'); const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py');
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh'); const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh'); const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh'); const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
const children: ChildProcess[] = []; const children: ChildProcess[] = [];
const temporaryRoots: string[] = []; const temporaryRoots: string[] = [];
const construction = {
manifest_version: 1,
generator_version: 'mutator-gate-acceptance',
fragments: [
{
source_id: 'authority/mutator-gate-acceptance',
content_base64: 'bXV0YXRvci1nYXRlIGFjY2VwdGFuY2UK',
expected_sha256: 'cc3de191821d48037f60b4d006fce74b5dd394d39fb5c8bf681c28889ff0e623',
},
],
};
const binding = (compaction_epoch = 1) => ({ const binding = (compaction_epoch = 1) => ({
compaction_epoch, compaction_epoch,
request_epoch: 0, request_epoch: 0,
h_source: 'a'.repeat(64), h_source: '3c8fc6733d6a2bdc001ed9277d636d7cfac037ae48ed7d54203180a3839dc7a6',
h_payload: 'b'.repeat(64), h_payload: '42da889037c29c3a41397df0f86465ffd121bdb8cd18ad0d7c3df259ab502d3e',
schema_version: 1, schema_version: 1,
}); });
const pendingReceiptCycles = new Map<string, PendingReceiptCycle>();
const observerFixtures = new Map<string, string>();
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> { async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
return await requestBrokerReply<BrokerReply>(socketPath, requestValue); return await requestBrokerReply<BrokerReply>(socketPath, requestValue);
} }
@@ -50,9 +81,18 @@ async function startBroker(): Promise<BrokerPaths> {
const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-')); const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-'));
await chmod(root, 0o700); await chmod(root, 0o700);
const socket = join(root, 'broker.sock'); const socket = join(root, 'broker.sock');
const observerFixture = join(root, 'test-observer.json');
const child = spawn( const child = spawn(
'python3', 'python3',
[daemonPath, '--socket', socket, '--state', join(root, 'state.json')], [
daemonPath,
'--socket',
socket,
'--state',
join(root, 'state.json'),
'--test-observer-file',
observerFixture,
],
{ {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}, },
@@ -68,7 +108,8 @@ async function startBroker(): Promise<BrokerPaths> {
); );
child.stdout?.once('data', () => resolve()); child.stdout?.once('data', () => resolve());
}); });
return { socket }; observerFixtures.set(socket, observerFixture);
return { socket, observerFixture };
} }
interface RuntimeLaunchEntry { interface RuntimeLaunchEntry {
@@ -162,28 +203,43 @@ async function beginVerification(
ttl_seconds = 300, ttl_seconds = 300,
compactionEpoch = 1, compactionEpoch = 1,
): Promise<BrokerReply> { ): Promise<BrokerReply> {
return await request(socket, { const reply = await request(socket, {
action: 'begin_verification', action: 'begin_verification',
session_id, session_id,
runtime_generation, runtime_generation,
runtime, runtime,
ttl_seconds, ttl_seconds,
binding: binding(compactionEpoch), binding: binding(compactionEpoch),
construction,
}); });
if (typeof reply.receipt_challenge === 'string' && typeof reply.receipt === 'string') {
pendingReceiptCycles.set(reply.receipt_challenge, {
sessionId: session_id,
runtimeGeneration: runtime_generation,
receiptChallenge: reply.receipt_challenge,
receipt: reply.receipt,
});
}
return reply;
} }
async function promote( async function promote(
socket: string, socket: string,
session_id: string, session_id: string,
promotion_token: string, receipt_challenge: string,
runtime_generation = 1, runtime_generation = 1,
): Promise<BrokerReply> { ): Promise<BrokerReply> {
const cycle = pendingReceiptCycles.get(receipt_challenge);
const observerFixture = observerFixtures.get(socket);
if (cycle === undefined || observerFixture === undefined) {
return await request(socket, { return await request(socket, {
action: 'promote_lease', action: 'promote_lease',
session_id, session_id,
runtime_generation, runtime_generation,
promotion_token, receipt_challenge,
}); });
}
return await observeAndPromoteReceiptChallenge(socket, observerFixture, cycle);
} }
async function authorize( async function authorize(
@@ -245,13 +301,13 @@ describe('whole mutator-class lease gate', () => {
const pending = await beginVerification(socket, sessionId, 'claude'); const pending = await beginVerification(socket, sessionId, 'claude');
expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' }); expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
expect(pending.promotion_token).toMatch(/^[a-f0-9]{64}$/); expect(pending.receipt_challenge).toMatch(/^[a-f0-9]{64}$/);
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({ expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
ok: false, ok: false,
decision: 'deny', decision: 'deny',
}); });
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({ expect(await promote(socket, sessionId, pending.receipt_challenge!)).toMatchObject({
ok: true, ok: true,
state: 'VERIFIED', state: 'VERIFIED',
}); });
@@ -267,9 +323,9 @@ describe('whole mutator-class lease gate', () => {
ok: false, ok: false,
decision: 'deny', decision: 'deny',
}); });
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({ expect(await promote(socket, sessionId, pending.receipt_challenge!)).toMatchObject({
ok: false, ok: false,
code: 'PROMOTION_TOKEN_MISMATCH', code: 'RECEIPT_REPLAY',
}); });
}); });
@@ -393,16 +449,239 @@ describe('whole mutator-class lease gate', () => {
).toMatchObject({ ok: false, code: 'INVALID_TOOL' }); ).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
}); });
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => { test('T12b/T30 reports the dual-observer-miss residual within and after TTL', async () => {
const { socket } = await startBroker(); const { socket } = await startBroker();
const sessionId = await register(socket); const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1); const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
await promote(socket, sessionId, pending.promotion_token!); await promote(socket, sessionId, pending.receipt_challenge!);
// Intentionally invoke neither compaction observer: this is the amended
// D2-v5 bounded residual, not a fail-closed path.
const withinTtl = await authorize(socket, sessionId, 'claude', 'Bash');
expect(withinTtl).toMatchObject({ ok: true, decision: 'allow', state: 'VERIFIED' });
console.info('T12b/T30 dual-hook-miss within-TTL: ALLOWED (bounded residual stale window)');
await new Promise((resolve) => setTimeout(resolve, 1_100));
const afterTtl = await authorize(socket, sessionId, 'claude', 'Bash');
expect(afterTtl).toMatchObject({
ok: false,
code: 'LEASE_EXPIRED',
decision: 'deny',
});
console.info('T12b/T30 dual-hook-miss after-TTL: DENIED (lease expiry)');
const threatContract = await readFile(compactionThreatPath, 'utf8');
expect(threatContract).toContain('BOUNDED RESIDUAL STALE WINDOW');
expect(threatContract).toContain('within-TTL consequential actions are allowed');
expect(threatContract).toContain('bounded by lease expiry, not by the mutator gate');
});
test.each([
{ observer: 'Claude PreCompact', reason: 'pre-compact' },
{ observer: 'Claude SessionStart(compact)', reason: 'session-start-compact' },
])('$observer revokes a verified lease through the broker path', async ({ reason }) => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.receipt_challenge!);
const revoked = spawnSync('python3', [revokerPath, '--runtime', 'claude', '--reason', reason], {
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
},
});
expect(revoked.status, revoked.stderr).toBe(0);
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
});
test('promote-lease-lost-ACK orphaned VERIFIED lease is caught by observer revoke and by monotonic TTL expiry (D2-v5 backstop)', async () => {
const { socket } = await startBroker();
const observerSessionId = await register(socket);
const observerPending = await beginVerification(socket, observerSessionId, 'claude');
await promote(socket, observerSessionId, observerPending.receipt_challenge!);
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
state: 'VERIFIED',
});
const retriedPromotion = await promote(
socket,
observerSessionId,
observerPending.receipt_challenge!,
);
expect(retriedPromotion.ok).toBe(false);
expect(retriedPromotion.code).toBe('RECEIPT_REPLAY');
const revoked = spawnSync(
'python3',
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: observerSessionId,
MOSAIC_RUNTIME_GENERATION: '1',
},
},
);
expect(revoked.status, revoked.stderr).toBe(0);
expect(await authorize(socket, observerSessionId, 'claude', 'Write')).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
const { socket: expirySocket } = await startBroker();
const expirySessionId = await register(expirySocket);
expect(expirySessionId).not.toBe(observerSessionId);
const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1);
await promote(expirySocket, expirySessionId, expiryPending.receipt_challenge!);
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
state: 'VERIFIED',
});
await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
ok: false,
code: 'LEASE_EXPIRED',
decision: 'deny',
});
});
test('a fired observer fences the old lease even while broker transport is unavailable', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.receipt_challenge!);
const root = await mkdtemp(join(tmpdir(), 'mosaic-observer-fence-'));
temporaryRoots.push(root);
const generationFile = join(root, 'runtime.generation');
await writeFile(generationFile, '1\n', { mode: 0o600 });
const failedObserver = spawnSync(
'python3',
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: join(root, 'unavailable.sock'),
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
MOSAIC_LEASE_GENERATION_FILE: generationFile,
},
},
);
expect(failedObserver.status).toBe(2);
expect(await readFile(generationFile, 'utf8')).toBe('2\n');
expect(await authorize(socket, sessionId, 'claude', 'Write', 2)).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
});
test('same-PID runtime-generation bump revokes the prior incarnation automatically', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'pi');
await promote(socket, sessionId, pending.receipt_challenge!);
const anchorPid = process.pid;
const root = await mkdtemp(join(tmpdir(), 'mosaic-generation-bump-'));
temporaryRoots.push(root);
const generationFile = join(root, 'runtime.generation');
await writeFile(generationFile, '1\n', { mode: 0o600 });
const bumped = spawnSync(
'python3',
[revokerPath, '--runtime', 'pi', '--reason', 'session-start-resume', '--bump-generation'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
MOSAIC_LEASE_GENERATION_FILE: generationFile,
},
},
);
expect(bumped.status, bumped.stderr).toBe(0);
expect(await readFile(generationFile, 'utf8')).toBe('2\n');
expect(process.pid).toBe(anchorPid);
expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
expect(await authorize(socket, sessionId, 'pi', 'bash', 1)).toMatchObject({
ok: false,
code: 'STALE_GENERATION',
});
});
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
};
expect(
settings.hooks['PreCompact']?.some((entry) =>
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
),
).toBe(true);
expect(
settings.hooks['SessionStart']?.some(
(entry) =>
entry.matcher === 'compact' &&
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
),
).toBe(true);
const piExtension = await readFile(piExtensionPath, 'utf8');
const piLifecycle = await readFile(piLifecyclePath, 'utf8');
expect(piExtension).toContain('registerLeaseLifecycleHooks');
expect(piLifecycle).toContain("pi.on('session_before_compact'");
expect(piLifecycle).toContain("pi.on('session_compact'");
expect(piLifecycle).toContain("pi.on('context'");
expect(piLifecycle).toContain('--bump-generation');
});
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
// Establish the lease with a normal (non-racing) TTL first and prove it
// authorizes. This "still valid" check is setup, not a TTL-expiry
// assertion, so it must not share a lease with a 1-second TTL: on a
// contended push-CI host, scheduling delay alone between promote() and
// this authorize() call can consume that entire 1-second margin and
// spuriously deny it (CI#1945). Using a generous TTL here removes that
// real-time race without touching lease-gate security semantics.
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: true, ok: true,
decision: 'allow', decision: 'allow',
}); });
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
// expiry demonstration below. It is never used for anything but the
// wait-then-expire assertion, so there is no setup work racing its
// 1-second window.
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
await promote(socket, sessionId, shortLived.receipt_challenge!);
await new Promise((resolve) => setTimeout(resolve, 1_100)); await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: false, ok: false,
@@ -410,8 +689,8 @@ describe('whole mutator-class lease gate', () => {
decision: 'deny', decision: 'deny',
}); });
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2); const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
await promote(socket, sessionId, refreshed.promotion_token!); await promote(socket, sessionId, refreshed.receipt_challenge!);
expect( expect(
await request(socket, { await request(socket, {
action: 'revoke_lease', action: 'revoke_lease',
@@ -431,7 +710,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker(); const { socket } = await startBroker();
const sessionId = await register(socket); const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'pi'); const pending = await beginVerification(socket, sessionId, 'pi');
await promote(socket, sessionId, pending.promotion_token!); await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({ expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({
ok: false, ok: false,
@@ -542,6 +821,12 @@ hook_present = any(
item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", [])) item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", []))
for item in pre_tool for item in pre_tool
) )
pre_compact = settings.get("hooks", {}).get("PreCompact", [])
session_start = settings.get("hooks", {}).get("SessionStart", [])
observers_present = (
any(any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in pre_compact)
and any(item.get("matcher") == "compact" and any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in session_start)
)
denied = subprocess.run( denied = subprocess.run(
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"], ["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
input=json.dumps({"tool_name": "Bash"}) + "\\n", input=json.dumps({"tool_name": "Bash"}) + "\\n",
@@ -553,11 +838,12 @@ is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
result = { result = {
"session_id": session_id, "session_id": session_id,
"hook_present": hook_present, "hook_present": hook_present,
"observers_present": observers_present,
"denied": denied, "denied": denied,
"is_yolo": is_yolo, "is_yolo": is_yolo,
} }
print(json.dumps(result)) print(json.dumps(result))
raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_present and denied else 1)
`; `;
await writeFile(fakeClaude, probe, { mode: 0o700 }); await writeFile(fakeClaude, probe, { mode: 0o700 });
await chmod(fakeClaude, 0o700); await chmod(fakeClaude, 0o700);
@@ -621,6 +907,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
expect(JSON.parse(String(execution!.stdout))).toEqual({ expect(JSON.parse(String(execution!.stdout))).toEqual({
session_id: expect.stringMatching(/^[a-f0-9]{64}$/), session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
hook_present: true, hook_present: true,
observers_present: true,
denied: true, denied: true,
is_yolo: yolo, is_yolo: yolo,
}); });
@@ -636,7 +923,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2); expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2);
const pending = await beginVerification(socket, sessionId, 'claude'); const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!); await promote(socket, sessionId, pending.receipt_challenge!);
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {

Some files were not shown because too many files have changed in this diff Show More