Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cc37bcf6 | ||
|
|
20d86e392b | ||
|
|
4b8eba95a3 | ||
|
|
3d0a882a63 | ||
|
|
1c3e79a9ed | ||
|
|
46f52eedfe | ||
|
|
df83a9eec2 | ||
|
|
d99ff57e14 | ||
|
|
06046f7675 | ||
|
|
f8d04d1bf4 | ||
|
|
029af418f0 | ||
|
|
51746f44eb | ||
|
|
1bfd0ddd71 | ||
|
|
a3cacac7fb | ||
|
|
b4578dcd0a | ||
|
|
b1254f52f3 | ||
|
|
2a2a87251a | ||
|
|
e6a881a795 | ||
|
|
7962e4302f | ||
|
|
8a901cc19a | ||
|
|
8b7ac5b51e | ||
|
|
96609bdade | ||
|
|
e3a0ee87b3 |
@@ -46,10 +46,28 @@ steps:
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
||||
# is undiscoverable from inside a session, and an agent that cannot learn a
|
||||
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
||||
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
|
||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
# a guard that over-blocks gets routed around, which fails just as hard.
|
||||
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
|
||||
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
|
||||
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
|
||||
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
|
||||
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
|
||||
# output. A repo only reaches that once it has enough worktrees, so the
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# PRD: Mosaic Stack v0.1.0
|
||||
|
||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
||||
|
||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Owner:** Jason Woltje
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# #1174 — Wrapper guard round 10
|
||||
|
||||
## Objective
|
||||
|
||||
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
|
||||
2. Add RED fixtures before production changes.
|
||||
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
|
||||
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
|
||||
|
||||
## Progress and evidence
|
||||
|
||||
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
|
||||
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
|
||||
- GREEN: expanded suite passes 242/242.
|
||||
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
|
||||
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
|
||||
- Historical discrimination with the 242-fixture suite:
|
||||
- `3d0a882a`: 216 pass / 26 fail.
|
||||
- `4b8eba95`: 222 pass / 20 fail.
|
||||
- `20d86e39`: 234 pass / 8 fail.
|
||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
||||
|
||||
## Residual / risk
|
||||
|
||||
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
|
||||
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
|
||||
@@ -1,71 +0,0 @@
|
||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
||||
|
||||
## Decision
|
||||
|
||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
||||
|
||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
||||
|
||||
## Classification
|
||||
|
||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
||||
|
||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
||||
|
||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
||||
|
||||
## Current host analysis (observation only; no refresh performed)
|
||||
|
||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
||||
|
||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
||||
|
||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
||||
|
||||
## Reviewed refresh command — analyse only, do not run during active seats
|
||||
|
||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
MOSAIC_SYNC_ONLY=1 \
|
||||
MOSAIC_INSTALL_MODE=keep \
|
||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
||||
```
|
||||
|
||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
||||
|
||||
```bash
|
||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
||||
```
|
||||
|
||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
||||
|
||||
## Post-refresh verification
|
||||
|
||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
||||
|
||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
||||
|
||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
||||
@@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
||||
- Do not auto-resolve data conflicts in shared state files.
|
||||
- Keep commits scoped to a single logical change set.
|
||||
|
||||
## Model Tiering
|
||||
|
||||
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
||||
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
||||
produces a review that passes and proves nothing. Both are defects.
|
||||
|
||||
Tiers are named by **capability class**, so the standard survives a model
|
||||
generation. An operator binds each class to a concrete model id.
|
||||
|
||||
| Class | Use for |
|
||||
| ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
||||
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
||||
| `judge` | code review, planning, API/compat-sensitive changes |
|
||||
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
||||
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
||||
which is usually the most expensive one.
|
||||
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
||||
with an existing contract is judged, not just built.
|
||||
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
||||
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
||||
by someone other than the author. "It seemed fine" is not evidence.
|
||||
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
||||
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
||||
|
||||
### Where the binding lives
|
||||
|
||||
The class→model map is operator configuration, never framework source: model
|
||||
availability, cost, and quotas differ per operator and per host.
|
||||
|
||||
Resolution order, first hit wins:
|
||||
|
||||
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
||||
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
||||
injects it)
|
||||
3. the framework default — the class names above, with no binding
|
||||
|
||||
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
||||
and 3 exist so a host with no config service still runs. A local override that
|
||||
silently disagrees with the config service is drift — the same failure class the
|
||||
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
||||
|
||||
## Prompting Contract
|
||||
|
||||
All runtime adapters should inject:
|
||||
|
||||
@@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
||||
|
||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
|
||||
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
||||
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
||||
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
||||
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
||||
|
||||
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
||||
supply it explicitly on any host where the provider CLI's default account is an admin.
|
||||
|
||||
| Issues | |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
||||
| `issue-view.sh` | Show one issue |
|
||||
| `issue-list.sh` | List issues |
|
||||
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
||||
| `issue-comment.sh` | Add a comment |
|
||||
| `issue-assign.sh` | Assign or unassign |
|
||||
| `issue-close.sh` | Close an issue |
|
||||
| `issue-reopen.sh` | Reopen a closed issue |
|
||||
|
||||
| Pull requests | |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `pr-create.sh` | Open a pull request |
|
||||
| `pr-view.sh` | Show one PR |
|
||||
| `pr-list.sh` | List PRs |
|
||||
| `pr-diff.sh` | Fetch a PR's diff |
|
||||
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
||||
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
||||
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
||||
| `pr-merge.sh` | Merge a PR |
|
||||
| `pr-close.sh` | Close a PR without merging |
|
||||
|
||||
| Milestones | |
|
||||
| --------------------- | ------------------ |
|
||||
| `milestone-create.sh` | Create a milestone |
|
||||
| `milestone-list.sh` | List milestones |
|
||||
| `milestone-close.sh` | Close a milestone |
|
||||
|
||||
| Gates and guards | |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
||||
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
||||
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
||||
|
||||
| Context | |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
||||
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
||||
|
||||
| Workspace | |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
||||
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
||||
|
||||
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
||||
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
||||
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
||||
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
||||
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
||||
anyway, under five simultaneous conventions on a single host.
|
||||
|
||||
```bash
|
||||
# Issues
|
||||
~/.config/mosaic/tools/git/issue-create.sh
|
||||
~/.config/mosaic/tools/git/issue-close.sh
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
||||
```
|
||||
|
||||
# PRs
|
||||
~/.config/mosaic/tools/git/pr-create.sh
|
||||
~/.config/mosaic/tools/git/pr-merge.sh
|
||||
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
||||
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
||||
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
||||
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
||||
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
||||
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
||||
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
||||
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
||||
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
||||
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
||||
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
||||
a missing flag — extend the wrapper.
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/issue-create.sh --help
|
||||
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
||||
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
||||
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
||||
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
||||
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
@@ -52,6 +52,16 @@
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -69,7 +69,7 @@ _manifest_glob_to_ere() {
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="${out}[^/]*"
|
||||
out="$out[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
@@ -87,8 +87,7 @@ _manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re
|
||||
re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
@@ -184,10 +183,7 @@ _mo_matches() {
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
# Operator exact entries are file carve-outs, not implicit directory
|
||||
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
|
||||
# otherwise one bare directory entry can hide all drift beneath it.
|
||||
[[ "$path" == "$pat" ]] && return 0
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
|
||||
@@ -153,38 +153,6 @@ warn_if_symlink_tree_present() {
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||
# worktree on a fleet host.
|
||||
#
|
||||
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||
# and a decision an agent has to make is a decision that drifts.
|
||||
#
|
||||
# So this script makes NO placement decision available. The caller supplies a
|
||||
# branch name. Every path is DERIVED:
|
||||
#
|
||||
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||
# itself already be a worktree)
|
||||
# REPO_NAME <- basename of the main worktree
|
||||
# REPO_PARENT <- dirname of the main worktree
|
||||
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||
# SLUG <- branch with '/' replaced by '-'
|
||||
# WT_PATH <- $WT_ROOT/$SLUG
|
||||
#
|
||||
# The derivation puts the worktree on the same filesystem as the object store
|
||||
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||
# properties that make the checkout cheap and — via `git worktree list` —
|
||||
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||
# mosaic-worktree.sh list this repo's worktrees + state
|
||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||
#
|
||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||
# commits absent from every remote, or holding ignored files that are not of the
|
||||
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||
# by size or age. --force overrides it and is yours to type deliberately.
|
||||
#
|
||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
REPO_HINT=""
|
||||
ARGS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||
shift || true
|
||||
|
||||
# ---- mechanical derivation -------------------------------------------------
|
||||
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||
# would nest worktrees inside worktrees.
|
||||
resolve_repo() {
|
||||
local start="${REPO_HINT:-$PWD}"
|
||||
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
|| die "not inside a git repository: $start"
|
||||
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
|
||||
# first match closes the read end while git is still writing, git takes SIGPIPE,
|
||||
# and under `set -euo pipefail` the command substitution returns 141 and this
|
||||
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
|
||||
# printing nothing at all.
|
||||
#
|
||||
# Whether it happens depends on how much git still had to write when awk left,
|
||||
# so the failure is a function of REPO SIZE: fine on a repo with three
|
||||
# worktrees, reliably broken on one with seventy. That is backwards — the repos
|
||||
# this helper exists to serve are exactly the ones that accumulated worktrees,
|
||||
# and it silently did nothing on those while working everywhere it was tried.
|
||||
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
|
||||
#
|
||||
# The file's own comment block below already names this class for `head -200`
|
||||
# and removed that cap for the same reason. The `exit` here is the same defect
|
||||
# in the same file, so the rule is now uniform: nothing in this script closes a
|
||||
# git pipe early. Dropping `exit` costs one pass over a few KB.
|
||||
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
|
||||
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||
}
|
||||
|
||||
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||
|
||||
derive_path() {
|
||||
local branch="$1"
|
||||
[ -n "$branch" ] || die "branch name required"
|
||||
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||
}
|
||||
|
||||
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||
# silently produce the layout we are trying to eliminate.
|
||||
assert_not_home() {
|
||||
local p="$1" home_real repo_real
|
||||
home_real="$(cd "$HOME" && pwd -P)"
|
||||
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||
case "$repo_real/" in
|
||||
"$home_real"/*)
|
||||
die "refusing: derived path is under \$HOME ($p).
|
||||
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||
configuration, credentials, state and caches — not checkouts." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- work-loss evidence ----------------------------------------------------
|
||||
# Two independent questions, both answered from git, neither from size or age:
|
||||
# dirty — anything uncommitted in the tree
|
||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||
# precious — IGNORED files git will not mention and will not miss
|
||||
#
|
||||
# The third question is not obvious and was missed on the first pass. An
|
||||
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||
# is also why nothing else is holding a copy.
|
||||
#
|
||||
# So ignored files count as work unless they are the well-known regenerable
|
||||
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||
# The list stays short and conservative for that reason.
|
||||
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||
|
||||
# These three run under `set -euo pipefail` inside command substitution, which
|
||||
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
||||
# silently. Two ways that bites, one of which shipped:
|
||||
#
|
||||
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
||||
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
||||
# `rm` exit 1 with no message and no removal — found by review.
|
||||
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
||||
# with 201 dirty files into the same silent abort. Not reported; it is the
|
||||
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
||||
# the cap only ever protected output that is now never printed.
|
||||
#
|
||||
# Every one of them therefore ends in a total, and every stage that can
|
||||
# legitimately exit nonzero says so explicitly.
|
||||
wt_dirty() {
|
||||
local out
|
||||
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
||||
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
||||
}
|
||||
|
||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
||||
|
||||
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||
# directory entry instead of being enumerated and then discarded.
|
||||
wt_precious() {
|
||||
local ignored
|
||||
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||
| awk '/^!! /{print substr($0,4)}' || true)"
|
||||
[ -n "$ignored" ] || { printf '0'; return 0; }
|
||||
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
||||
}
|
||||
|
||||
wt_state() {
|
||||
local wt="$1" d u p
|
||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||
else
|
||||
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- commands --------------------------------------------------------------
|
||||
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||
|
||||
cmd_new() {
|
||||
local branch="${1:-}" base=""
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WT_ROOT"
|
||||
|
||||
# Existing branch -> check it out. New branch -> create from base (default:
|
||||
# the remote's default branch if resolvable, else current HEAD).
|
||||
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||
else
|
||||
if [ -z "$base" ]; then
|
||||
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||
[ -n "$base" ] || base="HEAD"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
worktree: $path
|
||||
branch: $branch
|
||||
|
||||
Removal is part of this task, not a later chore. When the work is pushed:
|
||||
mosaic-worktree.sh rm $branch
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
resolve_repo
|
||||
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local branch="${1:-}" force=0
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
[ -d "$path" ] || die "no worktree at $path"
|
||||
|
||||
local d u p
|
||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||
die "refusing to remove $path
|
||||
uncommitted files: $d
|
||||
unpushed commits: $u
|
||||
ignored, not disposable: $p
|
||||
Commit and push first — that is the contract. Ignored files are counted because
|
||||
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||
If this work is genuinely disposable, re-run with --force."
|
||||
fi
|
||||
|
||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||
if [ "$force" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||
else
|
||||
git -C "$MAIN_WT" worktree remove "$path"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
echo "removed: $path"
|
||||
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_gc() {
|
||||
local apply=0
|
||||
[ "${1:-}" = "--apply" ] && apply=1
|
||||
resolve_repo
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && continue
|
||||
local_state="$(wt_state "$wt")"
|
||||
case "$local_state" in
|
||||
SAFE*)
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||
else
|
||||
echo "reclaimable (clean + fully pushed): $wt"
|
||||
fi ;;
|
||||
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||
esac
|
||||
done
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
new) cmd_new "$@" ;;
|
||||
path) cmd_path "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
rm) cmd_rm "$@" ;;
|
||||
gc) cmd_gc "$@" ;;
|
||||
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||
esac
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
AUTH_CONFIG=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
||||
}
|
||||
terminate() {
|
||||
local signal="$1"
|
||||
trap - "$signal"
|
||||
cleanup
|
||||
kill -s "$signal" "$$"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'terminate HUP' HUP
|
||||
trap 'terminate INT' INT
|
||||
trap 'terminate TERM' TERM
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Edit a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER Pull request number (required)
|
||||
-t, --title TITLE New title
|
||||
-b, --body BODY New body/description
|
||||
-B, --base BRANCH New base branch
|
||||
--draft Mark the pull request as draft
|
||||
--ready Mark the pull request ready for review
|
||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
||||
-r, --repo OWNER/REPO Explicit target repository
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
PLATFORM="gitea"
|
||||
else
|
||||
PLATFORM=$(detect_platform)
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||
CMD=(gh pr edit "$PR_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
gh pr ready "$PR_NUMBER"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
||||
[[ -n "$IDENTITY" ]] || {
|
||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
||||
exit 1
|
||||
}
|
||||
HOST="${HOST_OVERRIDE:-}"
|
||||
if [[ -z "$HOST" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
||||
if [[ -z "$REPO_SLUG" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
||||
else
|
||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
||||
echo "Error: could not stage private Gitea authentication" >&2
|
||||
exit 1
|
||||
}
|
||||
unset TOKEN
|
||||
|
||||
API_BASE="https://${HOST}/api/v1"
|
||||
# Resolve identity through the SAME private curl config used for the
|
||||
# mutation. Tea login names are globally scoped and can be duplicated
|
||||
# across hosts; a separate `tea api --login NAME` could validate another
|
||||
# credential than this host-bound token.
|
||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
||||
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = {}
|
||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
||||
;;
|
||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
@@ -18,7 +18,6 @@ WRAPPERS=(
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-edit.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
@@ -48,7 +47,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for.
|
||||
#
|
||||
# resolve_repo() took the first line of `git worktree list --porcelain` with
|
||||
# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end
|
||||
# of the pipe while git is still writing, git takes SIGPIPE, and under
|
||||
# `set -euo pipefail` the command substitution returns 141 — so the assignment
|
||||
# fails, `set -e` aborts the function, and the script dies printing NOTHING. No
|
||||
# message, no path, no worktree, exit 141.
|
||||
#
|
||||
# What makes it worth a dedicated test rather than a fixture line is WHEN it
|
||||
# fires. If git finishes writing before awk leaves, there is no SIGPIPE and
|
||||
# everything works. So the failure is a function of how much porcelain the repo
|
||||
# produces: invisible on a three-worktree repo, reliable on a seventy-worktree
|
||||
# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141,
|
||||
# no output — and it had passed every hand-check before that, on small repos.
|
||||
#
|
||||
# A test that ran `git worktree list` against whatever repo it happens to sit in
|
||||
# would inherit that same size dependence and would have PASSED on the tree that
|
||||
# was broken. So git is stubbed on PATH and made to emit a large porcelain
|
||||
# stream, which turns "depends on the repo you are standing in" into "always".
|
||||
#
|
||||
# Exit: 0 = the helper resolved the repo · 1 = it did not
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOL="${1:-$HERE/mosaic-worktree.sh}"
|
||||
[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir -p "$TMP/bin"
|
||||
|
||||
# The stub answers exactly the two calls resolve_repo makes, and answers the
|
||||
# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the
|
||||
# writer is still writing when a reader that quits early goes away. Anything
|
||||
# else exits non-zero rather than pretending to be git.
|
||||
cat > "$TMP/bin/git" <<'STUB'
|
||||
#!/bin/sh
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in -C) shift 2 ;; *) break ;; esac
|
||||
done
|
||||
case "$*" in
|
||||
"rev-parse --git-dir")
|
||||
echo .git; exit 0 ;;
|
||||
"worktree list --porcelain")
|
||||
# The first entry is the main worktree. That single line is all the helper
|
||||
# needs, and it is exactly what it stopped receiving.
|
||||
printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0
|
||||
awk 'BEGIN{ for (i = 0; i < 4000; i++)
|
||||
printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }'
|
||||
# NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail
|
||||
# in the caller is what turns that into the silent abort. A stub that exits 0
|
||||
# regardless hands the caller a clean status and the probe passes on the
|
||||
# broken tree — which is how this test failed to be a test on its first run.
|
||||
exit $? ;;
|
||||
esac
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$TMP/bin/git"
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
local why="$1" want="$2" got="$3"
|
||||
if [ "$want" = "$got" ]; then
|
||||
printf 'ok %s\n' "$why"
|
||||
else
|
||||
printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)"
|
||||
rc=$?
|
||||
|
||||
# Both halves are asserted. rc alone would pass if the helper started printing a
|
||||
# usage error, and output alone would miss a non-zero exit — and the defect's
|
||||
# signature is precisely a non-zero exit with no output, which only the pair
|
||||
# distinguishes from every other way this could go wrong.
|
||||
check 'resolving a repo with a large worktree list exits 0' 0 "$rc"
|
||||
check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out"
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf 'mosaic-worktree: resolves against a large porcelain stream.\n'
|
||||
else
|
||||
cat <<'EOF'
|
||||
mosaic-worktree could not resolve the repository.
|
||||
|
||||
An empty output with a non-zero exit is the SIGPIPE signature: a reader that
|
||||
quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns
|
||||
that into a silent abort. Nothing in this script may close a git pipe early.
|
||||
EOF
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for secret-safe, identity-bound PR editing and explicit targets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
|
||||
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
|
||||
XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
|
||||
rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$XDG_DIR/tea/config.yml" <<'YAML'
|
||||
logins:
|
||||
- name: usc-coder3
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-usc-token
|
||||
- name: same-host-other
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-other-token
|
||||
- name: mosaic-coder3
|
||||
url: https://git.mosaicstack.dev
|
||||
token: fixture-mosaic-token
|
||||
YAML
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Deliberately misleading duplicate-name response: the wrapper must never use
|
||||
# tea for identity validation because its name lookup is not host-bound.
|
||||
[[ "$*" == "api --login duplicate /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
||||
exit 1
|
||||
SH
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${*: -1}" == */user ]]; then
|
||||
printf '{"login":"%s"}\n' "${MOSAIC_STUB_AUTH_USER:-coder3}"
|
||||
elif [[ "${*: -1}" == */repos/* && " $* " != *" -X PATCH "* ]]; then
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "TERM" ]] && { kill -TERM "$PPID"; sleep 1; }
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "INT" ]] && { kill -INT "$PPID"; sleep 1; }
|
||||
printf '{"name":"repo"}\n'
|
||||
else
|
||||
printf '{"number":42,"draft":false}\n'
|
||||
fi
|
||||
SH
|
||||
cat > "$BIN_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
|
||||
|
||||
run_wrapper() {
|
||||
(cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
|
||||
}
|
||||
assert_no_secret() {
|
||||
! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
|
||||
}
|
||||
|
||||
# The explicit target differs from CWD origin and must govern BOTH host and slug.
|
||||
: > "$LOG_FILE"
|
||||
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
|
||||
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
|
||||
python3 - "$LOG_FILE" <<'PY'
|
||||
import json, pathlib, sys
|
||||
lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
|
||||
assert len(lines) == 3, lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/user" in lines[0], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[1], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[2], lines
|
||||
assert all("--config" in line for line in lines), lines
|
||||
assert "Authorization:" not in "\n".join(lines), lines
|
||||
payload = lines[2].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
|
||||
assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
|
||||
PY
|
||||
assert_no_secret
|
||||
|
||||
# Ready maps to false and still preflights before the write.
|
||||
: > "$LOG_FILE"
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
|
||||
grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
|
||||
|
||||
# Identity is mandatory; no ambient/first-host login can write.
|
||||
: > "$LOG_FILE"
|
||||
if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
|
||||
|
||||
# Explicit and ambient same-host wrong principals both refuse after identity
|
||||
# lookup but before repo preflight/PATCH. The /user read is expected curl #1.
|
||||
for mode in explicit ambient; do
|
||||
: > "$LOG_FILE"
|
||||
if [[ "$mode" == explicit ]]; then
|
||||
cmd=(--login same-host-other)
|
||||
else
|
||||
cmd=(); export GITEA_LOGIN=same-host-other
|
||||
fi
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "$mode wrong identity wrote" >&2; exit 1
|
||||
fi
|
||||
unset GITEA_LOGIN
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "$mode wrong identity passed identity lookup" >&2; exit 1; }
|
||||
! grep -q '/repos/' "$LOG_FILE" || { echo "$mode wrong identity reached repo preflight/PATCH" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Set identity with no explicit/ambient login refuses rather than selecting first host login.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Missing login selected a principal" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
|
||||
|
||||
# Split-credential probe for the duplicate-name cross-host seam: tea's
|
||||
# name-only /user would report coder3, while the selected host-bound curl token
|
||||
# reports other. The wrapper must trust only the latter handle used by PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 \
|
||||
-r mosaicstack/stack -H git.mosaicstack.dev --draft >/dev/null 2>&1; then
|
||||
echo "Duplicate-name split credential reached PATCH" >&2; exit 1
|
||||
fi
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "Duplicate-name identity mismatch passed /user" >&2; cat "$LOG_FILE" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "Duplicate-name mismatch mutated" >&2; exit 1; }
|
||||
|
||||
# TERM and INT during repo preflight clean up, do not mutate, and return the
|
||||
# signal status rather than swallowing termination into success.
|
||||
for sig in TERM INT; do
|
||||
: > "$LOG_FILE"
|
||||
set +e
|
||||
MOSAIC_STUB_SIGNAL="$sig" MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 \
|
||||
-r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
[[ "$rc" -ne 0 ]] || { echo "$sig was swallowed into success" >&2; exit 1; }
|
||||
[[ "$rc" -eq 143 || "$rc" -eq 130 ]] || { echo "$sig returned unexpected status $rc" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "$sig continued into PATCH" >&2; exit 1; }
|
||||
assert_no_secret
|
||||
done
|
||||
|
||||
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Cross-host login wrote" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
|
||||
|
||||
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
|
||||
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
|
||||
run_wrapper --help 2>&1 | grep -q '^Usage:'
|
||||
|
||||
# GitHub retains provider-native edit/readiness behavior.
|
||||
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
|
||||
: > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
|
||||
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
|
||||
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
|
||||
echo "PR edit regression harness passed"
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
||||
#
|
||||
# Resolves no credentials, touches no network, and creates no repository: the
|
||||
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
||||
# contract is testable from fixtures.
|
||||
#
|
||||
# The fixtures are written to a temp file rather than passed inline, and this is
|
||||
# not stylistic. The guard inspects the literal text of the Bash command it is
|
||||
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
||||
# trips the guard on the harness instead of on the fixture — which is exactly
|
||||
# what happened the first time this was checked by hand. Substring matching over
|
||||
# whole command text is the guard's deliberate fail-closed posture; a test that
|
||||
# does not account for it silently measures the wrong thing.
|
||||
#
|
||||
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
||||
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
FIXTURES="$TMP/fixtures.tsv"
|
||||
|
||||
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
||||
# [ TAB <substring the block message must contain> ]
|
||||
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
|
||||
# itself gets checked; without it a block is only asserted to have happened,
|
||||
# not to have been useful.
|
||||
{
|
||||
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
|
||||
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
|
||||
# Path words use the same quote/escape state machine as names, but preserve
|
||||
# substitutions so HOME remains visible. Quotes do not split the path word.
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
|
||||
# The target may be HOME itself. End-of-command and whitespace terminate the
|
||||
# token just as a slash does; punctuation that can extend a path does not.
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
|
||||
# Unquoted POSIX metacharacters terminate the target word even without spaces.
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
|
||||
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
|
||||
# Quote removal must not create an expansion the shell never performs.
|
||||
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
||||
# Round ten: placement is decided by the destination and the one clone option
|
||||
# that creates repository state elsewhere, not by every HOME-valued word in
|
||||
# the command. Sources, templates, references, and environment are not targets.
|
||||
printf '0\t{"tool_input":{"command":"NOTE=$HOME git clone https://example.invalid/x /src/wt"}}\tan unrelated assignment carrying HOME is not checkout placement\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --reference=$HOME https://example.invalid/x /src/wt"}}\ta HOME reference is an object source, not checkout placement\n'
|
||||
printf '0\t{"tool_input":{"command":"GIT_DIR=$HOME/x git clone https://example.invalid/x /src/wt"}}\tclone does not place its destination from ambient GIT_DIR\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --template=$HOME/t https://example.invalid/x /src/wt"}}\ta HOME template source is not checkout placement\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=$HOME/gd https://example.invalid/x /src/wt"}}\tseparate-git-dir explicitly places repository state under HOME\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir $HOME/gd https://example.invalid/x /src/wt"}}\tthe space-separated placement option is equivalent\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --reason=$HOME/note /src/wt"}}\ta worktree reason is metadata, not its path\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone $HOME/source /src/wt"}}\ta HOME source with an explicit safe destination is not placement\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x /src/wt"}}\ta space-separated HOME reference remains a source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --template $HOME/t https://example.invalid/x /src/wt"}}\ta space-separated HOME template remains a source\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone 2>/dev/null https://example.invalid/x $HOME/wt"}}\ta redirection before clone arguments does not become the destination\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x $HOME/wt"}}\ta source option does not hide a later HOME destination\n'
|
||||
# Routing this arm through the shared name site also repaired an over-block it
|
||||
# had carried from the start: the old whole-command regex found `git` INSIDE a
|
||||
# longer word, so these two were refused at every head before this commit.
|
||||
# Same class as mycurl and curl-wrapper, and refusing them is how a guard gets
|
||||
# routed around instead of repaired.
|
||||
printf '0\t{"tool_input":{"command":"mygit clone https://example.invalid/x $HOME/wt"}}\tmygit is a different program and its checkout is not ours\n'
|
||||
printf '0\t{"tool_input":{"command":"gitfoo clone https://example.invalid/x $HOME/wt"}}\tthe name has to end where git ends\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
||||
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
||||
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
||||
# --- bypasses an independent reviewer demonstrated against the first version.
|
||||
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
||||
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
||||
# the guard reads text, so every spelling it does not know is a hole.
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
||||
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
||||
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
||||
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
||||
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
||||
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
||||
# amount of fragment matching, because the endpoint does not exist until the
|
||||
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
||||
# read, rather than pretending it read one.
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
||||
# --- round six changed the contract in this direction, and these fixtures are
|
||||
# where it shows. They used to assert that discussing a call is not making one.
|
||||
# Five rounds proved there is no textual way to tell a quoted example from a
|
||||
# quoted command, so the guard stopped trying: it judges the payload, and a
|
||||
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
||||
# command line is now refused, and the way to write the example is a
|
||||
# file-writing tool. This is the deliberate cost of the mechanism change.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
||||
# The boundary that keeps this from being "block everything": what is refused
|
||||
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
||||
# these are asserted as hard as the blocks above.
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
||||
# Command position must still catch the real thing behind operators and env.
|
||||
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
||||
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
||||
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
||||
# to be parsed away; under this one it is the documented cost, and the message
|
||||
# gets composed with a file-writing tool instead.
|
||||
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
||||
# ...but quotes stop being data the moment something executes them.
|
||||
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
||||
# --- round three. Each of these four is a real write that a bare-name match
|
||||
# for the client could not see, because an ordinary word sat in front of it.
|
||||
# They are kept as fixtures after the mechanism change even though the guard no
|
||||
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
||||
# and a future re-narrowing that reintroduced position would fail here first.
|
||||
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
||||
# A shell standing between quoted data and execution makes that data code,
|
||||
# and the pipe is the form agents actually use. Filing it as data allowed the
|
||||
# call to vanish from the skeleton while still running.
|
||||
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
||||
# ...and the questions that used to follow — is the pipe target a shell, does a
|
||||
# shell on one line execute a string on another — no longer have to be answered
|
||||
# at all. Both of these carry the payload, both are refused, and neither
|
||||
# outcome depends on parsing what the pipe or the other line does.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
||||
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
||||
# --- round four. The guard was still reading the command as typed rather than
|
||||
# as the shell will run it: a backslash before a newline is removed before
|
||||
# anything else happens, so the endpoint token can be split across the join.
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
||||
# Transparent prefixes take option VALUES, and the value was a word the list
|
||||
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
||||
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
||||
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
||||
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
||||
# Execution through another command needed its own case under the old design.
|
||||
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
||||
# --- round five, and the finding that ended the parser. Command substitution
|
||||
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
||||
# span as inert prose. The unquoted and process-substitution forms already
|
||||
# blocked, which is what made it a classification defect rather than a spelling
|
||||
# one: the same call was refused or allowed depending on a quote character.
|
||||
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
||||
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
||||
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
||||
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
||||
# A client the guard was never taught is the point of dropping client
|
||||
# detection: neither of these names curl at all.
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
||||
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
||||
|
||||
# Round six scoped the guard on `https?://`, and review found the absence shape
|
||||
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
|
||||
# so the guard never reached the write question. These are the reported repros.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
|
||||
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
|
||||
# ...and the boundary that stops a broader scope gate becoming block-everything.
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
|
||||
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
|
||||
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
|
||||
|
||||
# Wrong remediation is its own defect: /issues/1/labels used to block with
|
||||
# "use issue-create.sh", which is not the wrapper for that call. Round seven
|
||||
# answered that by letting EVERY path under a numbered issue or PR through,
|
||||
# and review showed the reasoning ("no wrapper owns these") was false in this
|
||||
# tree. These are the reported repros, all rc 0 before round eight, and each
|
||||
# asserts the wrapper the advice must name — not merely that a block happened.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
|
||||
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
|
||||
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
|
||||
# state=closed, so a title/description/due-date edit is a gap and the message
|
||||
# has to say so — round eight named the wrapper and stopped there.
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
|
||||
# The residue: still genuinely owned by nothing, and still flowing through.
|
||||
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
|
||||
# nothing in the tree adds a requested reviewer.
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
|
||||
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
|
||||
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
|
||||
# for `https?://`, so a split endpoint in the other two was in scope to block,
|
||||
# produced no readable endpoint, and fell through to allow. Same defect class
|
||||
# as the milestone arm, one layer up. Each shape gets its own fixture, because
|
||||
# a single one would have passed on the arm that already worked.
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
|
||||
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
|
||||
# And the reason this is not "any variable blocks a write": a payload in a
|
||||
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
|
||||
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
|
||||
# The arms above the numbered ones must keep blocking, with their own wrappers.
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
|
||||
|
||||
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
|
||||
# which is Gitea's spelling. GitHub's API carries no version segment at all
|
||||
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
|
||||
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
|
||||
# than to what identifies a provider API. `/repos/` is the marker both share.
|
||||
# These endpoints are READABLE, so each asserts the wrapper it must name; a
|
||||
# rc-only fixture here would pass on the unreadable arm and prove nothing.
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
|
||||
# The end-of-options marker, which is the one option not spelled like one.
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
|
||||
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
|
||||
# in the newly admitted shape have to stay allowed, or this is a regression
|
||||
# wearing a fix'"'"'s clothes.
|
||||
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
|
||||
|
||||
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
|
||||
# must never trip it.
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
|
||||
# Documented over-block, pinned so it is a known boundary and not a surprise.
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
|
||||
|
||||
# --- round nine, all four from one adversarial pass, and three of them are
|
||||
# the same shape: a test written over the WHOLE command text deciding an
|
||||
# ALLOW. That is the fail-open form this file keeps rediscovering, and it had
|
||||
# reached the break-glass itself.
|
||||
#
|
||||
# BREAK-GLASS. `case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire
|
||||
# command if that string appeared anywhere in it — so quoting the override in a
|
||||
# note, or naming a variable after it, disabled the guard for the call sitting
|
||||
# beside it. The override is now read POSITIONALLY: leading `NAME=value`
|
||||
# assignments only, exactly where the shell would honour one.
|
||||
printf '2\t{"tool_input":{"command":"echo \\"MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews\\" >> notes.md"}}\tquoting the override in a document does not arm it\n'
|
||||
printf '2\t{"tool_input":{"command":"NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tan assignment whose VALUE is the override is not the override\n'
|
||||
printf '2\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t=10 matched the old substring test and is not the value 1\n'
|
||||
printf '0\t{"tool_input":{"command":"GITEA_TOKEN=$T MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe override still works behind other assignments, as the shell reads it\n'
|
||||
# The cost, pinned rather than discovered later: positional means positional.
|
||||
printf '2\t{"tool_input":{"command":"cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tan override after && is not in command position and does not arm\n'
|
||||
|
||||
# SUBRESOURCE REFINEMENT, same defect one arm lower. It asked whether a
|
||||
# subresource appears ANYWHERE in the command, so a numbered-object write was
|
||||
# cleared on the strength of text in its own BODY. Inverted: clear only when
|
||||
# EVERY numbered-object occurrence carries a subresource.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/pulls/2/files"}}\ta subresource in the body does not clear a write to the numbered issue\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/issues/3/reactions"}}\tsame, quoting a subresource of the same object type\tissue-edit.sh\n'
|
||||
# ...and its documented cost, in the safe direction.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=cf-/issues/2"}}\tan unwrapped subresource write that quotes a bare issue is refused\n'
|
||||
|
||||
# -K/--config. curl reads the method, the body, the headers AND the URL from
|
||||
# that file, so none of them are in the command: every write test above read 0
|
||||
# and the call went through. An unreadable request is not a cleared one.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe request in a config file is unreadable, so it is refused\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -K /tmp/req https://git.example.invalid/api/v1/repos/a/b/issues"}}\tthe short spelling, same answer\t--config/-K\n'
|
||||
# Cost, stated: this refuses a --config read against a host that has nothing
|
||||
# to do with a forge. The alternative is to require a forge marker in a
|
||||
# command whose URL may itself be in the file, which is the hole again.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://example.invalid/anything"}}\tan unrelated https URL with --config is refused too, by decision\t--config/-K\n'
|
||||
printf '0\t{"tool_input":{"command":"eslint --config .eslintrc.json src/"}}\t--config on a command that is not curl is nobody'"'"'s business\n'
|
||||
|
||||
# ROUND TEN. The --config check above was first written INSIDE the API-shape
|
||||
# gate, so it was guarded by a condition that the capability it guards against
|
||||
# removes. A config file can carry the URL; delete the URL from the command and
|
||||
# nothing is API-shaped, the branch is never entered, and the guard reports
|
||||
# clean on precisely the call it exists to refuse. It is now asked of any curl.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/provider-write.cfg"}}\ta config file can own the URL, so there is nothing API-shaped left to gate on\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
|
||||
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
|
||||
# curl by any ordinary path spelling. The first version of the config check
|
||||
# matched the bare word only, so these three executed the same wrapped write
|
||||
# while the guard reported clean. Recognizing only the unqualified name is
|
||||
# caller-name parsing, and that is the class this file exists to refuse.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
|
||||
# The prefix must end at a slash: a basename that merely ENDS in curl is a
|
||||
# different program, and blocking it would be the over-block that gets a guard
|
||||
# routed around rather than fixed.
|
||||
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
|
||||
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
|
||||
|
||||
# And the same name once it is punctuated. The basename repair above fixed the
|
||||
# UNQUOTED path spelling and nothing else, so two quote characters restored the
|
||||
# bypass it had just closed: the check was still modelling one presentation of
|
||||
# a shell word instead of the word. Every one of these executes the real curl.
|
||||
printf '2\t{"tool_input":{"command":"\\"/usr/bin/curl\\" --config /tmp/provider-write.cfg"}}\tquoting a path does not make it a different program\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"'"'"'./curl'"'"' --config /tmp/provider-write.cfg"}}\tnor does quoting a relative one\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"$(which curl) --config /tmp/provider-write.cfg"}}\tthe name is in the text even when a substitution supplies the path\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"`which curl` --config /tmp/provider-write.cfg"}}\tand in the older spelling of the same substitution\t--config/-K\n'
|
||||
|
||||
# The provider-CLI SCOPE gate had the identical defect, untouched while the
|
||||
# curl arm was repaired twice. It decides whether write detection runs at all,
|
||||
# so failing to admit these is indistinguishable from allowing them — and no
|
||||
# URL marker rescues them, because provider CLI paths carry no leading slash.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/issues -f title=x"}}\tan absolute path to a provider CLI is still a provider CLI\n'
|
||||
printf '2\t{"tool_input":{"command":"./gh api -X POST repos/a/b/issues -f title=x"}}\tand a relative one still is too\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/local/bin/tea api -X POST repos/a/b/issues -f title=x"}}\tthe same is true of every CLI the gate names, not just the first\n'
|
||||
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
||||
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
||||
|
||||
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
|
||||
# characters with whitespace, which is token separation and not quote removal:
|
||||
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
|
||||
# one word naming curl while whitespace made it two words naming neither.
|
||||
# `"/usr/bin/curl"` passed under that version only because the inserted space
|
||||
# happened to land after a slash, which established nothing.
|
||||
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
|
||||
# A backslash is NOT uniformly removed. It is literal inside single quotes,
|
||||
# and inside double quotes when it precedes anything other than $, `, ",
|
||||
# backslash, or newline. These spell a different program and must stay allowed.
|
||||
printf '0\t{"tool_input":{"command":"'"'"'cu\\\\rl'"'"' --config /tmp/provider-write.cfg"}}\ta backslash inside single quotes remains literal\n'
|
||||
printf '0\t{"tool_input":{"command":"\\"cu\\\\rl\\" --config /tmp/provider-write.cfg"}}\ta backslash before r inside double quotes remains literal\n'
|
||||
printf '0\t{"tool_input":{"command":"'"'"'g\\\\it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a single-quoted non-git name is not a checkout\n'
|
||||
printf '0\t{"tool_input":{"command":"\\"g\\\\it\\" clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a double-quoted non-git name is not a checkout\n'
|
||||
# The other branch of the same rule: OUTSIDE quotes a backslash escapes the
|
||||
# next character, so an escaped quote is a literal quote IN the name and the
|
||||
# program is not curl. Held separately from the cases above because it is a
|
||||
# different arm of the state machine, and an arm without a fixture is a rule
|
||||
# that is not held.
|
||||
printf '0\t{"tool_input":{"command":"cu\\\\\\"rl\\\\\\" --config /tmp/provider-write.cfg"}}\tan escaped quote is a literal quote in the name\n'
|
||||
# Three quoted segments concatenate into ONE word. This is the shape that
|
||||
# distinguishes quote removal from token separation, so it is worth its own line.
|
||||
printf '2\t{"tool_input":{"command":"\\"cu\\"'"'"'r'"'"'\\"l\\" --config /tmp/provider-write.cfg"}}\tadjacent quoted segments are one word, and that word is curl\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
|
||||
# The FLAG is the same recognition problem as the name, and is read the same
|
||||
# way. No review raised this one; the name was simply the easier half to reach.
|
||||
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
|
||||
|
||||
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
|
||||
# bare-name copy of the scope gate's regex, so a caller could be admitted by
|
||||
# the repaired gate and then go unrecognized by the fail-closed refinement —
|
||||
# a gate and its own refinement disagreeing about who the caller is.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
|
||||
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
|
||||
|
||||
# Shapes nobody raised. Written down because reasoning that they were already
|
||||
# covered is precisely what produced two of the rounds above; each one below
|
||||
# was measured, and the three that fail at df83a9ee are here on that evidence.
|
||||
# `\curl` is the ordinary way to bypass a shell alias and is a thing people
|
||||
# actually type, which makes it the least hypothetical entry in the file.
|
||||
printf '2\t{"tool_input":{"command":"\\\\curl --config /tmp/provider-write.cfg"}}\tescaping the leading character to dodge an alias still names curl\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"cur\\"l\\" --config /tmp/provider-write.cfg"}}\tthe quote may sit at any offset in the word\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST \\"repos/a/b/$EP\\" -f title=x"}}\tboth halves dressed at once, which is where they last disagreed\n'
|
||||
|
||||
# Over-blocking is a real failure and not a safe direction: a guard that
|
||||
# refuses legitimate work gets routed around instead of repaired. These four
|
||||
# pass at both heads, which is what a regression guard is for.
|
||||
printf '0\t{"tool_input":{"command":"gh api \\"repos/a/b/issues\\""}}\ta quoted read is still a read\n'
|
||||
printf '0\t{"tool_input":{"command":"curl https://example.com/file.txt -o /tmp/f"}}\tan ordinary download is not a provider write\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"$EP\\" && gh --version"}}\tno api subcommand, so nothing to refuse\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"not a curl call\\""}}\tthe word inside a string, with no flag, is prose\n'
|
||||
|
||||
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
|
||||
# both returned HTTP 200 with the same object from the live forge, so the
|
||||
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
||||
# sees a segment matching nothing. Refused rather than decoded — a decoder has
|
||||
# to be exactly right about depth and normalization, which is the parser
|
||||
# mistake this file declines everywhere else.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%75es/1/comments -f body=x"}}\tan encoded path segment reaches the wrapped endpoint\tpercent-escape\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/revi%%65ws"}}\tsame for the review endpoint, which is the one that matters most\tpercent-escape\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%2575es/1/comments -f body=x"}}\tdouble-encoded too, which is why this refuses instead of decoding\tpercent-escape\n'
|
||||
# Scoped to writes, deliberately. Reads are never blocked by this guard and a
|
||||
# query string carrying %%20 is an ordinary URL, not a hazard.
|
||||
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
|
||||
} > "$FIXTURES"
|
||||
|
||||
fail=0 n=0
|
||||
while IFS=$'\t' read -r want payload why remedy; do
|
||||
[ -n "${want:-}" ] || continue
|
||||
n=$((n + 1))
|
||||
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
# A block that names the wrong wrapper is a defect in its own right, and until
|
||||
# now it was invisible here: the harness read the exit code and nothing else,
|
||||
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
||||
# six rounds. Where a fixture states the remediation it expects, assert it.
|
||||
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
||||
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
done < "$FIXTURES"
|
||||
|
||||
# ---- $HOME resolution ------------------------------------------------------
|
||||
# These cannot be fixtures. Every case above varies the COMMAND; this defect
|
||||
# varies the ENVIRONMENT, and the loop has no way to express that.
|
||||
#
|
||||
# The checkout arm built its pattern from "$HOME" without asking whether $HOME
|
||||
# was a usable value. Three values it is not: unset (which is a crash under
|
||||
# `set -u`, not a decision), empty (the pattern collapses to `/`, so `~|\$HOME|`
|
||||
# matches whatever the empty alternative touches), and "/" (every absolute path
|
||||
# is under it, so the comparison stops discriminating). An agent seat running
|
||||
# with no HOME — a systemd unit without one, a container, `env -i` — got the
|
||||
# checkout question answered by accident rather than on the merits.
|
||||
#
|
||||
# The fix resolves $HOME once, rejects all three, and BLOCKS the checkout it
|
||||
# cannot adjudicate. A guard may not clear a question it was unable to ask. The
|
||||
# blast radius of that fail-closed arm is asserted below to be one command shape
|
||||
# and not the session: with no HOME at all, ordinary commands still pass and the
|
||||
# API arms still block.
|
||||
home_case() {
|
||||
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||
local out got
|
||||
n=$((n + 1))
|
||||
local payload
|
||||
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
|
||||
if [ "$homeval" = "@unset" ]; then
|
||||
out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
|
||||
else
|
||||
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
|
||||
fi
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
}
|
||||
|
||||
home_case 'HOME unset: a checkout is refused, not adjudicated' \
|
||||
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'HOME empty: same, and it is not the same thing as unset' \
|
||||
2 '' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'HOME=/ : every path is under it, so it discriminates nothing' \
|
||||
2 '/' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'a usable HOME still allows a checkout onto a work filesystem' \
|
||||
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
|
||||
home_case 'a usable HOME still catches the literal path' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
|
||||
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
|
||||
home_case 'quotes around the exact literal HOME path do not change the target' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under'
|
||||
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
|
||||
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
|
||||
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
|
||||
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
|
||||
# HOME would have every command it runs refused, which is how a guard gets
|
||||
# disabled rather than fixed.
|
||||
home_case 'HOME unset does not block an ordinary command' \
|
||||
0 '@unset' 'ls -la /src'
|
||||
home_case 'HOME unset does not stop the API arms doing their job' \
|
||||
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||
|
||||
# ---- the guard standing on its own -----------------------------------------
|
||||
# Every case above runs the guard from the directory holding its siblings, so
|
||||
# `[ -x "$W/pr-review.sh" ]` succeeds and the $HOME fallback beside it never
|
||||
# evaluates. That is a property of the HARNESS, not of the guard, and it hid a
|
||||
# live fail-open: with the guard copied somewhere alone AND no HOME, the
|
||||
# fallback expanded an unset variable under `set -u` and the script died at
|
||||
# rc=1 — on EVERY arm, before any adjudication. A PreToolUse hook exiting
|
||||
# nonzero-but-not-2 is a non-blocking error, so that seat ran with no guard and
|
||||
# nothing reported it.
|
||||
#
|
||||
# The first remediation moved that expansion four lines earlier and called it
|
||||
# closed. It was not closed, because the test could not reach it. So the guard
|
||||
# is copied ALONE here — no siblings, no installed mosaic home — which is the
|
||||
# deployment this file already claims to support ("still works from a repo
|
||||
# checkout with no installed mosaic home").
|
||||
LONE="$TMP/lone"; mkdir -p "$LONE"
|
||||
cp "$GUARD" "$LONE/wrapper-guard.sh"; chmod +x "$LONE/wrapper-guard.sh"
|
||||
|
||||
lone_case() {
|
||||
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||
local out got
|
||||
n=$((n + 1))
|
||||
if [ "$homeval" = "@unset" ]; then
|
||||
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$LONE/wrapper-guard.sh" 2>&1)"
|
||||
else
|
||||
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$LONE/wrapper-guard.sh" 2>&1)"
|
||||
fi
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s [standalone] (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
printf 'ok %s [standalone]\n' "$why"
|
||||
}
|
||||
|
||||
lone_case 'no siblings and no HOME: an ordinary command still passes, not rc=1' \
|
||||
0 '@unset' 'ls -la /src'
|
||||
lone_case 'no siblings and no HOME: a wrapped write is still refused' \
|
||||
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||
lone_case 'no siblings and no HOME: a checkout is refused, not adjudicated' \
|
||||
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
# Spelled without quotes on purpose. The payload is interpolated into a JSON
|
||||
# string by the helper, so a fixture carrying bare double quotes produces
|
||||
# malformed JSON, jq returns empty, and the guard exits 0 on an empty command —
|
||||
# a PASS that measures nothing. That is what the first version of this case did.
|
||||
lone_case 'no siblings and no HOME: the APPROVE trap still fires' \
|
||||
2 '@unset' 'gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE'
|
||||
lone_case 'no siblings, usable HOME: ordinary commands unaffected' \
|
||||
0 '/home/tester' 'ls -la /src'
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
||||
else
|
||||
cat <<'EOF'
|
||||
wrapper-guard drifted from its contract.
|
||||
|
||||
A guard that blocks too much gets routed around, and a guard that blocks too
|
||||
little is decoration. Both directions are failures here, which is why the
|
||||
allowed cases are asserted as hard as the blocked ones.
|
||||
EOF
|
||||
fi
|
||||
exit "$fail"
|
||||
+1031
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-tools-index.sh — assert every shipped tool is discoverable from the
|
||||
# resident documentation an agent actually has in context.
|
||||
#
|
||||
# WHY THIS GATE EXISTS
|
||||
# --------------------
|
||||
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
|
||||
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
|
||||
# undocumented ones was `pr-review.sh` — the wrapper that carries the
|
||||
# APPROVED/APPROVE provider-dialect split.
|
||||
#
|
||||
# The observable consequence, on a live fleet host: an agent needing to place a
|
||||
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
|
||||
# host, and got HTTP 200 with the review silently filed PENDING — three times,
|
||||
# because nothing about the failure pointed at the wrapper that already handled
|
||||
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
|
||||
# an index that said the tool did not exist.
|
||||
#
|
||||
# That is not a discipline problem and no amount of prose fixes it. A wrapper
|
||||
# that is not in the resident index is, from inside a session, indistinguishable
|
||||
# from a wrapper that was never written. So the invariant is mechanical:
|
||||
#
|
||||
# shipping a tool and documenting it are the same commit, or CI fails.
|
||||
#
|
||||
# WHAT IT CHECKS
|
||||
# --------------
|
||||
# forward every non-excluded tool in an ENFORCED suite is named in at least
|
||||
# one index document (missing tool -> undiscoverable -> FAIL)
|
||||
# reverse every `<name>.sh` an index document attributes to an enforced
|
||||
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
|
||||
#
|
||||
# Suites outside the enforced set are reported with a coverage percentage but do
|
||||
# not fail the build, so the ratchet can be tightened one suite per PR instead of
|
||||
# landing as one unreviewable sweep. `--strict` fails on those too.
|
||||
#
|
||||
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
|
||||
# -------------------------------------------------------------------
|
||||
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
|
||||
# would let an operator silence this gate by editing their own copy — the gate
|
||||
# would then be strongest exactly where it is least needed and absent where it
|
||||
# is needed most. The list is framework-owned and changes only through a
|
||||
# reviewed PR.
|
||||
#
|
||||
# Usage:
|
||||
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
|
||||
#
|
||||
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Suites whose coverage is a HARD requirement. Add a suite here only together
|
||||
# with the doc changes that make it pass.
|
||||
#
|
||||
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
|
||||
# an undiscoverable git wrapper converts a hard gate into a coin flip.
|
||||
ENFORCED_SUITES=(git)
|
||||
|
||||
# Files that are not agent-callable tools and must not be required in an index.
|
||||
EXCLUDE_GLOBS=(
|
||||
'test-*' # hermetic regression scripts, invoked by CI not by agents
|
||||
'_*' # private helpers (_lib, _scripts internals)
|
||||
'*.bak' # editor/installer debris
|
||||
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
|
||||
'README.md'
|
||||
)
|
||||
|
||||
STRICT=0
|
||||
SELF_TEST=0
|
||||
TOOLS_DIR=""
|
||||
DOCS=()
|
||||
|
||||
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
|
||||
--doc) DOCS+=("${2:-}"); shift 2 ;;
|
||||
--strict) STRICT=1; shift ;;
|
||||
--self-test) SELF_TEST=1; shift ;;
|
||||
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- location resolution ---------------------------------------------------
|
||||
# Runs from two places with different layouts, and must not silently check the
|
||||
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
|
||||
resolve_locations() {
|
||||
local here framework
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# .../framework/tools/quality/scripts -> .../framework
|
||||
framework="$(cd -- "$here/../../.." && pwd)"
|
||||
|
||||
if [ -z "$TOOLS_DIR" ]; then
|
||||
if [ -d "$framework/tools" ]; then
|
||||
TOOLS_DIR="$framework/tools"
|
||||
else
|
||||
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||
# The two layouts are mutually exclusive on purpose. Unioning them would let
|
||||
# a well-maintained operator TOOLS.md on the developer's own machine mask a
|
||||
# gap in the shipped defaults — the check would pass locally and the defect
|
||||
# would still install on every other host. Repo layout wins when present.
|
||||
if [ -f "$framework/defaults/TOOLS.md" ]; then
|
||||
DOCS+=("$framework/defaults/TOOLS.md")
|
||||
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
|
||||
else
|
||||
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
|
||||
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
|
||||
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
|
||||
}
|
||||
|
||||
is_excluded() {
|
||||
local name="$1" glob
|
||||
for glob in "${EXCLUDE_GLOBS[@]}"; do
|
||||
# shellcheck disable=SC2254 # glob is intentionally a pattern
|
||||
case "$name" in $glob) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# A tool counts as documented when its basename appears anywhere in the corpus.
|
||||
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
|
||||
# about *presence*: the gate's job is "an agent can find it", not house style.
|
||||
documented() { grep -qF -- "$1" "$CORPUS"; }
|
||||
|
||||
# ---- the check -------------------------------------------------------------
|
||||
run_check() {
|
||||
local rc=0 suite dir tool base enforced
|
||||
|
||||
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
|
||||
cat "${DOCS[@]}" > "$CORPUS"
|
||||
|
||||
printf 'tools: %s\n' "$TOOLS_DIR"
|
||||
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
|
||||
printf '\n'
|
||||
|
||||
for dir in "$TOOLS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
suite="$(basename -- "$dir")"
|
||||
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
|
||||
[ "$STRICT" -eq 1 ] && enforced=1
|
||||
case "$suite" in _*) continue ;; esac
|
||||
|
||||
local total=0 found=0
|
||||
local -a suite_missing=() suite_noexec=()
|
||||
for tool in "$dir"*.sh; do
|
||||
[ -e "$tool" ] || continue
|
||||
base="$(basename -- "$tool")"
|
||||
is_excluded "$base" && continue
|
||||
total=$((total + 1))
|
||||
if documented "$base"; then
|
||||
found=$((found + 1))
|
||||
# Documented AND present is not enough. The index presents these as
|
||||
# commands to run, and every caller — the wrapper guard included —
|
||||
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
|
||||
# documented, present, and dead: it reads as absent to every check that
|
||||
# matters while scoring 100% here. That is a false green, which is worse
|
||||
# than a red, so it fails rather than warns.
|
||||
[ -x "$tool" ] || suite_noexec+=("$base")
|
||||
else
|
||||
suite_missing+=("$base")
|
||||
fi
|
||||
done
|
||||
[ "$total" -eq 0 ] && continue
|
||||
|
||||
local pct=$(( found * 100 / total ))
|
||||
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
|
||||
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
|
||||
rc=1
|
||||
fi
|
||||
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
||||
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
||||
rc=1
|
||||
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
|
||||
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
||||
else
|
||||
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
||||
fi
|
||||
|
||||
# Reverse: an index that names a tool this suite does not have sends agents
|
||||
# after something that cannot run. Only checked for enforced suites, where
|
||||
# the naming is unambiguous enough to attribute.
|
||||
if [ "$enforced" -eq 1 ]; then
|
||||
local -a stale=()
|
||||
local ref
|
||||
while read -r ref; do
|
||||
[ -n "$ref" ] || continue
|
||||
is_excluded "$ref" && continue
|
||||
[ -e "$dir$ref" ] || stale+=("$ref")
|
||||
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
|
||||
| sed "s|^$suite/||" | sort -u)
|
||||
if [ ${#stale[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s stale index references (no such file): %s\n' \
|
||||
"$suite" "${stale[*]}"
|
||||
rc=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
cat <<EOF
|
||||
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
|
||||
tells it to use a wrapper it has no way to learn exists — it will reach for raw
|
||||
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
|
||||
|
||||
Fix by naming each tool above in one of the index documents listed at the top,
|
||||
in the same commit that ships it.
|
||||
EOF
|
||||
else
|
||||
printf 'every enforced suite is fully discoverable.\n'
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ---- self-test -------------------------------------------------------------
|
||||
# Proves the gate can actually fail. A checker that only ever passes is
|
||||
# indistinguishable from one that is not running, which is the failure mode this
|
||||
# whole file exists to prevent — so it must demonstrate a red on demand.
|
||||
self_test() {
|
||||
local tmp rc
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
|
||||
mkdir -p "$tmp/tools/git"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
||||
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
|
||||
|
||||
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
||||
# command-prefix assignment, so point the globals at the fixture directly.
|
||||
TOOLS_DIR="$tmp/tools"
|
||||
DOCS=("$tmp/doc.md")
|
||||
|
||||
# Case 1: fully documented -> pass.
|
||||
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
||||
if run_check >/dev/null; then
|
||||
printf 'self-test 1/4 ok (complete index passes)\n'
|
||||
else
|
||||
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
|
||||
fi
|
||||
|
||||
# Case 2: an undocumented tool -> fail.
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
# Case 3: a stale index reference -> fail.
|
||||
rm "$tmp/tools/git/undocumented-tool.sh"
|
||||
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
# Case 4: documented, present, and NOT executable -> fail. Found by an
|
||||
# independent reviewer: a 0644 wrapper scored 100% here while reading as
|
||||
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
|
||||
sed -i '/deleted-tool/d' "$tmp/doc.md"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
|
||||
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
|
||||
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
|
||||
}
|
||||
|
||||
if [ "$SELF_TEST" -eq 1 ]; then
|
||||
self_test
|
||||
else
|
||||
resolve_locations
|
||||
run_check
|
||||
fi
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed comparison of deployed Mosaic tools to manifest-owned shipped tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
value.update(chunk)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def default_source_tools() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def normalize_source(path: Path) -> Path:
|
||||
candidate = path.resolve()
|
||||
return candidate / "tools" if (candidate / "tools").is_dir() else candidate
|
||||
|
||||
|
||||
def assert_traversable_directory(path: Path) -> None:
|
||||
mode = stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
|
||||
# At least one principal class must have both read and search. This catches
|
||||
# mode-000 even for privileged reviewers for whom os.access() would lie.
|
||||
if not any(mode & read and mode & execute for read, execute in ((0o400, 0o100), (0o040, 0o010), (0o004, 0o001))):
|
||||
raise PermissionError(f"directory has no readable/searchable mode: {path}")
|
||||
|
||||
|
||||
def census(root: Path, *, reject_symlinks: bool) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
|
||||
def onerror(error: OSError) -> None:
|
||||
raise error
|
||||
|
||||
for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=onerror):
|
||||
current_path = Path(current)
|
||||
assert_traversable_directory(current_path)
|
||||
for name in directories:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink() and reject_symlinks:
|
||||
# A source symlink makes the shipped census incomplete. Deployed
|
||||
# aliases are assessed later only when they occupy a required
|
||||
# framework path; installed-only aliases remain operator state.
|
||||
raise OSError(f"symlinked directory is not an independent census entry: {entry}")
|
||||
for name in filenames:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink():
|
||||
if reject_symlinks:
|
||||
raise OSError(f"symlinked file is not an independent census entry: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
continue
|
||||
mode = entry.stat(follow_symlinks=False).st_mode
|
||||
if not stat.S_ISREG(mode):
|
||||
raise OSError(f"non-regular census entry: {entry}")
|
||||
if stat.S_IMODE(mode) & 0o444 == 0:
|
||||
raise PermissionError(f"file has no readable mode: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
return result
|
||||
|
||||
|
||||
def classify_with_manifest(source: Path, relatives: list[str]) -> dict[str, str]:
|
||||
framework = source.parent
|
||||
manifest = framework / "framework-manifest.txt"
|
||||
resolver = source / "_lib" / "manifest.sh"
|
||||
if not manifest.is_file() or not os.access(manifest, os.R_OK):
|
||||
raise OSError(f"ownership manifest is missing or unreadable: {manifest}")
|
||||
if not resolver.is_file() or not os.access(resolver, os.R_OK):
|
||||
raise OSError(f"canonical manifest resolver is missing or unreadable: {resolver}")
|
||||
payload = "".join(f"tools/{relative}\n" for relative in relatives)
|
||||
completed = subprocess.run(
|
||||
["bash", str(resolver), "classify"],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "MANIFEST_FILE": str(manifest)},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or f"resolver rc={completed.returncode}"
|
||||
raise OSError(f"ownership manifest failed canonical resolution: {detail}")
|
||||
classified: dict[str, str] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
ownership, separator, manifest_path = line.partition("\t")
|
||||
if not separator or not manifest_path.startswith("tools/") or ownership not in {"framework", "operator"}:
|
||||
raise OSError(f"invalid canonical ownership output: {line!r}")
|
||||
relative = manifest_path.removeprefix("tools/")
|
||||
if relative in classified:
|
||||
raise OSError(f"duplicate canonical ownership output: {relative}")
|
||||
classified[relative] = ownership
|
||||
if set(classified) != set(relatives):
|
||||
raise OSError("canonical ownership output did not classify the complete source census")
|
||||
return classified
|
||||
|
||||
|
||||
def has_symlinked_component(root: Path, relative: str) -> bool:
|
||||
current = root
|
||||
for component in Path(relative).parts:
|
||||
current = current / component
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Detect deployed Mosaic framework-tool drift")
|
||||
parser.add_argument("--source-root", type=Path, default=Path(os.environ["MOSAIC_FRAMEWORK_SOURCE_ROOT"]) if os.environ.get("MOSAIC_FRAMEWORK_SOURCE_ROOT") else default_source_tools())
|
||||
parser.add_argument("--installed-root", type=Path, default=Path(os.environ.get("MOSAIC_HOME", Path.home() / ".config/mosaic")) / "tools")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = normalize_source(args.source_root)
|
||||
installed = args.installed_root.resolve()
|
||||
try:
|
||||
if not source.is_dir():
|
||||
raise OSError(f"source tools missing: {source}")
|
||||
if not installed.is_dir():
|
||||
raise OSError(f"installed tools missing: {installed}")
|
||||
if source.samefile(installed):
|
||||
raise OSError("source and installed roots identify the same filesystem object")
|
||||
source_files = census(source, reject_symlinks=True)
|
||||
if not source_files:
|
||||
raise OSError("source tools census is empty")
|
||||
ownership = classify_with_manifest(source, sorted(source_files))
|
||||
required = sorted(relative for relative, owner in ownership.items() if owner == "framework")
|
||||
if not required:
|
||||
raise OSError("ownership manifest classifies zero shipped tools as framework-owned")
|
||||
installed_files = census(installed, reject_symlinks=False)
|
||||
except (OSError, PermissionError) as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
in_sync: list[str] = []
|
||||
stale: list[str] = []
|
||||
not_installed: list[str] = []
|
||||
unsafe_alias: list[str] = []
|
||||
for relative in required:
|
||||
deployed = installed / relative
|
||||
if not deployed.is_file():
|
||||
not_installed.append(relative)
|
||||
continue
|
||||
if has_symlinked_component(installed, relative):
|
||||
unsafe_alias.append(relative)
|
||||
continue
|
||||
try:
|
||||
if source_files[relative].samefile(deployed):
|
||||
unsafe_alias.append(relative)
|
||||
elif digest(source_files[relative]) == digest(deployed):
|
||||
in_sync.append(relative)
|
||||
else:
|
||||
stale.append(relative)
|
||||
except OSError as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT cannot compare {relative}: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_relative = set(source_files)
|
||||
installed_only = sorted(set(installed_files) - source_relative)
|
||||
if args.verbose:
|
||||
for relative in in_sync:
|
||||
print(f"[framework-drift] IN_SYNC {relative}")
|
||||
for relative in stale:
|
||||
print(f"[framework-drift] STALE {relative}")
|
||||
for relative in not_installed:
|
||||
print(f"[framework-drift] NOT_INSTALLED {relative}")
|
||||
for relative in unsafe_alias:
|
||||
print(f"[framework-drift] UNSAFE_ALIAS {relative}")
|
||||
if args.verbose:
|
||||
for relative in installed_only:
|
||||
print(f"[framework-drift] INSTALLED_ONLY operator-or-unknown {relative}")
|
||||
|
||||
print(
|
||||
"[framework-drift] summary "
|
||||
f"in-sync={len(in_sync)} stale={len(stale)} not-installed={len(not_installed)} "
|
||||
f"unsafe-alias={len(unsafe_alias)} installed-only={len(installed_only)}"
|
||||
)
|
||||
print("[framework-drift] classification canonical framework-manifest ownership; installed-only=operator-or-unknown-preserved")
|
||||
if stale or not_installed or unsafe_alias:
|
||||
print("[framework-drift] FAIL deployed framework tools do not match independent shipped source; schedule a reviewed framework reseed", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
CHECKER = Path(__file__).with_name("framework-drift-check.py")
|
||||
REAL_RESOLVER = CHECKER.parents[2] / "_lib" / "manifest.sh"
|
||||
|
||||
|
||||
class FrameworkDriftCheckTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.framework = root / "framework"
|
||||
self.source = self.framework / "tools"
|
||||
self.installed = root / "home" / "tools"
|
||||
for directory in (self.source / "git", self.source / "_lib", self.installed / "git", self.installed / "_lib"):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REAL_RESOLVER, self.source / "_lib" / "manifest.sh")
|
||||
(self.source / "git" / "guard.sh").write_text("fixed\n")
|
||||
(self.source / "git" / "new-wrapper.sh").write_text("new\n")
|
||||
(self.source / "_lib" / "credentials.json").write_text("source-placeholder\n")
|
||||
self.write_manifest()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_manifest(self, operator_extra: str = "") -> None:
|
||||
(self.framework / "framework-manifest.txt").write_text(
|
||||
"[framework]\ntools/**\n[operator]\ntools/_lib/credentials.json\n" + operator_extra
|
||||
)
|
||||
|
||||
def run_check(self, *extra: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.installed), *extra],
|
||||
text=True, capture_output=True, check=False,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
|
||||
def install_matching(self) -> None:
|
||||
for relative in ("git/guard.sh", "git/new-wrapper.sh", "_lib/manifest.sh"):
|
||||
shutil.copy2(self.source / relative, self.installed / relative)
|
||||
(self.installed / "_lib" / "credentials.json").write_text("different-operator-secret\n")
|
||||
|
||||
def test_fails_loudly_and_classifies_stale_missing_and_installed_only(self) -> None:
|
||||
(self.installed / "git" / "guard.sh").write_text("broken\n")
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
(self.installed / "local-helper.sh").write_text("operator\n")
|
||||
result = self.run_check("--verbose")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
self.assertIn("NOT_INSTALLED git/new-wrapper.sh", result.stdout)
|
||||
self.assertIn("INSTALLED_ONLY operator-or-unknown local-helper.sh", result.stdout)
|
||||
self.assertIn("FAIL deployed framework tools", result.stderr)
|
||||
|
||||
def test_passes_only_when_every_manifest_owned_source_file_matches(self) -> None:
|
||||
self.install_matching()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("stale=0 not-installed=0 unsafe-alias=0", result.stdout)
|
||||
|
||||
def test_exact_operator_directory_does_not_hide_framework_drift_beneath_it(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("drift-hidden-by-directory-entry\n")
|
||||
self.write_manifest("tools/git\n")
|
||||
|
||||
result = self.run_check()
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
|
||||
def test_manifest_is_required_and_policy_changes_take_effect(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("operator-divergence\n")
|
||||
self.write_manifest("tools/git/guard.sh\n")
|
||||
self.assertEqual(self.run_check().returncode, 0)
|
||||
(self.framework / "framework-manifest.txt").unlink()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT ownership manifest is missing", result.stderr)
|
||||
|
||||
def test_empty_and_unreadable_source_census_cannot_assert(self) -> None:
|
||||
empty_framework = Path(self.temp.name) / "empty-framework"
|
||||
empty_source = empty_framework / "tools"
|
||||
empty_source.mkdir(parents=True)
|
||||
shutil.copy2(self.framework / "framework-manifest.txt", empty_framework / "framework-manifest.txt")
|
||||
# The canonical resolver is supplied outside the empty census solely so
|
||||
# this probe reaches the explicit minimum-population guard.
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(empty_framework), "--installed-root", str(self.installed)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
|
||||
blocked = self.source / "blocked"
|
||||
blocked.mkdir(); (blocked / "hidden.sh").write_text("hidden\n"); blocked.chmod(0)
|
||||
try:
|
||||
result = self.run_check()
|
||||
finally:
|
||||
blocked.chmod(0o700)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
self.assertTrue("Permission denied" in result.stderr or "no readable/searchable mode" in result.stderr)
|
||||
|
||||
def test_root_and_descendant_aliases_cannot_report_clean(self) -> None:
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.source)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("same filesystem object", result.stderr)
|
||||
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
shutil.rmtree(self.installed / "git")
|
||||
(self.installed / "git").symlink_to(self.source / "git", target_is_directory=True)
|
||||
result = self.run_check()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue("symlinked directory" in result.stderr or "UNSAFE_ALIAS" in result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Doctor must contain a stalled drift checker and continue its remaining audit.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCTOR="$SCRIPT_DIR/../../_scripts/mosaic-doctor"
|
||||
WORK="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/framework-drift-doctor}"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/source/tools/quality/scripts" "$WORK/source/tools/_scripts" "$WORK/home/tools"
|
||||
cp "$DOCTOR" "$WORK/source/tools/_scripts/mosaic-doctor"
|
||||
cat > "$WORK/source/tools/quality/scripts/framework-drift-check.py" <<'PY'
|
||||
import time
|
||||
time.sleep(30)
|
||||
PY
|
||||
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
output=$(MOSAIC_HOME="$WORK/home" MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC=1 \
|
||||
bash "$WORK/source/tools/_scripts/mosaic-doctor" --fail-on-warn 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
|
||||
[[ "$rc" -ne 0 ]] || { echo "FAIL: checker timeout became doctor success" >&2; exit 1; }
|
||||
[[ "$elapsed" -lt 10 ]] || { echo "FAIL: checker hang escaped watchdog (${elapsed}s)" >&2; exit 1; }
|
||||
[[ "$output" == *"CANNOT_ASSERT framework drift checker timed out"* ]] || {
|
||||
echo "FAIL: missing timeout CANNOT_ASSERT diagnostic" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
[[ "$output" == *"[mosaic-doctor] warnings="* ]] || {
|
||||
echo "FAIL: doctor did not continue after checker timeout" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
|
||||
echo "framework drift doctor watchdog regression passed"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && 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 src/mutator-gate/version_coupling_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-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && 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 src/mutator-gate/version_coupling_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 && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -146,21 +146,6 @@ describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||
});
|
||||
|
||||
it('limits an exact operator carve-out to that path while requiring /** for subtrees', () => {
|
||||
const exact: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git'],
|
||||
};
|
||||
expect(resolveOwnership(exact, 'tools/git')).toBe('operator');
|
||||
expect(resolveOwnership(exact, 'tools/git/guard.sh')).toBe('framework');
|
||||
|
||||
const subtree: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git/**'],
|
||||
};
|
||||
expect(resolveOwnership(subtree, 'tools/git/guard.sh')).toBe('operator');
|
||||
});
|
||||
|
||||
it('framework-declared paths resolve to framework', () => {
|
||||
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||
|
||||
@@ -154,25 +154,12 @@ export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||
return globs.some((g) => matchGlob(g, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator entries without wildcards are exact file carve-outs. Treating them
|
||||
* as directory prefixes would let one bare entry hide an entire framework
|
||||
* subtree from reconciliation and drift detection. Operator subtree ownership
|
||||
* remains explicit through `dir/**`.
|
||||
*/
|
||||
function matchesOperatorGlob(glob: string, relPath: string): boolean {
|
||||
const pattern = normalizeRel(glob);
|
||||
if (pattern === '') return false;
|
||||
if (!pattern.includes('*')) return normalizeRel(relPath) === pattern;
|
||||
return matchGlob(pattern, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||
* operator globs win, then framework globs, else operator by default.
|
||||
*/
|
||||
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||
if (manifest.operator.some((glob) => matchesOperatorGlob(glob, relPath))) return 'operator';
|
||||
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user