@groundnuty/macf 0.2.26 → 0.2.27

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": "64a19a66cf1c80b5ecd8dcd0d1b3249d0676a220",
3
+ "built_at": "2026-05-19T08:02:20.141Z"
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.27",
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.27",
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,386 @@ 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_emit() {
241
+ # Emit span + counter for an actions:write-scoped invocation.
242
+ # Observational only — every emission path is best-effort. Failures
243
+ # are swallowed (|| true) so audit-log infrastructure issues never
244
+ # propagate to the actual gh call.
245
+ local action="$1"
246
+ local repo="${2:-unknown}"
247
+ local workflow="${3:-}"
248
+
249
+ # Skip silently if observability is opt-out (per CLAUDE.md — OTEL
250
+ # endpoint unset = no observability stack to report to).
251
+ if [[ -z "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ]]; then
252
+ return 0
253
+ fi
254
+
255
+ local actor="${OTEL_RESOURCE_ATTRIBUTES:-}"
256
+ # gen_ai.agent.name lives inside the resource attrs CSV; parse for it.
257
+ if [[ "$actor" =~ gen_ai\.agent\.name=([^,]+) ]]; then
258
+ actor="app/macf-${BASH_REMATCH[1]}"
259
+ else
260
+ actor="app/unknown"
261
+ fi
262
+
263
+ # Build url.path / url.full for the audit signal. The HTTP method is
264
+ # POST for every action class in scope (workflow_dispatch POST,
265
+ # run cancel POST, run rerun POST). HTTP semconv canonical attrs.
266
+ local url_path=""
267
+ local url_full=""
268
+ case "$action" in
269
+ dispatch)
270
+ if [[ -n "$workflow" && -n "$repo" && "$repo" != "unknown" ]]; then
271
+ url_path="/repos/${repo}/actions/workflows/${workflow}/dispatches"
272
+ fi
273
+ ;;
274
+ cancel)
275
+ # Run-id is hard to parse generically (positional arg in `gh run cancel`);
276
+ # leave path with placeholder. The action+repo dimensions are the
277
+ # primary alert surface; per-run-id forensics goes via the gh-CLI logs.
278
+ url_path="/repos/${repo}/actions/runs/{id}/cancel"
279
+ ;;
280
+ rerun)
281
+ url_path="/repos/${repo}/actions/runs/{id}/rerun"
282
+ ;;
283
+ esac
284
+ if [[ -n "$url_path" ]]; then
285
+ url_full="https://api.github.com${url_path}"
286
+ fi
287
+
288
+ # Lean emission order (DR-019 Amendment A "OTel emission from bash"):
289
+ # 1. otel-cli if installed — preferred, lightweight, well-documented
290
+ # 2. curl OTLP HTTP JSON fallback — works in any env with curl
291
+ # Both paths are observational; failures swallowed.
292
+ if command -v otel-cli >/dev/null 2>&1; then
293
+ _macf_audit_emit_otel_cli "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" 2>/dev/null || true
294
+ else
295
+ _macf_audit_emit_curl "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" 2>/dev/null || true
296
+ fi
297
+ }
298
+
299
+ _macf_audit_emit_otel_cli() {
300
+ # Preferred path: otel-cli for span emission. Note: otel-cli emits
301
+ # spans only (no metrics support as of last check); the counter side
302
+ # falls through to the curl path even when otel-cli is installed.
303
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
304
+ local attrs="gh.api.scope=actions:write,gh.repo=${repo},gh.action=${action},gh.actor=${actor},http.request.method=POST"
305
+ if [[ -n "$workflow" ]]; then
306
+ attrs="${attrs},gh.workflow=${workflow}"
307
+ fi
308
+ if [[ -n "$url_path" ]]; then
309
+ attrs="${attrs},url.path=${url_path},url.full=${url_full}"
310
+ fi
311
+ otel-cli span \
312
+ --name "macf.app.gh_api_call" \
313
+ --kind client \
314
+ --attrs "$attrs" \
315
+ --service "${OTEL_SERVICE_NAME:-macf-agent}" \
316
+ >/dev/null 2>&1 || true
317
+ # Counter still goes via curl (otel-cli has no metrics emit subcommand).
318
+ _macf_audit_emit_curl_metric "$action" "$repo" "$workflow" 2>/dev/null || true
319
+ }
320
+
321
+ _macf_audit_emit_curl() {
322
+ # Fallback path: curl POST to OTLP HTTP JSON endpoint. Emits both span
323
+ # (/v1/traces) and counter (/v1/metrics).
324
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
325
+ _macf_audit_emit_curl_span "$action" "$repo" "$workflow" "$actor" "$url_path" "$url_full" || true
326
+ _macf_audit_emit_curl_metric "$action" "$repo" "$workflow" || true
327
+ }
328
+
329
+ _macf_audit_emit_curl_span() {
330
+ local action="$1" repo="$2" workflow="$3" actor="$4" url_path="$5" url_full="$6"
331
+ local endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT%/}"
332
+ # OTLP HTTP JSON: ns since epoch. `date +%s%N` on Linux gives nanos
333
+ # directly. Macs need a fallback but the agent runtime is Linux-only
334
+ # (devbox/devcontainer) per CLAUDE.md.
335
+ local ts_ns
336
+ ts_ns="$(date +%s%N)"
337
+ # Random hex IDs — span IDs are 16 hex chars, trace IDs 32. /dev/urandom
338
+ # is portable; head + xxd would also work but stays jq-clean.
339
+ local trace_id span_id
340
+ trace_id="$(LC_ALL=C tr -dc 'a-f0-9' </dev/urandom | head -c 32)"
341
+ span_id="$(LC_ALL=C tr -dc 'a-f0-9' </dev/urandom | head -c 16)"
342
+
343
+ # Build span attrs JSON via jq for safe string escaping (workflow / repo
344
+ # could contain regex-friendly chars; jq handles JSON-encoding correctly).
345
+ # OTel HTTP semconv canonical attrs + MACF governance attrs per DR-019
346
+ # Amendment A "Signal 1 — OTel span" table. Note: http.response.status_code
347
+ # is OMITTED — this is a PRE-tool-use hook; the call hasn't fired yet,
348
+ # so there is no response status to report. Documented as a known gap
349
+ # for the span shape.
350
+ local attrs_json
351
+ attrs_json="$(
352
+ jq -n \
353
+ --arg scope "actions:write" \
354
+ --arg repo "$repo" \
355
+ --arg workflow "$workflow" \
356
+ --arg action "$action" \
357
+ --arg actor "$actor" \
358
+ --arg method "POST" \
359
+ --arg url_path "$url_path" \
360
+ --arg url_full "$url_full" \
361
+ '[
362
+ {key: "gh.api.scope", value: {stringValue: $scope}},
363
+ {key: "gh.repo", value: {stringValue: $repo}},
364
+ {key: "gh.action", value: {stringValue: $action}},
365
+ {key: "gh.actor", value: {stringValue: $actor}},
366
+ {key: "http.request.method", value: {stringValue: $method}}
367
+ ]
368
+ + ( if $workflow != "" then [{key: "gh.workflow", value: {stringValue: $workflow}}] else [] end )
369
+ + ( if $url_path != "" then [{key: "url.path", value: {stringValue: $url_path}}] else [] end )
370
+ + ( if $url_full != "" then [{key: "url.full", value: {stringValue: $url_full}}] else [] end )' 2>/dev/null
371
+ )" || return 1
372
+
373
+ local body
374
+ body="$(
375
+ jq -n \
376
+ --arg trace_id "$trace_id" \
377
+ --arg span_id "$span_id" \
378
+ --arg name "macf.app.gh_api_call" \
379
+ --arg ts_ns "$ts_ns" \
380
+ --argjson attrs "$attrs_json" \
381
+ '{
382
+ resourceSpans: [{
383
+ resource: { attributes: [] },
384
+ scopeSpans: [{
385
+ scope: { name: "macf" },
386
+ spans: [{
387
+ traceId: $trace_id,
388
+ spanId: $span_id,
389
+ name: $name,
390
+ startTimeUnixNano: $ts_ns,
391
+ endTimeUnixNano: $ts_ns,
392
+ kind: 3,
393
+ attributes: $attrs
394
+ }]
395
+ }]
396
+ }]
397
+ }' 2>/dev/null
398
+ )" || return 1
399
+
400
+ # SPAN_KIND_CLIENT = 3 in OTLP proto3 enum encoding.
401
+ # Short timeout so a slow / down collector doesn't delay the gh call.
402
+ curl -sS -m 2 \
403
+ -X POST \
404
+ -H "Content-Type: application/json" \
405
+ --data "$body" \
406
+ "${endpoint}/v1/traces" >/dev/null 2>&1 || return 1
407
+ }
408
+
409
+ _macf_audit_emit_curl_metric() {
410
+ local action="$1" repo="$2" workflow="$3"
411
+ local endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT%/}"
412
+ local ts_ns
413
+ ts_ns="$(date +%s%N)"
414
+
415
+ local attrs_json
416
+ attrs_json="$(
417
+ jq -n \
418
+ --arg repo "$repo" \
419
+ --arg action "$action" \
420
+ --arg workflow "$workflow" \
421
+ '[
422
+ {key: "repo", value: {stringValue: $repo}},
423
+ {key: "action", value: {stringValue: $action}}
424
+ ]
425
+ + ( if $workflow != "" then [{key: "workflow", value: {stringValue: $workflow}}] else [] end )' 2>/dev/null
426
+ )" || return 1
427
+
428
+ local body
429
+ body="$(
430
+ jq -n \
431
+ --arg name "macf.app.gh_actions_write_total" \
432
+ --arg ts_ns "$ts_ns" \
433
+ --argjson attrs "$attrs_json" \
434
+ '{
435
+ resourceMetrics: [{
436
+ resource: { attributes: [] },
437
+ scopeMetrics: [{
438
+ scope: { name: "macf" },
439
+ metrics: [{
440
+ name: $name,
441
+ description: "GitHub API actions:write invocations by MACF agent App",
442
+ unit: "1",
443
+ sum: {
444
+ aggregationTemporality: 1,
445
+ isMonotonic: true,
446
+ dataPoints: [{
447
+ asInt: "1",
448
+ startTimeUnixNano: $ts_ns,
449
+ timeUnixNano: $ts_ns,
450
+ attributes: $attrs
451
+ }]
452
+ }
453
+ }]
454
+ }]
455
+ }]
456
+ }' 2>/dev/null
457
+ )" || return 1
458
+
459
+ # aggregationTemporality = 1 = DELTA (per macf#281 Phase 2 convention).
460
+ # DELTA is robust to N-process / restart topologies — every emission
461
+ # is an independent delta point; collector aggregates by series identity.
462
+ curl -sS -m 2 \
463
+ -X POST \
464
+ -H "Content-Type: application/json" \
465
+ --data "$body" \
466
+ "${endpoint}/v1/metrics" >/dev/null 2>&1 || return 1
467
+ }
468
+
469
+ # Classify and emit. Note: classification is intentionally permissive —
470
+ # wrapper forms (`sudo`, `bash -c "..."`, `GH_TOKEN=x ...`) all flow through
471
+ # the same regex because the wrapper-aware GH_PATTERN above already
472
+ # tolerates them; classify_action just looks for the `gh <verb>` substring.
473
+ _MACF_AUDIT_ACTION="$(_macf_audit_classify_action "$COMMAND")"
474
+ if [[ -n "$_MACF_AUDIT_ACTION" ]]; then
475
+ _MACF_AUDIT_REPO="$(_macf_audit_parse_repo "$COMMAND")"
476
+ _MACF_AUDIT_WORKFLOW="$(_macf_audit_parse_workflow "$COMMAND")"
477
+ # Dispatch-allowlist enforcement: emit unconditionally (the audit-log
478
+ # spec wants visibility into ALL dispatches; the allowlist drives the
479
+ # dashboard's "unexpected workflow" alert, not script-side blocking).
480
+ # `cancel` / `rerun` operate on runs not workflows — no allowlist
481
+ # check applies. Future Path-2 promotion may convert this from
482
+ # "always emit, let collector alert" to "warn-but-allow on non-allowlist
483
+ # dispatches"; out of scope for #381 per DR-019 Amendment A § "Dispatch
484
+ # allowlist + addition criteria" (operational alerting lives in
485
+ # macf-devops-toolkit dashboard).
486
+ _macf_audit_emit "$_MACF_AUDIT_ACTION" "$_MACF_AUDIT_REPO" "$_MACF_AUDIT_WORKFLOW" || true
487
+ fi
488
+
102
489
  exit 0