@groundnuty/macf 0.2.26 → 0.2.28

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "d8d7ff9ae0877e59eeafe90bf853df2cc7227076",
3
- "built_at": "2026-05-18T21:58:15.013Z"
2
+ "commit": "928c95482ebb793cb87bf567687f616210ec55f1",
3
+ "built_at": "2026-05-19T10:58:05.072Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundnuty/macf",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "Multi-Agent Coordination Framework CLI — coordinate Claude Code agents via GitHub. Installs as `macf` binary; use `macf init` to set up an agent workspace, `macf update` to refresh rules + version pins.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "test:watch": "vitest"
36
36
  },
37
37
  "dependencies": {
38
- "@groundnuty/macf-core": "0.2.26",
38
+ "@groundnuty/macf-core": "0.2.28",
39
39
  "commander": "^14.0.3",
40
40
  "reflect-metadata": "^0.2.2",
41
41
  "zod": "^4.0.0"
@@ -65,7 +65,12 @@ if [[ "$COMMAND" =~ (^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_
65
65
  exit 0
66
66
  fi
67
67
 
68
- # Check GH_TOKEN: must be present AND start with ghs_ (bot token).
68
+ # Check GH_TOKEN: must be present AND match the bot-token shape
69
+ # ^ghs_[A-Za-z0-9_]+$. Prefix-only check (`${GH_TOKEN:0:4} == ghs_`)
70
+ # was bypassable by `GH_TOKEN='ghs_; rm -rf <sentinel>'` — the first
71
+ # four chars matched, the rest never validated. Surfaced as Pattern B's
72
+ # 1/10 anomaly in the §4.4 failure-injection sprint (paper-research §27);
73
+ # canonical-rule update in #364, this script in #365.
69
74
  # ghp_/gho_/ghu_ are user tokens; empty falls through to stored
70
75
  # `gh auth login` (user). Either case fires the trap.
71
76
  # Note: `${GH_TOKEN:-}` expansion is mandatory under `set -u`; a bare
@@ -73,7 +78,7 @@ fi
73
78
  # is unset, which is exactly the case we need to handle.
74
79
  GH_TOKEN_VALUE="${GH_TOKEN:-}"
75
80
  TOKEN_PREFIX="${GH_TOKEN_VALUE:0:4}"
76
- if [[ -z "$GH_TOKEN_VALUE" ]] || [[ "$TOKEN_PREFIX" != "ghs_" ]]; then
81
+ if [[ -z "$GH_TOKEN_VALUE" ]] || [[ ! "$GH_TOKEN_VALUE" =~ ^ghs_[A-Za-z0-9_]+$ ]]; then
77
82
  cat >&2 <<ERR
78
83
  BLOCKED by MACF attribution-trap hook: this command would post as the USER, not the BOT.
79
84
 
@@ -99,4 +104,436 @@ ERR
99
104
  exit 2
100
105
  fi
101
106
 
107
+ # -----------------------------------------------------------------------
108
+ # DR-019 Amendment A (#381) — actions:write audit-log emission
109
+ # -----------------------------------------------------------------------
110
+ # Token-shape check passed; if the command is an `actions:write`-scoped
111
+ # subcommand class, emit OTel span + counter signals BEFORE allowing the
112
+ # call through. Observational only — emission failure must NOT block the
113
+ # call. The only block path is the token-shape check above.
114
+ #
115
+ # Subcommand classes (per DR-019 Amendment A "Pattern-match comprehensiveness"):
116
+ #
117
+ # - Workflow lifecycle: `gh workflow run` → action=dispatch
118
+ # `gh workflow disable` → action=dispatch
119
+ # `gh workflow enable` → action=dispatch
120
+ # - Run lifecycle: `gh run cancel` → action=cancel
121
+ # `gh run rerun` → action=rerun
122
+ # `gh run rerun --failed` → action=rerun
123
+ # - API-direct (POST): `gh api .../actions/workflows/.../dispatches`
124
+ # → action=dispatch
125
+ # `gh api .../actions/runs/{id}/cancel`
126
+ # → action=cancel
127
+ # `gh api .../actions/runs/{id}/rerun`
128
+ # → action=rerun
129
+ # `gh api .../actions/runs/{id}/rerun-failed-jobs`
130
+ # → action=rerun
131
+ #
132
+ # Known instrumentation gaps (per DR-019 Amendment A "Known instrumentation
133
+ # gaps" + science-agent forward-looking note): this PreToolUse hook catches
134
+ # every LLM-issued Bash call to `gh`. It does NOT catch non-Bash subprocess
135
+ # paths from compiled JS/TS (e.g., a hypothetical
136
+ # `child_process.spawn('gh', [...])` from a Node.js channel-server module),
137
+ # nor direct `curl` to GitHub's REST API from such paths. Current MACF
138
+ # architecture has no such paths — `notify_peer` is the only non-Bash
139
+ # subprocess call from compiled code, and it talks to peer agents' `/notify`
140
+ # endpoints (not GitHub). If a non-Bash-`gh` subprocess path emerges later,
141
+ # instrument THEN — YAGNI for current scope.
142
+
143
+ # Dispatch-allowlist regex. Per DR-019 Amendment A, the allowlist governs
144
+ # `dispatch` actions only — `cancel` / `rerun` operate on runs (not workflows)
145
+ # and emit audit-log unconditionally. Keep this as a shell variable near
146
+ # the top of the audit branch so it's trivial to amend per future DR
147
+ # amendments. Match is "workflow filename appears anywhere in the command";
148
+ # this is loose by design so wrapper forms / different gh argument orderings
149
+ # still match.
150
+ MACF_ACTIONS_DISPATCH_ALLOWLIST_REGEX='npm-deprecate\.yml'
151
+
152
+ _macf_audit_classify_action() {
153
+ # Echo the action class (`dispatch`|`cancel`|`rerun`) for the given
154
+ # command, or empty if it's not an actions:write subcommand.
155
+ local cmd="$1"
156
+ # Workflow lifecycle — `gh workflow run/enable/disable` (require a word
157
+ # boundary after the verb so `gh workflow runs` etc. don't false-match).
158
+ if [[ "$cmd" =~ (^|[[:space:];|&\"\'])gh[[:space:]]+workflow[[:space:]]+(run|enable|disable)([[:space:]]|$|[\"\']) ]]; then
159
+ echo "dispatch"
160
+ return
161
+ fi
162
+ # Run lifecycle — `gh run cancel` / `gh run rerun` (rerun --failed
163
+ # collapses to action=rerun; the dimensionality is action-only, not
164
+ # rerun-variant).
165
+ if [[ "$cmd" =~ (^|[[:space:];|&\"\'])gh[[:space:]]+run[[:space:]]+cancel([[:space:]]|$|[\"\']) ]]; then
166
+ echo "cancel"
167
+ return
168
+ fi
169
+ if [[ "$cmd" =~ (^|[[:space:];|&\"\'])gh[[:space:]]+run[[:space:]]+rerun([[:space:]]|$|[\"\']) ]]; then
170
+ echo "rerun"
171
+ return
172
+ fi
173
+ # API-direct POST paths — `/dispatches` (workflow dispatch),
174
+ # `/cancel` (run cancel), `/rerun` and `/rerun-failed-jobs` (run rerun).
175
+ if [[ "$cmd" =~ gh[[:space:]]+api[[:space:]] ]]; then
176
+ if [[ "$cmd" =~ /actions/workflows/[^[:space:]\"\']+/dispatches ]]; then
177
+ echo "dispatch"
178
+ return
179
+ fi
180
+ if [[ "$cmd" =~ /actions/runs/[^[:space:]/\"\']+/cancel ]]; then
181
+ echo "cancel"
182
+ return
183
+ fi
184
+ if [[ "$cmd" =~ /actions/runs/[^[:space:]/\"\']+/rerun(-failed-jobs)? ]]; then
185
+ echo "rerun"
186
+ return
187
+ fi
188
+ fi
189
+ echo ""
190
+ }
191
+
192
+ _macf_audit_parse_repo() {
193
+ # Extract `<owner>/<repo>` from `--repo owner/repo` or `-R owner/repo`
194
+ # (gh CLI long/short forms) or from `gh api .../repos/<owner>/<repo>/...`.
195
+ # Echoes empty string if no repo can be parsed (the caller logs "unknown").
196
+ local cmd="$1"
197
+ # `--repo owner/repo` form (gh CLI)
198
+ if [[ "$cmd" =~ --repo[[:space:]=]+([A-Za-z0-9._-]+/[A-Za-z0-9._-]+) ]]; then
199
+ echo "${BASH_REMATCH[1]}"
200
+ return
201
+ fi
202
+ # `-R owner/repo` short form
203
+ if [[ "$cmd" =~ (^|[[:space:]])-R[[:space:]]+([A-Za-z0-9._-]+/[A-Za-z0-9._-]+) ]]; then
204
+ echo "${BASH_REMATCH[2]}"
205
+ return
206
+ fi
207
+ # `gh api .../repos/<owner>/<repo>/...` form
208
+ if [[ "$cmd" =~ /repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/ ]]; then
209
+ echo "${BASH_REMATCH[1]}"
210
+ return
211
+ fi
212
+ echo ""
213
+ }
214
+
215
+ _macf_audit_parse_workflow() {
216
+ # Extract the workflow filename (`<name>.yml`) for `dispatch` actions.
217
+ # For `gh workflow run <name.yml>` form, take the first .yml/.yaml token
218
+ # after the `workflow run/enable/disable` verb. For `gh api .../actions/
219
+ # workflows/<name.yml>/dispatches` form, parse from the API path. For
220
+ # `cancel`/`rerun` actions, workflow is null (operates on a run-id, not
221
+ # a workflow name).
222
+ local cmd="$1"
223
+ # gh CLI workflow form
224
+ if [[ "$cmd" =~ gh[[:space:]]+workflow[[:space:]]+(run|enable|disable)[[:space:]]+([^[:space:]\"\']+) ]]; then
225
+ local wf="${BASH_REMATCH[2]}"
226
+ # Strip surrounding quotes if any
227
+ wf="${wf#\"}"; wf="${wf%\"}"
228
+ wf="${wf#\'}"; wf="${wf%\'}"
229
+ echo "$wf"
230
+ return
231
+ fi
232
+ # API-direct workflow form
233
+ if [[ "$cmd" =~ /actions/workflows/([^/[:space:]\"\']+)/dispatches ]]; then
234
+ echo "${BASH_REMATCH[1]}"
235
+ return
236
+ fi
237
+ echo ""
238
+ }
239
+
240
+ _macf_audit_build_resource_attrs_json() {
241
+ # Build OTLP-shape resource.attributes JSON array from the canonical
242
+ # claude.sh-exported OTel env vars (`OTEL_SERVICE_NAME` +
243
+ # `OTEL_RESOURCE_ATTRIBUTES`). Per macf#388 / observability-wiring.md:
244
+ # claude.sh exports these at session bootstrap as the single source
245
+ # of truth for the agent's identity attrs. The hook silent-skips
246
+ # service.name + gen_ai.* attrs when emitting outside a claude.sh-
247
+ # wrapped session (graceful degradation; returns the empty array).
248
+ #
249
+ # OTEL_RESOURCE_ATTRIBUTES format per OTel spec: comma-separated
250
+ # `key=value` pairs, no quoting. Canonical export:
251
+ # gen_ai.agent.name=<name>,gen_ai.agent.role=<role>,service.namespace=macf
252
+ local service_name="${OTEL_SERVICE_NAME:-}"
253
+ local resource_attrs="${OTEL_RESOURCE_ATTRIBUTES:-}"
254
+ jq -n \
255
+ --arg service_name "$service_name" \
256
+ --arg resource_attrs "$resource_attrs" \
257
+ '
258
+ (
259
+ if $service_name != ""
260
+ then [{key: "service.name", value: {stringValue: $service_name}}]
261
+ else []
262
+ end
263
+ )
264
+ +
265
+ (
266
+ $resource_attrs
267
+ | split(",")
268
+ | map(select(length > 0))
269
+ | map(split("=") | select(length >= 2 and .[0] != "") | {key: .[0], value: {stringValue: (.[1] // "")}})
270
+ )
271
+ ' 2>/dev/null || echo "[]"
272
+ }
273
+
274
+ _macf_audit_emit() {
275
+ # Emit span + counter for an actions:write-scoped invocation.
276
+ # Observational only — every emission path is best-effort. Failures
277
+ # are swallowed (|| true) so audit-log infrastructure issues never
278
+ # propagate to the actual gh call.
279
+ local action="$1"
280
+ local repo="${2:-unknown}"
281
+ local workflow="${3:-}"
282
+
283
+ # Skip silently if observability is opt-out (per CLAUDE.md — OTEL
284
+ # endpoint unset = no observability stack to report to).
285
+ if [[ -z "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ]]; then
286
+ return 0
287
+ fi
288
+
289
+ local actor="${OTEL_RESOURCE_ATTRIBUTES:-}"
290
+ # gen_ai.agent.name lives inside the resource attrs CSV; parse for it.
291
+ if [[ "$actor" =~ gen_ai\.agent\.name=([^,]+) ]]; then
292
+ actor="app/macf-${BASH_REMATCH[1]}"
293
+ else
294
+ actor="app/unknown"
295
+ fi
296
+
297
+ # Build url.path / url.full for the audit signal. The HTTP method is
298
+ # POST for every action class in scope (workflow_dispatch POST,
299
+ # run cancel POST, run rerun POST). HTTP semconv canonical attrs.
300
+ local url_path=""
301
+ local url_full=""
302
+ case "$action" in
303
+ dispatch)
304
+ if [[ -n "$workflow" && -n "$repo" && "$repo" != "unknown" ]]; then
305
+ url_path="/repos/${repo}/actions/workflows/${workflow}/dispatches"
306
+ fi
307
+ ;;
308
+ cancel)
309
+ # Run-id is hard to parse generically (positional arg in `gh run cancel`);
310
+ # leave path with placeholder. The action+repo dimensions are the
311
+ # primary alert surface; per-run-id forensics goes via the gh-CLI logs.
312
+ url_path="/repos/${repo}/actions/runs/{id}/cancel"
313
+ ;;
314
+ rerun)
315
+ url_path="/repos/${repo}/actions/runs/{id}/rerun"
316
+ ;;
317
+ esac
318
+ if [[ -n "$url_path" ]]; then
319
+ url_full="https://api.github.com${url_path}"
320
+ fi
321
+
322
+ # Lean emission order (DR-019 Amendment A "OTel emission from bash"):
323
+ # 1. otel-cli if installed — preferred, lightweight, well-documented
324
+ # 2. curl OTLP HTTP JSON fallback — works in any env with curl
325
+ # Both paths are observational; failures swallowed.
326
+ if command -v otel-cli >/dev/null 2>&1; then
327
+ _macf_audit_emit_otel_cli "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" 2>/dev/null || true
328
+ else
329
+ _macf_audit_emit_curl "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" 2>/dev/null || true
330
+ fi
331
+ }
332
+
333
+ _macf_audit_emit_otel_cli() {
334
+ # Preferred path: otel-cli for span emission. Note: otel-cli emits
335
+ # spans only (no metrics support as of last check); the counter side
336
+ # falls through to the curl path even when otel-cli is installed.
337
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
338
+ local attrs="gh.api.scope=actions:write,gh.repo=${repo},gh.action=${action},gh.actor=${actor},http.request.method=POST"
339
+ if [[ -n "$workflow" ]]; then
340
+ attrs="${attrs},gh.workflow=${workflow}"
341
+ fi
342
+ if [[ -n "$url_path" ]]; then
343
+ attrs="${attrs},url.path=${url_path},url.full=${url_full}"
344
+ fi
345
+ otel-cli span \
346
+ --name "macf.app.gh_api_call" \
347
+ --kind client \
348
+ --attrs "$attrs" \
349
+ --service "${OTEL_SERVICE_NAME:-macf-agent}" \
350
+ >/dev/null 2>&1 || true
351
+ # Counter still goes via curl (otel-cli has no metrics emit subcommand).
352
+ _macf_audit_emit_curl_metric "$action" "$repo" "$workflow" 2>/dev/null || true
353
+ }
354
+
355
+ _macf_audit_emit_curl() {
356
+ # Fallback path: curl POST to OTLP HTTP JSON endpoint. Emits both span
357
+ # (/v1/traces) and counter (/v1/metrics).
358
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
359
+ _macf_audit_emit_curl_span "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" || true
360
+ _macf_audit_emit_curl_metric "$action" "$repo" "$workflow" || true
361
+ }
362
+
363
+ _macf_audit_emit_curl_span() {
364
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
365
+ local endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT%/}"
366
+ # OTLP HTTP JSON: ns since epoch. `date +%s%N` on Linux gives nanos
367
+ # directly. Macs need a fallback but the agent runtime is Linux-only
368
+ # (devbox/devcontainer) per CLAUDE.md.
369
+ local ts_ns
370
+ ts_ns="$(date +%s%N)"
371
+ # Random hex IDs — span IDs are 16 hex chars, trace IDs 32. /dev/urandom
372
+ # is portable; head + xxd would also work but stays jq-clean.
373
+ local trace_id span_id
374
+ trace_id="$(LC_ALL=C tr -dc 'a-f0-9' </dev/urandom | head -c 32)"
375
+ span_id="$(LC_ALL=C tr -dc 'a-f0-9' </dev/urandom | head -c 16)"
376
+
377
+ # Build span attrs JSON via jq for safe string escaping (workflow / repo
378
+ # could contain regex-friendly chars; jq handles JSON-encoding correctly).
379
+ # OTel HTTP semconv canonical attrs + MACF governance attrs per DR-019
380
+ # Amendment A "Signal 1 — OTel span" table. Note: http.response.status_code
381
+ # is OMITTED — this is a PRE-tool-use hook; the call hasn't fired yet,
382
+ # so there is no response status to report. Documented as a known gap
383
+ # for the span shape.
384
+ local attrs_json
385
+ attrs_json="$(
386
+ jq -n \
387
+ --arg scope "actions:write" \
388
+ --arg repo "$repo" \
389
+ --arg workflow "$workflow" \
390
+ --arg action "$action" \
391
+ --arg actor "$actor" \
392
+ --arg method "POST" \
393
+ --arg url_path "$url_path" \
394
+ --arg url_full "$url_full" \
395
+ '[
396
+ {key: "gh.api.scope", value: {stringValue: $scope}},
397
+ {key: "gh.repo", value: {stringValue: $repo}},
398
+ {key: "gh.action", value: {stringValue: $action}},
399
+ {key: "gh.actor", value: {stringValue: $actor}},
400
+ {key: "http.request.method", value: {stringValue: $method}}
401
+ ]
402
+ + ( if $workflow != "" then [{key: "gh.workflow", value: {stringValue: $workflow}}] else [] end )
403
+ + ( if $url_path != "" then [{key: "url.path", value: {stringValue: $url_path}}] else [] end )
404
+ + ( if $url_full != "" then [{key: "url.full", value: {stringValue: $url_full}}] else [] end )' 2>/dev/null
405
+ )" || return 1
406
+
407
+ # macf#388: populate resource.attributes from claude.sh's exported
408
+ # OTel env vars so hook-emitted spans match the rest of the MACF
409
+ # stack's service.name / gen_ai.* attrs (instead of falling under
410
+ # `rootServiceName=<root span not yet received>` in Tempo).
411
+ # Graceful degradation: empty array when env unset.
412
+ local resource_attrs_json
413
+ resource_attrs_json="$(_macf_audit_build_resource_attrs_json)"
414
+
415
+ local body
416
+ body="$(
417
+ jq -n \
418
+ --arg trace_id "$trace_id" \
419
+ --arg span_id "$span_id" \
420
+ --arg name "macf.app.gh_api_call" \
421
+ --arg ts_ns "$ts_ns" \
422
+ --argjson attrs "$attrs_json" \
423
+ --argjson resource_attrs "$resource_attrs_json" \
424
+ '{
425
+ resourceSpans: [{
426
+ resource: { attributes: $resource_attrs },
427
+ scopeSpans: [{
428
+ scope: { name: "macf" },
429
+ spans: [{
430
+ traceId: $trace_id,
431
+ spanId: $span_id,
432
+ name: $name,
433
+ startTimeUnixNano: $ts_ns,
434
+ endTimeUnixNano: $ts_ns,
435
+ kind: 3,
436
+ attributes: $attrs
437
+ }]
438
+ }]
439
+ }]
440
+ }' 2>/dev/null
441
+ )" || return 1
442
+
443
+ # SPAN_KIND_CLIENT = 3 in OTLP proto3 enum encoding.
444
+ # Short timeout so a slow / down collector doesn't delay the gh call.
445
+ curl -sS -m 2 \
446
+ -X POST \
447
+ -H "Content-Type: application/json" \
448
+ --data "$body" \
449
+ "${endpoint}/v1/traces" >/dev/null 2>&1 || return 1
450
+ }
451
+
452
+ _macf_audit_emit_curl_metric() {
453
+ local action="$1" repo="$2" workflow="$3"
454
+ local endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT%/}"
455
+ local ts_ns
456
+ ts_ns="$(date +%s%N)"
457
+
458
+ local attrs_json
459
+ attrs_json="$(
460
+ jq -n \
461
+ --arg repo "$repo" \
462
+ --arg action "$action" \
463
+ --arg workflow "$workflow" \
464
+ '[
465
+ {key: "repo", value: {stringValue: $repo}},
466
+ {key: "action", value: {stringValue: $action}}
467
+ ]
468
+ + ( if $workflow != "" then [{key: "workflow", value: {stringValue: $workflow}}] else [] end )' 2>/dev/null
469
+ )" || return 1
470
+
471
+ # macf#388: same resource-attrs population as the span side, for
472
+ # cross-signal consistency on Tempo / Prometheus aggregation by
473
+ # service.name / gen_ai.* dimensions.
474
+ local resource_attrs_json
475
+ resource_attrs_json="$(_macf_audit_build_resource_attrs_json)"
476
+
477
+ local body
478
+ body="$(
479
+ jq -n \
480
+ --arg name "macf.app.gh_actions_write_total" \
481
+ --arg ts_ns "$ts_ns" \
482
+ --argjson attrs "$attrs_json" \
483
+ --argjson resource_attrs "$resource_attrs_json" \
484
+ '{
485
+ resourceMetrics: [{
486
+ resource: { attributes: $resource_attrs },
487
+ scopeMetrics: [{
488
+ scope: { name: "macf" },
489
+ metrics: [{
490
+ name: $name,
491
+ description: "GitHub API actions:write invocations by MACF agent App",
492
+ unit: "1",
493
+ sum: {
494
+ aggregationTemporality: 1,
495
+ isMonotonic: true,
496
+ dataPoints: [{
497
+ asInt: "1",
498
+ startTimeUnixNano: $ts_ns,
499
+ timeUnixNano: $ts_ns,
500
+ attributes: $attrs
501
+ }]
502
+ }
503
+ }]
504
+ }]
505
+ }]
506
+ }' 2>/dev/null
507
+ )" || return 1
508
+
509
+ # aggregationTemporality = 1 = DELTA (per macf#281 Phase 2 convention).
510
+ # DELTA is robust to N-process / restart topologies — every emission
511
+ # is an independent delta point; collector aggregates by series identity.
512
+ curl -sS -m 2 \
513
+ -X POST \
514
+ -H "Content-Type: application/json" \
515
+ --data "$body" \
516
+ "${endpoint}/v1/metrics" >/dev/null 2>&1 || return 1
517
+ }
518
+
519
+ # Classify and emit. Note: classification is intentionally permissive —
520
+ # wrapper forms (`sudo`, `bash -c "..."`, `GH_TOKEN=x ...`) all flow through
521
+ # the same regex because the wrapper-aware GH_PATTERN above already
522
+ # tolerates them; classify_action just looks for the `gh <verb>` substring.
523
+ _MACF_AUDIT_ACTION="$(_macf_audit_classify_action "$COMMAND")"
524
+ if [[ -n "$_MACF_AUDIT_ACTION" ]]; then
525
+ _MACF_AUDIT_REPO="$(_macf_audit_parse_repo "$COMMAND")"
526
+ _MACF_AUDIT_WORKFLOW="$(_macf_audit_parse_workflow "$COMMAND")"
527
+ # Dispatch-allowlist enforcement: emit unconditionally (the audit-log
528
+ # spec wants visibility into ALL dispatches; the allowlist drives the
529
+ # dashboard's "unexpected workflow" alert, not script-side blocking).
530
+ # `cancel` / `rerun` operate on runs not workflows — no allowlist
531
+ # check applies. Future Path-2 promotion may convert this from
532
+ # "always emit, let collector alert" to "warn-but-allow on non-allowlist
533
+ # dispatches"; out of scope for #381 per DR-019 Amendment A § "Dispatch
534
+ # allowlist + addition criteria" (operational alerting lives in
535
+ # macf-devops-toolkit dashboard).
536
+ _macf_audit_emit "$_MACF_AUDIT_ACTION" "$_MACF_AUDIT_REPO" "$_MACF_AUDIT_WORKFLOW" || true
537
+ fi
538
+
102
539
  exit 0