Files
boshu2__agentops/scripts/check-mutation-route-coverage.sh
T
Boden Fuller 81f864da52 feat(daemon): add /v1/schedules routes + registerMutationRoute helper + bypass guard (soc-8inr.5)
PER PRE-MORTEM AMENDMENT A2 (soc-8inr.5): introduce a single registration
choke-point for mutation HTTP routes plus a build-time guard that asserts
no other code path bypasses it.

- registerMutationRoute(mux, path, policyFn, handler) wraps handler in
  AuthorizeMutationDecision and atomically appends path to the tracked
  mutationRoutes slice. Decision is stashed in request context so handlers
  can read TokenName for ledger actor attribution without re-running auth.
- registerReadOnlyRoute keeps read-only registrations in auth.go too, so
  ALL mux.HandleFunc callsites in cli/internal/daemon/ live in one file
  (the single audit surface).
- Existing /jobs, /v1/jobs, /jobs/cancel, /v1/jobs/cancel, and
  /openclaw/v1/triggers/jobs migrate to registerMutationRoute. Read-only
  /health, /ready, /status, /events, /openclaw/v1/* migrate to
  registerReadOnlyRoute. Behavior is preserved including 405 Method Not
  Allowed semantics (auth wrapper passes non-mutation methods through to
  the handler so requireMethod can still return 405).

- New /v1/schedules routes:
  - POST /v1/schedules creates a RecurringJobTemplate (admin capability).
  - GET /v1/schedules lists schedules — read-only, no auth.
  - DELETE /v1/schedules/{name} removes a schedule (admin capability).
  Validation: malformed JSON / missing fields / invalid cron return 400.
  Duplicate names return 409.

- mutationPathInSet + mutationCapabilityForPath add "/foo/*" wildcard
  support so DELETE /v1/schedules/{name} can be authorized without
  enumerating every name in AllowedPaths.

- Default legacy mutation tokens now carry MutationCapabilityAdmin (in
  addition to submit/cancel/openclaw_trigger). Single-token mode is
  local-only by construction; scoped tokens that need a narrower scope
  must use the JSON token-file format.

- scripts/check-mutation-route-coverage.sh greps cli/internal/daemon/
  for raw mux.HandleFunc callsites outside auth.go and fails the gate
  if any are found. Wired into scripts/pre-push-gate.sh as an always-on
  check (security gate; not skipped on local fast mode).

Tests:
- TestRegisterMutationRoute_AppendsAndWraps: helper appends to tracked
  list, rejects unauthenticated POST, runs handler with decision in
  context on valid token.
- TestMutation_AllRegisteredRoutesEnforcePolicy: parity test — every path
  in MutationRoutesForTest() returns 403 without a token.
- TestMutation_GetSchedulesIsReadOnly: GET /v1/schedules is 200 without
  auth and is NOT in the tracked mutation slice.
- TestRoute_PostSchedules_Creates / DuplicateNameReturns409 /
  MalformedJSONReturns400 / MissingFieldsReturns400.
- TestRoute_GetSchedules_ReturnsList.
- TestRoute_DeleteSchedule_Removes / AuthRequired.

Closes soc-8inr.5.
2026-05-01 17:35:13 -04:00

70 lines
2.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# scripts/check-mutation-route-coverage.sh
#
# PER PRE-MORTEM AMENDMENT A2 (security, soc-8inr.5): proves that
# registerMutationRoute is the ONLY path through which mutation HTTP routes
# are registered in cli/internal/daemon/. A developer could otherwise call
# mux.HandleFunc("/v1/foo", handler) directly and bypass the auth wrapper
# entirely.
#
# CI gate: this script must exit 0. Wired into scripts/pre-push-gate.sh.
#
# Heuristic: every mux.HandleFunc callsite in cli/internal/daemon/ that lives
# outside auth.go fails the gate. auth.go is the single allowed home for
# raw mux registration via registerMutationRoute / registerReadOnlyRoute
# helpers; all other files MUST go through those helpers.
#
# Read-only routes registered via registerReadOnlyRoute are intentionally
# exempt from the auth wrapper but still go through auth.go (single
# registration choke-point), so this gate covers both.
set -euo pipefail
if REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$REPO_ROOT" ]]; then
:
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
fi
SERVER_DIR="$REPO_ROOT/cli/internal/daemon"
if [[ ! -d "$SERVER_DIR" ]]; then
echo "ERROR: daemon source dir not found: $SERVER_DIR" >&2
exit 2
fi
# Find all mux.HandleFunc callsites in non-test daemon files. Exclude auth.go
# (the registration choke-point) and *_test.go (tests may register fixtures).
violations=$(grep -rEn 'mux\.HandleFunc\b' "$SERVER_DIR" \
--include='*.go' \
--exclude='*_test.go' \
| grep -v '/auth\.go:' \
| grep -v '^[^:]*:[0-9]\+://' \
|| true)
# Strip pure comment lines (// at start, after optional whitespace).
violations=$(echo "$violations" | awk -F: '{
line=""
for (i=3; i<=NF; i++) line = line (i==3?"":":") $i
gsub(/^[ \t]+/, "", line)
if (line ~ /^\/\//) next
print
}')
if [[ -n "$violations" ]]; then
echo "ERROR: mux.HandleFunc called outside auth.go in cli/internal/daemon/" >&2
echo " (registerMutationRoute / registerReadOnlyRoute is the ONLY allowed path)" >&2
echo "" >&2
echo "$violations" >&2
echo "" >&2
echo "If a route is read-only, register via registerReadOnlyRoute(mux, path, handler)." >&2
echo "If a route is a mutation, register via registerMutationRoute(mux, path, policy, handler)." >&2
echo "" >&2
echo "PER PRE-MORTEM AMENDMENT A2 (soc-8inr.5): direct mux.HandleFunc bypasses" >&2
echo "the auth wrapper. The gate refuses to merge any route that escapes the helpers." >&2
exit 1
fi
echo "OK: all mux.HandleFunc callsites in cli/internal/daemon/ live in auth.go (registerMutationRoute / registerReadOnlyRoute scope)"
exit 0