@groundnuty/macf 0.2.35 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-info.json +2 -2
- package/dist/cli/claude-sh.d.ts +12 -10
- package/dist/cli/claude-sh.d.ts.map +1 -1
- package/dist/cli/claude-sh.js +26 -13
- package/dist/cli/claude-sh.js.map +1 -1
- package/dist/cli/commands/certs.js +3 -3
- package/dist/cli/commands/certs.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +10 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/monitor.d.ts +16 -0
- package/dist/cli/commands/monitor.d.ts.map +1 -0
- package/dist/cli/commands/monitor.js +96 -0
- package/dist/cli/commands/monitor.js.map +1 -0
- package/dist/cli/commands/propose.d.ts +21 -0
- package/dist/cli/commands/propose.d.ts.map +1 -0
- package/dist/cli/commands/propose.js +128 -0
- package/dist/cli/commands/propose.js.map +1 -0
- package/dist/cli/commands/rules-refresh.d.ts +1 -0
- package/dist/cli/commands/rules-refresh.d.ts.map +1 -1
- package/dist/cli/commands/rules-refresh.js +22 -1
- package/dist/cli/commands/rules-refresh.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +23 -2
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/env-files-update.d.ts.map +1 -1
- package/dist/cli/env-files-update.js +5 -1
- package/dist/cli/env-files-update.js.map +1 -1
- package/dist/cli/env-files.d.ts +38 -13
- package/dist/cli/env-files.d.ts.map +1 -1
- package/dist/cli/env-files.js +73 -14
- package/dist/cli/env-files.js.map +1 -1
- package/dist/cli/index.js +109 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/monitor/digest.d.ts +89 -0
- package/dist/cli/monitor/digest.d.ts.map +1 -0
- package/dist/cli/monitor/digest.js +232 -0
- package/dist/cli/monitor/digest.js.map +1 -0
- package/dist/cli/monitor/github-reader.d.ts +38 -0
- package/dist/cli/monitor/github-reader.d.ts.map +1 -0
- package/dist/cli/monitor/github-reader.js +65 -0
- package/dist/cli/monitor/github-reader.js.map +1 -0
- package/dist/cli/monitor/reflections.d.ts +18 -0
- package/dist/cli/monitor/reflections.d.ts.map +1 -0
- package/dist/cli/monitor/reflections.js +72 -0
- package/dist/cli/monitor/reflections.js.map +1 -0
- package/dist/cli/monitor/run.d.ts +30 -0
- package/dist/cli/monitor/run.d.ts.map +1 -0
- package/dist/cli/monitor/run.js +67 -0
- package/dist/cli/monitor/run.js.map +1 -0
- package/dist/cli/project-rules.d.ts +105 -0
- package/dist/cli/project-rules.d.ts.map +1 -0
- package/dist/cli/project-rules.js +305 -0
- package/dist/cli/project-rules.js.map +1 -0
- package/dist/cli/propose/candidates.d.ts +95 -0
- package/dist/cli/propose/candidates.d.ts.map +1 -0
- package/dist/cli/propose/candidates.js +117 -0
- package/dist/cli/propose/candidates.js.map +1 -0
- package/dist/cli/propose/invariants.d.ts +49 -0
- package/dist/cli/propose/invariants.d.ts.map +1 -0
- package/dist/cli/propose/invariants.js +154 -0
- package/dist/cli/propose/invariants.js.map +1 -0
- package/dist/cli/propose/proposal-writer.d.ts +33 -0
- package/dist/cli/propose/proposal-writer.d.ts.map +1 -0
- package/dist/cli/propose/proposal-writer.js +53 -0
- package/dist/cli/propose/proposal-writer.js.map +1 -0
- package/dist/cli/propose/report.d.ts +49 -0
- package/dist/cli/propose/report.d.ts.map +1 -0
- package/dist/cli/propose/report.js +227 -0
- package/dist/cli/propose/report.js.map +1 -0
- package/dist/cli/propose/run.d.ts +41 -0
- package/dist/cli/propose/run.d.ts.map +1 -0
- package/dist/cli/propose/run.js +62 -0
- package/dist/cli/propose/run.js.map +1 -0
- package/dist/cli/settings-writer.d.ts +87 -6
- package/dist/cli/settings-writer.d.ts.map +1 -1
- package/dist/cli/settings-writer.js +141 -6
- package/dist/cli/settings-writer.js.map +1 -1
- package/dist/reconciler/parse-delivered.d.ts +32 -0
- package/dist/reconciler/parse-delivered.d.ts.map +1 -0
- package/dist/reconciler/parse-delivered.js +18 -0
- package/dist/reconciler/parse-delivered.js.map +1 -0
- package/dist/reconciler/parse-processed.d.ts +57 -0
- package/dist/reconciler/parse-processed.d.ts.map +1 -0
- package/dist/reconciler/parse-processed.js +41 -0
- package/dist/reconciler/parse-processed.js.map +1 -0
- package/dist/reconciler/reconcile.d.ts +130 -0
- package/dist/reconciler/reconcile.d.ts.map +1 -0
- package/dist/reconciler/reconcile.js +119 -0
- package/dist/reconciler/reconcile.js.map +1 -0
- package/dist/reconciler/run.d.ts +23 -0
- package/dist/reconciler/run.d.ts.map +1 -0
- package/dist/reconciler/run.js +273 -0
- package/dist/reconciler/run.js.map +1 -0
- package/package.json +2 -2
- package/plugin/rules/coordination.md +22 -13
- package/plugin/rules/gh-token-attribution-traps.md +4 -0
- package/plugin/rules/mention-routing-hygiene.md +2 -0
- package/plugin/rules/observability-wiring.md +3 -3
- package/plugin/rules/reflection-staging.md +65 -0
- package/plugin/rules/silent-fallback-hazards.md +64 -8
- package/scripts/check-auditor-never-acts.sh +167 -0
- package/scripts/check-gh-attribution.sh +230 -0
- package/scripts/emit-turn-receipt.sh +81 -0
- package/scripts/harvest-reflection.sh +125 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> **Workspaces without full `macf init`** (e.g. `groundnuty/macf` itself, or any Claude Code workspace operated by a bot that isn't a MACF-registered agent) can still get this canonical rule via `macf rules refresh --dir <workspace>`. Same copy, no App credentials or registry required.
|
|
6
6
|
|
|
7
|
-
This rule names the CLASS so agents recognize the shape on first encounter rather than re-discovering each instance from scratch.
|
|
7
|
+
This rule names the CLASS so agents recognize the shape on first encounter rather than re-discovering each instance from scratch. Eleven active instances are documented below as worked examples spanning different architectural layers (identity, parsing, TUI binding, observability routing, config substitution, multi-agent coordination protocol, metric-instrumentation lifecycle, observability-endpoint routing, release-pipeline-partial-publish, third-party-action retry-exhaustion, credential-refresh temporal-binding). (Instance 10 — a legacy substrate-routing receipt-gap — was retired 2026-06-07; its number is kept, not reused.) Ten of eleven active instances have structural defenses applied or in flight — the pattern of defense generalizes alongside the pattern of hazard.
|
|
8
8
|
|
|
9
9
|
Instance 9 is annotated as **sister-shape** (failure correctly surfaced + partial side-effect breaks retry idempotency) — listed here for cross-reference convenience but warrants a sibling canonical rule (`partial-side-effect-hazards.md`) if more instances surface. The two classes share "multi-step pipeline where consumer assumes atomicity" but the failure surface differs: silent-fallback hides at the API boundary; partial-side-effect surfaces loudly but persists semi-state.
|
|
10
10
|
|
|
@@ -23,7 +23,7 @@ The trap is that defensive programming targets exit codes, but exit-code success
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Known instances
|
|
27
27
|
|
|
28
28
|
### Instance 1 — gh-token attribution traps
|
|
29
29
|
|
|
@@ -51,10 +51,10 @@ This is a distinct sub-case from the missing-helper / mis-pipefail / wrong-prefi
|
|
|
51
51
|
**Failure shape:** `tmux send-keys` exits 0 + keystrokes are written to pane stdin, but Claude Code's input handler is bound to a different IPC channel (RC's SDK socket); routing-via-tmux silently bypasses the actual input path → recipient never sees the routed prompt.
|
|
52
52
|
**Recurrence:** Cross-agent triangulated; 2+ confirmed firings on real routes hours apart, same shape.
|
|
53
53
|
**Defense status:** Two-tier per fleet class:
|
|
54
|
-
- **Consumer fleet** (CV agents, tester agents, future macf-init'd consumers):
|
|
54
|
+
- **Consumer fleet** (CV agents, tester agents, future macf-init'd consumers): largely mitigated, not fully eliminated. The routed **message** arrives via the channel-server's HTTP/MCP path (not as a keystroke), so the prompt *content* reaches the agent regardless of RC state — that's the structural win over send-keys routing (DR-020 / macf-actions v3+). The **residual**: the channel-server's `wakeViaTmux` nudge still uses send-keys, so under RC-bound input the auto-wake keystroke may not land — the message sits in the MCP channel until the agent next reads it, rather than being lost. So the hazard is reduced to a wake-latency issue, not a content-drop.
|
|
55
55
|
- **Substrate fleet** (workspaces operated as the design surface, not registered MACF consumers): permanent operational reality — substrate workspaces don't run `macf init`. Defensive posture: rule-discipline + Pattern C fragility detector (`tmux display -p '#{session_activity}'` doesn't advance under RC-bound input).
|
|
56
56
|
|
|
57
|
-
The
|
|
57
|
+
The content-drop is retired for the consumer fleet (message arrives via HTTP/MCP); only the wake-nudge residual remains there. The substrate fleet expects full Instance 3 firings to recur on routes indefinitely; rule-discipline catches the failure at observation time, not pre-emptively.
|
|
58
58
|
|
|
59
59
|
### Instance 4 — Loki / ClickHouse-logs pipeline divergence (label-vs-structured-metadata)
|
|
60
60
|
|
|
@@ -163,6 +163,60 @@ Both classes share "multi-step pipeline where consumer assumes atomicity" but th
|
|
|
163
163
|
|
|
164
164
|
**Codification rationale:** 3 instances across 2 trigger mechanisms + defense pattern stable + operator-witnessed across 2 calendar days (2026-05-18 + 2026-05-19) + cross-agent (instance 1 via science-agent's authoring; instances 2/3 via code-agent's release-cut workflow) — meets all four "When to add a new instance" criteria. The sister-class question (separate `partial-side-effect-hazards.md` canonical rule) is acknowledged inline + deferred to the 5-instance threshold per rule-promotion convention.
|
|
165
165
|
|
|
166
|
+
### Instance 10 — retired (legacy substrate-routing receipt-gap)
|
|
167
|
+
|
|
168
|
+
Documented a send-logged ≠ received gap on the legacy Stage-2 substrate routing last-mile, a path the macf **product** does not use: consumers route via the channel-server / A2A path, whose `notify_received` / `mcp_pushed` + OTel receipt spans (`macf.mcp.push`, `macf.tmux_wake.deliver`) already capture receipt. Removed from canonical 2026-06-07 — legacy-routing operational detail belongs in substrate workbench + git history, not the product rules. The number is retained (not reused) to keep Instances 1–9 + 11 stable as identifiers.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### Instance 11 — Third-party retry-wrapping action exits 0 on internal retry-exhaustion (connect-failure masked as step-success)
|
|
173
|
+
|
|
174
|
+
**Surface:** any consumer-CI step that delegates a connect/auth handshake to a third-party GitHub Action (or wrapper tool) that owns its own internal retry loop — tailnet join (`tailscale/github-action`), OTLP collector connect, cloud-provider auth (`aws-actions/configure-aws-credentials`, `google-github-actions/auth`), registry login, etc. The action retries internally and reports a single aggregate exit code for the step.
|
|
175
|
+
|
|
176
|
+
**Failure shape:** the wrapped tool fails on EVERY internal retry (a hard error, not a transient one — e.g. an invalid-auth `400` that no amount of retrying can fix), the action **exhausts its retry budget and still exits `0`**, and the workflow continues into a broken state. The connect never happened, but the step is green. The real failure then surfaces far downstream as a *different* problem — every later fetch/query against the resource the step was supposed to make reachable fails, and the symptom (timeout, connection-refused, "endpoint unreachable") points the investigator at the downstream consumer, not at the masked upstream connect. The exit-0 step is the last place anyone looks because it's the one place that reported success.
|
|
177
|
+
|
|
178
|
+
**Recurrence:** First confirmed instance — groundnuty/macf#461 (2026-06-07). The e2e workflow's `tailscale/github-action` step was configured with `tags: tag:ci`, but the OAuth client + ACL only permit `tag:ci-runner`. `tailscale up` returned `Status 400 "requested tags [tag:ci] are invalid or not permitted"` on all 5 internal retries; the action exited `0`; the runner never joined the tailnet; every subsequent tailnet fetch failed, presenting as a generic **"Tempo query unreachable."** Because the connect step was *green*, it was the last place examined — the diagnosis cycled through transient-retry, Tempo-serve-config, and DNS-vs-IP hypotheses until reading the connect step's **actual output** (not its exit code) surfaced the `400`. (Two *genuinely separate* upstream bugs — a devbox/Nix-install step-ordering fault [#460] and the Tempo query port not being tailnet-exposed [devops-toolkit #88] — were correctly diagnosed and landed first; they were real fixes, not projections of this masked failure. The exit-0 masking is what hid *this* failure for an extra diagnostic cycle even after those two were resolved.)
|
|
179
|
+
|
|
180
|
+
**Distinct from the sister GHA-surface instances** — do NOT fold this into Instance 5 or Instance 8:
|
|
181
|
+
|
|
182
|
+
| Aspect | Instance 5 (secrets-misnamed) | Instance 8 (OTLP endpoint silent-drop) | Instance 11 (this) |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| Where the lie originates | empty-string substitution at `${{ }}` expansion, BEFORE the tool runs | exporter silently retries-then-drops, no exit code surfaced at all | third-party action runs, fails every retry, then **exits `0` reporting success** |
|
|
185
|
+
| What's wrong | a required input is missing/renamed | a long-lived process points at a dead endpoint | a connect handshake hard-fails but its wrapper claims success |
|
|
186
|
+
| Downstream disguise | misleading auth error at the *consuming* step | empty observability surface, no failure signal anywhere | masquerades as a *different* downstream problem (here: "Tempo unreachable") |
|
|
187
|
+
|
|
188
|
+
Instance 5 is "the input was never there"; Instance 8 is "the data went into a void with no signal"; Instance 11 is "the connection step actively *reported success* while having hard-failed." All three live on the GitHub-Actions / CI-plumbing surface but the trust boundary that breaks differs — Instance 11's is specifically *a third party's exit code about its own retry exhaustion*. The tailscale case above is just the worked example: any consumer CI that wraps a connect/auth in a retrying action (tailnet, OTLP, cloud-auth, registry-login) is exposed to the same shape.
|
|
189
|
+
|
|
190
|
+
**Defense status:** SHIPPED (Pattern A result-invariant assert, with a Pattern D precheck flavor) — a **"Verify <resource> is up" step placed immediately after the connect step**, asserting the connection's result-invariant and failing LOUD (red job) when it doesn't hold. Never trust a third-party action's exit code as evidence that its own internal retries succeeded — assert the post-connect state directly.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# After the tailscale connect step (NOT trusting its exit 0):
|
|
194
|
+
tailscale status --json | jq -e '.BackendState == "Running"' >/dev/null \
|
|
195
|
+
|| { echo "::error::tailnet did not come up — tailscale BackendState != Running."; \
|
|
196
|
+
echo "::error::The connect action may have exhausted retries and exited 0 anyway"; \
|
|
197
|
+
echo "::error::(e.g. tag/ACL mismatch returns Status 400 on every retry)."; \
|
|
198
|
+
tailscale status || true; exit 1; }
|
|
199
|
+
echo "✓ tailnet up (BackendState=Running)"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Generalizes to any retry-wrapping action: assert the result-invariant the connect was *supposed to establish* — `BackendState == "Running"` for a tailnet, a successful authenticated probe for cloud-auth, a `200` from the collector's health endpoint for OTLP — in a dedicated step right after the connect, before any downstream consumer runs. This converts a far-downstream misdiagnosis (the 3-hop red-herring chase in #461) into a fail-at-the-boundary red job pointing directly at the broken connect.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### Instance 12 — PreToolUse credential-guard validates the *ambient* token, blind to inline mid-command reassignment
|
|
207
|
+
|
|
208
|
+
**Surface:** the #140 `check-gh-token.sh` PreToolUse hook, plus any refresh idiom that reassigns `GH_TOKEN` *inline within the same Bash command* as the `gh` calls it is meant to guard (`export GH_TOKEN=$(gh token generate ... | jq -r .token) && gh ...`; `export GH_TOKEN=$(cat tok.txt) && gh ...`).
|
|
209
|
+
|
|
210
|
+
**Failure shape:** the hook validates `GH_TOKEN` purely from the **ambient environment present *before* the command runs** (`GH_TOKEN_VALUE="${GH_TOKEN:-}"`, then the `^ghs_[A-Za-z0-9_]+$` predicate). It never parses the command string for an inline `GH_TOKEN=` / `export GH_TOKEN=$(...)` reassignment. Agents launch with a valid `ghs_` token in ambient env, so the hook **passes and exits 0** — *then* the inline `$(...)` runs, and on an intermittent GitHub-side 401 the naive `| jq` (no `set -o pipefail`) emits empty/`null`, clobbering `GH_TOKEN` to empty **after the hook has already returned**. The chained `gh` calls fall back to the stored `gh auth login` user. **The recommended refresh-chain bypasses its own guard.** The hook only blocks when the ambient is *already* bad — the exact case the rules tell agents not to rely on; in the normal regime (valid ambient) it is a pass-through no-op for every inline-refresh shape, including the file-cache read.
|
|
211
|
+
|
|
212
|
+
Two adjacent sub-failures: **(a)** `export X=$(helper)` masks a fail-loud helper's non-zero exit, because `export` is a builtin whose own exit `0` replaces the substitution's (ShellCheck SC2155) — so `pipefail` / a fail-loud helper *alone* is insufficient; only `GH_TOKEN=$(helper) || exit 1` (bare assignment + explicit abort) short-circuits the `&&`. **(b)** A redirect `helper > tok.txt` truncates the cache file *before* the helper runs, so a 401 leaves an *empty* file for the next read — write atomic-validated (`mktemp` + `grep ghs_` + `mv`) instead.
|
|
213
|
+
|
|
214
|
+
**Recurrence:** First confirmed — `macf-cv-architect` 2026-06-12 (4 issue/PR comments posted as the operator under an intermittent GitHub-side 401). Verified by 3-lens adversarial review (source-code / shell-mechanism / alternative-cause, 3-0 survived). Generalizes to every agent's inline / file-cache refresh form; the substrate workbenches additionally had the footgun *taught* by an unrefreshed bootstrap `gh-token-refresh.md` that `macf update` does not distribute.
|
|
215
|
+
|
|
216
|
+
**Defense status:** layered. **(1) DOC (shipped):** de-footgun `gh-token-refresh.md` (+ `agent-identity.md`) — fail-loud `GH_TOKEN=$(helper) || exit 1`, no inline-refresh, atomic-validated file-cache; this rule's sister `gh-token-attribution-traps.md` strengthened with the export-mask. **(2) STRUCTURAL — the load-bearing fix, Pattern A:** a result-invariant **PostToolUse** check asserting the just-written resource's `author` == expected bot login (`macf-whoami.sh` / `--json author`) — the only level that sees through inline-clobber, file-cache-staleness, *and* future bypass shapes, because it checks what was actually posted, not the command-string shape (filed macf#489). **(3) Decided-against:** teaching the PreToolUse hook to detect inline `GH_TOKEN=$(...)` reassignment — brittle regex over arbitrary shell, and the export-mask makes the safe-predicate subtle.
|
|
217
|
+
|
|
218
|
+
**Class lesson:** a structural defense that validates a precondition at the **wrong temporal level** — pre-command ambient state instead of the post-mutation runtime value — provides no protection in exactly the regime it was built for. **Result-invariant assertion at the boundary (Pattern A) is the temporal-level-agnostic fix; command-string precondition checks are not.** This is the clearest case yet of *why* Pattern A bears the most weight in this rule.
|
|
219
|
+
|
|
166
220
|
---
|
|
167
221
|
|
|
168
222
|
## How to recognize the class on first encounter
|
|
@@ -306,7 +360,7 @@ Silent-fallback hazards are **architectural**, not implementation bugs. They eme
|
|
|
306
360
|
|
|
307
361
|
For coordination-system safety analysis: this is a class of hazards multi-agent systems must explicitly defend against. Each new instance teaches the same lesson; the class-name is what makes the lesson transferable across agents.
|
|
308
362
|
|
|
309
|
-
### Defense-pattern emergence (
|
|
363
|
+
### Defense-pattern emergence (9-of-10 active instances have structural defense applied or shipped)
|
|
310
364
|
|
|
311
365
|
| Instance | Surface | Structural defense | Pattern |
|
|
312
366
|
|---|---|---|---|
|
|
@@ -319,10 +373,12 @@ For coordination-system safety analysis: this is a class of hazards multi-agent
|
|
|
319
373
|
| 7 — OTel-counter cumulative-state vs short-lived-process lifecycle | Metric-instrumentation lifecycle | Two-phase: doc workaround `sum(increase(...))` + OTel SDK delta temporality | Pattern A |
|
|
320
374
|
| 8 — OTLP endpoint silent-drop | Observability-endpoint routing | Five-surface defense: CLI release-discipline + substrate testers env-override + canonical template `:14318` default + cluster-side compat port-map + agent-process `doctor-otel.sh` Pattern A | Pattern A (composite — first multi-architectural-layer case in this rule; instances 1-7 have single-pattern defenses) |
|
|
321
375
|
| 9 — Sigstore TLOG orphans on failed npm publish (sister-class) | npm publish + sigstore attestation pipeline | Three-defense composite: bump-version recovery (DR-022 Amendment L) + pre-flight registry-collision check (Pattern D analog, macf#380) + TLOG-state observability (devops-toolkit#74+#77 Grafana dashboard live) | Pattern D analog (pre-flight precheck) + recovery-procedure-codification |
|
|
376
|
+
| 11 — Third-party retry-wrapping action exits 0 on retry-exhaustion | Consumer-CI connect/auth via third-party action (tailnet, OTLP, cloud-auth, registry-login) | SHIPPED — "Verify <resource> is up" step immediately after the connect asserts the connection's result-invariant (e.g. `tailscale status` `BackendState == "Running"`) + fails LOUD; never trusts the action's exit code about its own retry exhaustion (macf#461) | Pattern A (post-connect result-invariant assert) + Pattern D flavor (precheck-before-downstream) |
|
|
377
|
+
| 12 — PreToolUse credential-guard validates ambient token, blind to inline reassignment | gh-token PreToolUse hook + inline `export GH_TOKEN=$(...) && gh` (refresh-chain or file-cache) | DOC shipped (de-footgun `gh-token-refresh.md` + atomic-validated cache) + STRUCTURAL in flight (Pattern A result-invariant PostToolUse whoami post-check, macf#489) | Pattern A (result-invariant post-check — a wrong-temporal-level precondition can't see the inline clobber) |
|
|
322
378
|
|
|
323
|
-
|
|
379
|
+
Ten of eleven active instances have structural defense applied, shipped, or in flight. Defense patterns (A, B, C, D, E) generalize across instances — they're reusable defense templates, not case-specific fixes. **Pattern A (result-invariant assertion at the boundary) bears the most weight** — it's the structural defense for instances 4, 7, 8, 11, AND 12 (5 of 11), each at a different architectural boundary (logs pipeline, metric counter, observability endpoint, third-party-action connect-verify, credential-refresh temporal-binding). Instance 8's five-surface defense topology (consumer canonical + cluster-side compat port-map + concrete Pattern A impl) demonstrates that structural defense at the observability-pipeline-class can compose across architectural layers — the canonical-distribution layer + the cluster-infrastructure layer + the assertion-script layer all reinforce each other rather than substituting for each other. Instance 9 demonstrates that the Pattern D template generalizes from workflow-secrets-prechecks to release-pipeline-prechecks AND that recovery-procedure-codification (DR-022 Amendment L's bump-version-not-tag-retry) is its own defense category — distinct from detection-pre-merge defenses (Patterns A/B/D) and discrimination-at-receiver defenses (Pattern E).
|
|
324
380
|
|
|
325
|
-
The breadth of layers spanned by 5 different defense patterns (identity, parsing, TUI binding, observability routing, config substitution, multi-agent coordination protocol, metric-instrumentation lifecycle, observability-endpoint routing, release-pipeline-partial-publish) is independent evidence that the hazard CLASS is real. If silent-fallback was a single-instance accident, no defense pattern would emerge. **Pattern A's recurrence across 3 different observability boundaries (logs / metrics / endpoint) is the strongest signal that result-invariant assertion is the load-bearing structural-defense template for the entire observability-pipeline-class** of silent fallback.
|
|
381
|
+
The breadth of layers spanned by 5 different defense patterns (identity, parsing, TUI binding, observability routing, config substitution, multi-agent coordination protocol, metric-instrumentation lifecycle, observability-endpoint routing, release-pipeline-partial-publish, third-party-action retry-exhaustion, credential-refresh temporal-binding) is independent evidence that the hazard CLASS is real. If silent-fallback was a single-instance accident, no defense pattern would emerge. **Pattern A's recurrence across 3 different observability boundaries (logs / metrics / endpoint) is the strongest signal that result-invariant assertion is the load-bearing structural-defense template for the entire observability-pipeline-class** of silent fallback.
|
|
326
382
|
|
|
327
383
|
---
|
|
328
384
|
|
|
@@ -336,7 +392,7 @@ Add when ALL of the following hold:
|
|
|
336
392
|
|
|
337
393
|
The class-name is what makes the lesson transferable, not multi-agent witness. A single-agent-confirmed instance with a concrete trace + identified defense pattern is sufficient for canonicalization (instances 4, 5, 7, 8 are all single-agent-confirmed). Cross-agent triangulation strengthens the framing but isn't a precondition.
|
|
338
394
|
|
|
339
|
-
Add as a new numbered section
|
|
395
|
+
Add as a new numbered section (the next number is **13** — numbering is append-only; retired instances keep their slot, see Instance 10) with the same fields: Surface / Failure shape / Recurrence / Defense status. Increment the intro paragraph's active-instance count + the Defense-pattern emergence header's `N-of-M active instances` count too.
|
|
340
396
|
|
|
341
397
|
---
|
|
342
398
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# check-auditor-never-acts.sh — Claude Code PreToolUse hook that blocks
|
|
4
|
+
# state-mutating `gh` ops (`gh pr merge`, `gh issue close`, `gh pr close`)
|
|
5
|
+
# when the active identity is the AUDITOR (`MACF_AGENT_ROLE=auditor`), while
|
|
6
|
+
# leaving the propose verbs (`gh issue/pr create|comment`) and all reads
|
|
7
|
+
# untouched. Structurally enforces the auditor's "never-acts" boundary per
|
|
8
|
+
# DR-026 (the auditor — self-evolving coordination governance): the auditor
|
|
9
|
+
# is write-PROPOSALS-only; it opens issues/PRs and comments, but never merges
|
|
10
|
+
# or closes — those acts route to a non-auditor implementer / the operator.
|
|
11
|
+
#
|
|
12
|
+
# Why structural and not permission-based: a GitHub App's `pull_requests:write`
|
|
13
|
+
# permission grants merge+close TOGETHER with open-PR; there is no "open-a-PR-
|
|
14
|
+
# but-not-merge" permission scope to express. The never-acts boundary therefore
|
|
15
|
+
# has to be enforced at tool-call time, in the same family as the sibling
|
|
16
|
+
# `check-*.sh` hooks (#140 token / #244+#272 mention / #270 lgtm / #431 close /
|
|
17
|
+
# #489 attribution).
|
|
18
|
+
#
|
|
19
|
+
# Hook contract (PreToolUse): JSON on stdin, exit 0 = allow, exit 2 = block
|
|
20
|
+
# (stderr is fed back to Claude as the error). Same shape as #140's
|
|
21
|
+
# check-gh-token.sh + #270's check-lgtm-gate.sh.
|
|
22
|
+
#
|
|
23
|
+
# Inert for every NON-auditor identity (exit 0 before any parsing) — this is
|
|
24
|
+
# the load-bearing gate that makes fleet-wide distribution safe: shipping this
|
|
25
|
+
# hook to every workspace via `macf init` / `macf update` is a no-op everywhere
|
|
26
|
+
# except the auditor, so code-agent / science-agent / cv-* keep their full
|
|
27
|
+
# `gh` surface unchanged.
|
|
28
|
+
#
|
|
29
|
+
# Override: MACF_SKIP_AUDITOR_ACT_CHECK=1 bypasses (for a sanctioned exception
|
|
30
|
+
# — e.g. the operator explicitly authorizing the auditor to perform a one-off
|
|
31
|
+
# merge/close). Consistent with MACF_SKIP_TOKEN_CHECK / MACF_SKIP_LGTM_CHECK /
|
|
32
|
+
# MACF_SKIP_CLOSE_CHECK / MACF_SKIP_ATTRIBUTION_CHECK in the sister hooks.
|
|
33
|
+
#
|
|
34
|
+
# Refs: groundnuty/macf#499 (this hook); DR-026 §1/§4 (auditor never-acts
|
|
35
|
+
# boundary; PROPOSED via #495); #140 / #244+#272 / #270 / #431 / #489
|
|
36
|
+
# (sister Path-2 hooks).
|
|
37
|
+
set -uo pipefail
|
|
38
|
+
|
|
39
|
+
# Defense-in-depth: any unexpected error past this point must NOT brick the
|
|
40
|
+
# harness. We use `set -uo pipefail` (NOT `-e`) so commands that fail are
|
|
41
|
+
# handled explicitly; this trap is a final safety net for a genuinely
|
|
42
|
+
# unexpected fault — fail open (allow).
|
|
43
|
+
trap 'exit 0' ERR
|
|
44
|
+
|
|
45
|
+
# 1. Operator override first — cheapest exit. No stdin read, no parsing.
|
|
46
|
+
if [[ "${MACF_SKIP_AUDITOR_ACT_CHECK:-}" == "1" ]]; then
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# 2. Inert for every non-auditor identity. This is the load-bearing gate —
|
|
51
|
+
# when the active role isn't the auditor, the hook does nothing, so it is
|
|
52
|
+
# safe to distribute to every workspace. `MACF_AGENT_ROLE` is exported by
|
|
53
|
+
# claude.sh (env-files.ts) as the agent's role.
|
|
54
|
+
if [[ "${MACF_AGENT_ROLE:-}" != "auditor" ]]; then
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# 3. Read the PreToolUse payload. Fall through to allow on parse error — a
|
|
59
|
+
# broken hook must not brick the harness. Same defense-in-depth as
|
|
60
|
+
# check-gh-token.sh / check-lgtm-gate.sh.
|
|
61
|
+
INPUT_JSON="$(cat 2>/dev/null || echo "")"
|
|
62
|
+
COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
|
|
63
|
+
|
|
64
|
+
if [[ -z "$COMMAND" ]]; then
|
|
65
|
+
# No command extractable — allow (defense-in-depth).
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# 4. Is this a `gh` invocation at all? Reuse the wrapper-aware GH_PATTERN +
|
|
70
|
+
# SHELL_C_PATTERN from check-gh-token.sh so `sudo gh`, `env X= gh`,
|
|
71
|
+
# `bash -c "gh …"`, and chained `&& gh` forms all count. If it's not a gh
|
|
72
|
+
# command, there is nothing for this hook to gate — allow.
|
|
73
|
+
GH_PATTERN='(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|watch[[:space:]]+|ionice[[:space:]]+|setsid[[:space:]]+|nice[[:space:]]+|time[[:space:]]+|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]'
|
|
74
|
+
SHELL_C_PATTERN='(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*(bash|sh|zsh)[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-[a-zA-Z]*c[[:space:]]+[^[:space:]].*gh[[:space:]]'
|
|
75
|
+
|
|
76
|
+
if [[ ! "$COMMAND" =~ $GH_PATTERN ]] && [[ ! "$COMMAND" =~ $SHELL_C_PATTERN ]]; then
|
|
77
|
+
# Not a gh command — allow.
|
|
78
|
+
exit 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# 5. Is the command one of the BLOCKED acting-verbs? Each op is matched two
|
|
82
|
+
# ways, mirroring check-lgtm-gate.sh's merge match: a WRAPPER pattern
|
|
83
|
+
# (sudo / env VAR= / watch / nice / chained-leadin `;|&` / inline VAR=)
|
|
84
|
+
# and a SHELL_C pattern (`bash -c "gh pr merge …"` and variants). Both
|
|
85
|
+
# anchor the `gh <noun> <verb>` substring and end on a whitespace-or-EOL
|
|
86
|
+
# boundary so e.g. `gh pr merge` matches but a hypothetical
|
|
87
|
+
# `gh pr merge-base` does NOT (exact-subcommand match).
|
|
88
|
+
#
|
|
89
|
+
# ── BLOCKED acting-verbs (DENYLIST; intentionally minimal) ──────────────
|
|
90
|
+
# This is a denylist of STATE-MUTATING acts. The propose verbs
|
|
91
|
+
# (`gh issue create`, `gh pr create`, `gh issue comment`, `gh pr comment`)
|
|
92
|
+
# are deliberately ABSENT — the auditor is write-proposals-only, so those
|
|
93
|
+
# must fall through to the allow at the bottom. To extend the boundary
|
|
94
|
+
# (e.g. block `gh issue edit` of another agent's issue), add a `<noun> <verb>`
|
|
95
|
+
# entry to BLOCKED_VERBS below; keep the propose verbs out.
|
|
96
|
+
#
|
|
97
|
+
# gh pr merge — merging a PR is an act, not a proposal
|
|
98
|
+
# gh issue close — closing an issue is an act (reporter-owns-closure)
|
|
99
|
+
# gh pr close — closing a PR is an act
|
|
100
|
+
BLOCKED_VERBS=(
|
|
101
|
+
'pr merge'
|
|
102
|
+
'issue close'
|
|
103
|
+
'pr close'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Build the wrapper + shell-c regexes for a given `<noun> <verb>` and test the
|
|
107
|
+
# command against both. Echoes the canonical `gh <noun> <verb>` label on a hit.
|
|
108
|
+
_match_blocked_verb() {
|
|
109
|
+
local noun_verb="$1" # e.g. "pr merge"
|
|
110
|
+
local cmd="$2"
|
|
111
|
+
# Translate the space in "<noun> <verb>" into the whitespace-class form.
|
|
112
|
+
local nv="${noun_verb/ /[[:space:]]+}"
|
|
113
|
+
local wrapper_pat="(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|watch[[:space:]]+|ionice[[:space:]]+|setsid[[:space:]]+|nice[[:space:]]+|time[[:space:]]+|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]+${nv}([[:space:]]|$)"
|
|
114
|
+
local shell_c_pat="(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*(bash|sh|zsh)[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-[a-zA-Z]*c[[:space:]]+[^[:space:]].*gh[[:space:]]+${nv}([[:space:]]|$)"
|
|
115
|
+
if [[ "$cmd" =~ $wrapper_pat ]] || [[ "$cmd" =~ $shell_c_pat ]]; then
|
|
116
|
+
echo "gh ${noun_verb}"
|
|
117
|
+
return 0
|
|
118
|
+
fi
|
|
119
|
+
return 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
BLOCKED_OP=""
|
|
123
|
+
for verb in "${BLOCKED_VERBS[@]}"; do
|
|
124
|
+
if hit="$(_match_blocked_verb "$verb" "$COMMAND")"; then
|
|
125
|
+
BLOCKED_OP="$hit"
|
|
126
|
+
break
|
|
127
|
+
fi
|
|
128
|
+
done
|
|
129
|
+
|
|
130
|
+
if [[ -z "$BLOCKED_OP" ]]; then
|
|
131
|
+
# 6. Not a blocked acting-verb — the propose verbs (gh issue/pr create,
|
|
132
|
+
# gh issue/pr comment) and all reads fall through here. Write-proposals-
|
|
133
|
+
# only is the auditor's permitted power. Allow.
|
|
134
|
+
exit 0
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# Blocked acting-verb under the auditor identity — block LOUD.
|
|
138
|
+
cat >&2 <<ERR
|
|
139
|
+
BLOCKED by MACF auditor-never-acts hook: the AUDITOR is write-PROPOSALS-only and
|
|
140
|
+
must never perform a state-mutating act. The command you ran is a \`${BLOCKED_OP}\`,
|
|
141
|
+
which closes/merges a resource — that is an ACT, not a proposal.
|
|
142
|
+
|
|
143
|
+
Command: ${COMMAND}
|
|
144
|
+
Blocked op: ${BLOCKED_OP}
|
|
145
|
+
|
|
146
|
+
Per DR-026 §1/§4 (the auditor — self-evolving coordination governance), the
|
|
147
|
+
auditor opens issues + PRs and comments to PROPOSE changes, but the merge/close
|
|
148
|
+
ACT belongs to a non-auditor implementer (code-agent / science-agent) or the
|
|
149
|
+
operator. This boundary is structural because a GitHub App's
|
|
150
|
+
\`pull_requests:write\` permission grants merge+close together with open-PR —
|
|
151
|
+
there is no "open-a-PR-but-not-merge" scope to express it, so the hook enforces
|
|
152
|
+
it at tool-call time.
|
|
153
|
+
|
|
154
|
+
Fix — route the act to a non-auditor identity:
|
|
155
|
+
- For a merge: @mention the PR's implementer on the issue thread; they merge
|
|
156
|
+
after the LGTM gate, per coordination.md / pr-discipline.md.
|
|
157
|
+
- For a close: @mention the issue's reporter; reporter-owns-closure
|
|
158
|
+
(coordination.md §Issue Lifecycle 1) — they close after verifying.
|
|
159
|
+
Leave the auditor's role to the PROPOSAL (the issue / PR / comment) you
|
|
160
|
+
already created.
|
|
161
|
+
|
|
162
|
+
Override (ONLY for an operator-sanctioned exception):
|
|
163
|
+
export MACF_SKIP_AUDITOR_ACT_CHECK=1
|
|
164
|
+
|
|
165
|
+
Refs: groundnuty/macf#499 (this hook); DR-026 §1/§4 (auditor never-acts).
|
|
166
|
+
ERR
|
|
167
|
+
exit 2
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# check-gh-attribution.sh — Claude Code PostToolUse hook that, AFTER a
|
|
4
|
+
# `gh`-write Bash op, verifies the just-written GitHub resource (issue /
|
|
5
|
+
# PR / comment) was authored by the BOT, not the operator's user account.
|
|
6
|
+
# A user-attributed write is the silent-fallback Instance-12 attribution
|
|
7
|
+
# trap: the `gh` call fell back to stored `gh auth login` (user) because
|
|
8
|
+
# GH_TOKEN was empty / a `ghp_`/`gho_`/`ghu_` user token / the literal
|
|
9
|
+
# string "null", and nothing surfaced the mismatch at the time. The
|
|
10
|
+
# #140 PreToolUse `check-gh-token.sh` catches the *missing-bot-token*
|
|
11
|
+
# shape BEFORE the call; this hook is the result-invariant backstop that
|
|
12
|
+
# catches a slipped write AFTER the fact by reading who actually authored
|
|
13
|
+
# the resource on GitHub.
|
|
14
|
+
#
|
|
15
|
+
# Hook contract (PostToolUse): JSON on stdin, exit 0 = ok. PostToolUse
|
|
16
|
+
# CANNOT block (the tool already ran) — the loud signal is `exit 2` with a
|
|
17
|
+
# multi-line stderr message, which Claude Code surfaces back to Claude.
|
|
18
|
+
# Read both the newer (`.tool_output.stdout`) and older
|
|
19
|
+
# (`.tool_response.stdout` / `.tool_response`) output shapes defensively.
|
|
20
|
+
#
|
|
21
|
+
# Posture: FAIL-OPEN. `set -uo pipefail` (NOT `-e`) — every uncertain
|
|
22
|
+
# branch (no URL, gh failure, can't parse, can't resolve expected login)
|
|
23
|
+
# exits 0. A false WARN is more costly than a missed one here: the call
|
|
24
|
+
# already happened, and the operator may be running a knowingly
|
|
25
|
+
# user-attributed op. Only a CONFIRMED user-authored write fires `exit 2`.
|
|
26
|
+
#
|
|
27
|
+
# Override: MACF_SKIP_ATTRIBUTION_CHECK=1 bypasses (intentional
|
|
28
|
+
# user-attributed ops, e.g. an onboarding `gh` call before the bot token
|
|
29
|
+
# is wired). Consistent with MACF_SKIP_TOKEN_CHECK / MACF_SKIP_CLOSE_CHECK
|
|
30
|
+
# in the sister hooks.
|
|
31
|
+
#
|
|
32
|
+
# Refs: groundnuty/macf#489 (this hook); silent-fallback-hazards.md
|
|
33
|
+
# Instance 12; coordination.md §Token & Git Hygiene (the attribution
|
|
34
|
+
# trap); #140 / #244+#272 / #270 / #431 (sister Path-2 hooks).
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
# Cheap exit on operator override — no stdin read, no parsing.
|
|
38
|
+
if [[ "${MACF_SKIP_ATTRIBUTION_CHECK:-}" == "1" ]]; then
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Defense-in-depth: any unexpected error past this point must NOT brick the
|
|
43
|
+
# harness. We already use `set -uo pipefail` (no `-e`) so commands that fail
|
|
44
|
+
# are handled explicitly; this trap is a final safety net for a genuinely
|
|
45
|
+
# unexpected fault (e.g. a bash internal error) — fail open.
|
|
46
|
+
trap 'exit 0' ERR
|
|
47
|
+
|
|
48
|
+
# Read the PostToolUse payload. Fall through to allow on parse error.
|
|
49
|
+
INPUT_JSON="$(cat)"
|
|
50
|
+
COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
|
|
51
|
+
[[ -z "$COMMAND" ]] && exit 0
|
|
52
|
+
|
|
53
|
+
# ── Is this a gh-write op that produces an attributable resource? ─────────
|
|
54
|
+
# Match the write subcommands whose output carries a resource/comment URL:
|
|
55
|
+
# gh issue comment / gh pr comment → posts a comment
|
|
56
|
+
# gh issue create / gh pr create → creates the resource
|
|
57
|
+
# gh issue close … --comment → posts a closing comment
|
|
58
|
+
# gh pr close … --comment → posts a closing comment
|
|
59
|
+
# A bare `gh issue close` (no --comment) writes nothing attributable → skip.
|
|
60
|
+
# This is a RESULT check (not a blocker), so a simple wrapper-tolerant
|
|
61
|
+
# `grep -qE` over the raw command suffices — we don't need the airtight
|
|
62
|
+
# bypass-resistant regex the PreToolUse blockers carry.
|
|
63
|
+
is_gh_write() {
|
|
64
|
+
local cmd="$1"
|
|
65
|
+
if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+comment([[:space:]]|$)' <<<"$cmd"; then
|
|
66
|
+
return 0
|
|
67
|
+
fi
|
|
68
|
+
if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+create([[:space:]]|$)' <<<"$cmd"; then
|
|
69
|
+
return 0
|
|
70
|
+
fi
|
|
71
|
+
# close … --comment (the --comment may appear anywhere after the verb)
|
|
72
|
+
if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+close([[:space:]]|$)' <<<"$cmd" \
|
|
73
|
+
&& grep -qiE '(^|[[:space:]])--comment([[:space:]]|=|$)' <<<"$cmd"; then
|
|
74
|
+
return 0
|
|
75
|
+
fi
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
is_gh_write "$COMMAND" || exit 0
|
|
79
|
+
|
|
80
|
+
# ── Extract the resource URL from the tool output ─────────────────────────
|
|
81
|
+
# `gh issue create` / `gh pr create` print the new URL on stdout; `gh issue
|
|
82
|
+
# comment` / `gh pr comment` print the comment URL (…#issuecomment-<id>).
|
|
83
|
+
# Read both PostToolUse output shapes (newer `.tool_output.stdout`, older
|
|
84
|
+
# `.tool_response.stdout`, oldest `.tool_response` as a raw string).
|
|
85
|
+
OUTPUT="$(jq -r '.tool_output.stdout // .tool_response.stdout // .tool_response // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
|
|
86
|
+
[[ -z "$OUTPUT" ]] && exit 0
|
|
87
|
+
|
|
88
|
+
URL="$(grep -oE 'https://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/(issues|pull)/[0-9]+(#issuecomment-[0-9]+)?' <<<"$OUTPUT" | head -1 || true)"
|
|
89
|
+
# No URL in output (e.g. `--json` suppressed it, or output was discarded) →
|
|
90
|
+
# fail open. We can't verify what we can't see.
|
|
91
|
+
[[ -z "$URL" ]] && exit 0
|
|
92
|
+
|
|
93
|
+
# ── Parse the URL → owner / repo / kind / number / optional comment-id ────
|
|
94
|
+
# Form: https://github.com/<owner>/<repo>/(issues|pull)/<N>[#issuecomment-<id>]
|
|
95
|
+
URL_PATH="${URL#https://github.com/}"
|
|
96
|
+
OWNER="$(cut -d/ -f1 <<<"$URL_PATH")"
|
|
97
|
+
REPO="$(cut -d/ -f2 <<<"$URL_PATH")"
|
|
98
|
+
KIND="$(cut -d/ -f3 <<<"$URL_PATH")" # issues | pull
|
|
99
|
+
NUM_AND_FRAG="$(cut -d/ -f4 <<<"$URL_PATH")" # <N> | <N>#issuecomment-<id>
|
|
100
|
+
NUM="${NUM_AND_FRAG%%#*}"
|
|
101
|
+
COMMENT_ID=""
|
|
102
|
+
if [[ "$NUM_AND_FRAG" == *"#issuecomment-"* ]]; then
|
|
103
|
+
COMMENT_ID="${NUM_AND_FRAG##*#issuecomment-}"
|
|
104
|
+
fi
|
|
105
|
+
# Sanity — if any required piece is missing/odd, fail open.
|
|
106
|
+
[[ -z "$OWNER" || -z "$REPO" || -z "$KIND" || -z "$NUM" ]] && exit 0
|
|
107
|
+
|
|
108
|
+
# ── Build the ACTUAL-resource API path ────────────────────────────────────
|
|
109
|
+
# Comment-id present → both issue AND pr comments live under the issues
|
|
110
|
+
# comments namespace; else a PR → pulls/<N>; else an issue → issues/<N>.
|
|
111
|
+
if [[ -n "$COMMENT_ID" ]]; then
|
|
112
|
+
API_PATH="/repos/${OWNER}/${REPO}/issues/comments/${COMMENT_ID}"
|
|
113
|
+
elif [[ "$KIND" == "pull" ]]; then
|
|
114
|
+
API_PATH="/repos/${OWNER}/${REPO}/pulls/${NUM}"
|
|
115
|
+
else
|
|
116
|
+
API_PATH="/repos/${OWNER}/${REPO}/issues/${NUM}"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
# ── Query the author (short timeout; one brief retry for API consistency) ─
|
|
120
|
+
# The resource was JUST created, so a first read can occasionally race the
|
|
121
|
+
# write through GitHub's read replicas. One `sleep 1` retry handles that
|
|
122
|
+
# without materially delaying the turn. gh failure / empty → fail open.
|
|
123
|
+
query_author() {
|
|
124
|
+
GH_PAGER= gh api "$API_PATH" --jq '{login: .user.login, type: .user.type}' 2>/dev/null
|
|
125
|
+
}
|
|
126
|
+
RESP="$(query_author || true)"
|
|
127
|
+
if [[ -z "$RESP" ]]; then
|
|
128
|
+
sleep 1
|
|
129
|
+
RESP="$(query_author || true)"
|
|
130
|
+
fi
|
|
131
|
+
[[ -z "$RESP" ]] && exit 0
|
|
132
|
+
|
|
133
|
+
ACTUAL_LOGIN="$(jq -r '.login // ""' <<<"$RESP" 2>/dev/null || echo "")"
|
|
134
|
+
ACTUAL_TYPE="$(jq -r '.type // ""' <<<"$RESP" 2>/dev/null || echo "")"
|
|
135
|
+
# Couldn't extract an author at all → fail open.
|
|
136
|
+
[[ -z "$ACTUAL_LOGIN" ]] && exit 0
|
|
137
|
+
|
|
138
|
+
# ── Resolve the EXPECTED bot login (first hit wins; may be empty) ─────────
|
|
139
|
+
# 1. $MACF_EXPECTED_BOT_LOGIN — explicit operator/test override.
|
|
140
|
+
# 2. .macf/macf-agent.json — derive `<name>[bot]` from the workspace config.
|
|
141
|
+
# The canonical workspace field is `agent_name`; the repo-side
|
|
142
|
+
# agent-config.json carries `app_name`. Accept either (agent_name first).
|
|
143
|
+
# 3. empty — fall back to the type-based check below.
|
|
144
|
+
EXPECTED_LOGIN="${MACF_EXPECTED_BOT_LOGIN:-}"
|
|
145
|
+
if [[ -z "$EXPECTED_LOGIN" ]]; then
|
|
146
|
+
AGENT_JSON="${CLAUDE_PROJECT_DIR:-.}/.macf/macf-agent.json"
|
|
147
|
+
if [[ -f "$AGENT_JSON" ]]; then
|
|
148
|
+
AGENT_NAME="$(jq -r '.agent_name // .app_name // ""' "$AGENT_JSON" 2>/dev/null || echo "")"
|
|
149
|
+
if [[ -n "$AGENT_NAME" ]]; then
|
|
150
|
+
# Append `[bot]` exactly once (tolerate a config that already carries it).
|
|
151
|
+
EXPECTED_LOGIN="${AGENT_NAME%"[bot]"}[bot]"
|
|
152
|
+
fi
|
|
153
|
+
fi
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# Normalize a login for comparison: strip a leading `app/` prefix (gh's
|
|
157
|
+
# GraphQL author.login carries `app/<name>`; the REST `.user.login` does
|
|
158
|
+
# not) and lowercase. Echoes the normalized form.
|
|
159
|
+
normalize_login() {
|
|
160
|
+
local l="$1"
|
|
161
|
+
l="${l#app/}"
|
|
162
|
+
echo "${l,,}"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
NORM_ACTUAL="$(normalize_login "$ACTUAL_LOGIN")"
|
|
166
|
+
|
|
167
|
+
# ── Decide: OK vs MISMATCH ────────────────────────────────────────────────
|
|
168
|
+
MISMATCH=0
|
|
169
|
+
if [[ -n "$EXPECTED_LOGIN" ]]; then
|
|
170
|
+
NORM_EXPECTED="$(normalize_login "$EXPECTED_LOGIN")"
|
|
171
|
+
if [[ "$NORM_ACTUAL" == "$NORM_EXPECTED" ]]; then
|
|
172
|
+
exit 0
|
|
173
|
+
fi
|
|
174
|
+
MISMATCH=1
|
|
175
|
+
else
|
|
176
|
+
# No expected login known — best verifiable signal is the author TYPE.
|
|
177
|
+
# A Bot authored it → trust it (some bot posted; correct by design).
|
|
178
|
+
# A User authored it → the Instance-12 trap (a human account wrote it).
|
|
179
|
+
if [[ "$ACTUAL_TYPE" == "Bot" ]]; then
|
|
180
|
+
exit 0
|
|
181
|
+
fi
|
|
182
|
+
MISMATCH=1
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
[[ "$MISMATCH" -ne 1 ]] && exit 0
|
|
186
|
+
|
|
187
|
+
# ── MISMATCH → loud warning to stderr, then exit 2 ────────────────────────
|
|
188
|
+
EXPECTED_LINE="(unknown — set \$MACF_EXPECTED_BOT_LOGIN or .macf/macf-agent.json)"
|
|
189
|
+
if [[ -n "$EXPECTED_LOGIN" ]]; then
|
|
190
|
+
EXPECTED_LINE="$EXPECTED_LOGIN"
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
cat >&2 <<ERR
|
|
194
|
+
WARNING (MACF attribution-result check): the GitHub resource you just wrote
|
|
195
|
+
appears to be authored by the WRONG account — the silent-fallback Instance-12
|
|
196
|
+
attribution trap (a \`gh\` write fell back to the operator's USER auth instead
|
|
197
|
+
of the bot installation token).
|
|
198
|
+
|
|
199
|
+
Resource: ${URL}
|
|
200
|
+
Authored by: ${ACTUAL_LOGIN} (type: ${ACTUAL_TYPE:-unknown})
|
|
201
|
+
Expected (bot): ${EXPECTED_LINE}
|
|
202
|
+
|
|
203
|
+
The tool already ran — this is a PostToolUse check, so the resource is live on
|
|
204
|
+
GitHub under the wrong attribution. Cross-agent routing keys off the bot login;
|
|
205
|
+
a user-attributed comment/issue/PR is invisible to peers and breaks the
|
|
206
|
+
reporter-owns-closure + @mention-routing contracts.
|
|
207
|
+
|
|
208
|
+
Repair:
|
|
209
|
+
1. Refresh the bot token (fail-loud helper), THEN re-do the op as the bot:
|
|
210
|
+
|
|
211
|
+
GH_TOKEN=\$("\$MACF_WORKSPACE_DIR/.claude/scripts/macf-gh-token.sh" \\
|
|
212
|
+
--app-id "\$APP_ID" --install-id "\$INSTALL_ID" --key "\$KEY_PATH") || exit 1
|
|
213
|
+
export GH_TOKEN
|
|
214
|
+
|
|
215
|
+
2. If the resource has NOT been replied-to yet, delete the mis-attributed
|
|
216
|
+
write and re-post it as the bot (clean correction).
|
|
217
|
+
3. If it HAS already been replied-to / acted-on, do NOT delete — post a
|
|
218
|
+
short clarify-forward correction comment AS THE BOT noting the prior
|
|
219
|
+
write was mis-attributed, so the thread stays coherent.
|
|
220
|
+
|
|
221
|
+
Verify your identity any time:
|
|
222
|
+
GH_TOKEN=\$GH_TOKEN "\$MACF_WORKSPACE_DIR/.claude/scripts/macf-whoami.sh"
|
|
223
|
+
|
|
224
|
+
Override (ONLY for intentional user-attributed ops, e.g. onboarding):
|
|
225
|
+
export MACF_SKIP_ATTRIBUTION_CHECK=1
|
|
226
|
+
|
|
227
|
+
Refs: groundnuty/macf#489 (this hook); silent-fallback-hazards.md Instance 12;
|
|
228
|
+
coordination.md §Token & Git Hygiene.
|
|
229
|
+
ERR
|
|
230
|
+
exit 2
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# UserPromptSubmit hook — turn-ack receipt for routed prompts
|
|
3
|
+
# (groundnuty/macf#444 Option D, piece 2).
|
|
4
|
+
#
|
|
5
|
+
# When the router injects a prompt it appends a correlation marker
|
|
6
|
+
# `[macf-route:<run_id>:<agent>]` (macf-actions#45, piece 1). When that prompt
|
|
7
|
+
# is submitted AS A TURN, this hook fires and emits a `turn_processed` OTel
|
|
8
|
+
# span carrying (routed_run_id, agent). A reconciler (piece 4) joins the
|
|
9
|
+
# router's delivered-pairs log against these spans: a delivered route with no
|
|
10
|
+
# matching span past the open-threshold = a dropped ping that surfaces
|
|
11
|
+
# structurally (closes the #437 send≠receipt gap). A prompt with NO marker
|
|
12
|
+
# (a typed prompt, a non-routed turn) is a no-op — exit 0, emit nothing.
|
|
13
|
+
#
|
|
14
|
+
# Registered `async: true` (settings.json) so it never adds turn latency. It
|
|
15
|
+
# NEVER blocks the turn: any failure path exits 0 (with a stderr WARN on a
|
|
16
|
+
# genuine emit error — fail-loud per silent-fallback-hazards.md Instance 8).
|
|
17
|
+
#
|
|
18
|
+
# Dependency: a live OTLP endpoint (OTEL_EXPORTER_OTLP_ENDPOINT — populated on
|
|
19
|
+
# substrate by the #418 launcher OTEL block). Absent/unreachable → curl fails
|
|
20
|
+
# → WARN, no span, no crash. So this ships safely before #418's relaunch; it
|
|
21
|
+
# simply no-ops-with-WARN until substrate OTel is live.
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
|
|
24
|
+
INPUT="$(cat)"
|
|
25
|
+
|
|
26
|
+
# Extract the submitted prompt text. jq if available; else scan the raw payload.
|
|
27
|
+
PROMPT=""
|
|
28
|
+
if command -v jq >/dev/null 2>&1; then
|
|
29
|
+
PROMPT="$(printf '%s' "$INPUT" | jq -r '.prompt // empty' 2>/dev/null || true)"
|
|
30
|
+
fi
|
|
31
|
+
[ -z "$PROMPT" ] && PROMPT="$INPUT"
|
|
32
|
+
|
|
33
|
+
# Route-correlation markers: [macf-route:<digits>:<kebab-agent>]. No match → not
|
|
34
|
+
# a routed prompt → no-op. A COALESCED turn (a busy agent processing several
|
|
35
|
+
# queued routed pings in one turn) carries MULTIPLE markers — emit a receipt for
|
|
36
|
+
# EACH distinct one. A receipt then means "the marked prompt reached the agent",
|
|
37
|
+
# not "a distinct turn happened". (Was `head -1`, which under-emitted → the
|
|
38
|
+
# macf#444 reconciler false-flagged the un-receipted markers as drops — #462.)
|
|
39
|
+
MARKERS="$(printf '%s' "$PROMPT" | grep -oE '\[macf-route:[0-9]+:[a-z0-9-]+\]' | sort -u || true)"
|
|
40
|
+
[ -z "$MARKERS" ] && exit 0
|
|
41
|
+
|
|
42
|
+
# Need curl + openssl to emit; absent → degrade silently (no span, no noise).
|
|
43
|
+
command -v curl >/dev/null 2>&1 || exit 0
|
|
44
|
+
command -v openssl >/dev/null 2>&1 || exit 0
|
|
45
|
+
|
|
46
|
+
BASE="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://orzech-dev-agents-monitoring.tail491af.ts.net:4318}"
|
|
47
|
+
BASE="${BASE%/v1/traces}"
|
|
48
|
+
|
|
49
|
+
# One independent span per distinct marker (own trace/span id + timestamp).
|
|
50
|
+
printf '%s\n' "$MARKERS" | while IFS= read -r MARKER; do
|
|
51
|
+
[ -z "$MARKER" ] && continue
|
|
52
|
+
RUN_ID="$(printf '%s' "$MARKER" | sed -E 's/.*\[macf-route:([0-9]+):([a-z0-9-]+)\].*/\1/')"
|
|
53
|
+
AGENT="$(printf '%s' "$MARKER" | sed -E 's/.*\[macf-route:([0-9]+):([a-z0-9-]+)\].*/\2/')"
|
|
54
|
+
|
|
55
|
+
# Nanosecond epoch. `date +%s%N` is GNU-only (substrate is Linux); on a BSD/mac
|
|
56
|
+
# date that prints a literal `N`, fall back to seconds×1e9.
|
|
57
|
+
NOW="$(date +%s%N 2>/dev/null || true)"
|
|
58
|
+
case "$NOW" in
|
|
59
|
+
*N|'' ) NOW="$(( $(date +%s) * 1000000000 ))" ;;
|
|
60
|
+
esac
|
|
61
|
+
TID="$(openssl rand -hex 16)"
|
|
62
|
+
SID="$(openssl rand -hex 8)"
|
|
63
|
+
|
|
64
|
+
# Identity on resource attrs (matches the collector's resource/paper-dims +
|
|
65
|
+
# the `{resource."gen_ai.agent.name"=…}` TraceQL); correlation on span attrs.
|
|
66
|
+
curl -sf -m 3 -X POST "${BASE}/v1/traces" \
|
|
67
|
+
-H 'Content-Type: application/json' --data-binary @- <<JSON >/dev/null \
|
|
68
|
+
|| echo "WARN: turn_processed span emit failed (endpoint=${BASE}/v1/traces run=${RUN_ID} agent=${AGENT})" >&2
|
|
69
|
+
{"resourceSpans":[{"resource":{"attributes":[
|
|
70
|
+
{"key":"service.name","value":{"stringValue":"${OTEL_SERVICE_NAME:-macf-agent}"}},
|
|
71
|
+
{"key":"gen_ai.agent.name","value":{"stringValue":"${AGENT}"}},
|
|
72
|
+
{"key":"service.namespace","value":{"stringValue":"macf"}}
|
|
73
|
+
]},"scopeSpans":[{"scope":{"name":"macf.hook"},"spans":[{
|
|
74
|
+
"traceId":"${TID}","spanId":"${SID}","name":"turn_processed","kind":1,
|
|
75
|
+
"startTimeUnixNano":"${NOW}","endTimeUnixNano":"${NOW}",
|
|
76
|
+
"attributes":[{"key":"routed_run_id","value":{"stringValue":"${RUN_ID}"}},
|
|
77
|
+
{"key":"agent","value":{"stringValue":"${AGENT}"}}],"status":{"code":1}}]}]}]}
|
|
78
|
+
JSON
|
|
79
|
+
done
|
|
80
|
+
|
|
81
|
+
exit 0
|