Skip to content

feat(audit): attributable JSONL audit line at every authorization and tool-call funnel (Spec 107 PR-D) - #1296

Open
Dumbris wants to merge 14 commits into
107-c-group-allowlistfrom
107-d-audit-line
Open

Dumbris wants to merge 14 commits into
107-c-group-allowlistfrom
107-d-audit-line

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds an attributable, edition-neutral JSONL audit line at every authorization
decision and tool-call funnel, plus a login/logout auth_event line for the
server edition's OAuth front door (Spec 107 PR-D, US3, FR-012–FR-019,
FR-039 part 4, FR-043(b,j), SC-003, SC-009).

  • internal/audit: JCS (RFC 8785) canonicalisation over sanitised args →
    args_sha256; three typed constructors (NewAuthz, NewToolCall,
    NewAuthEvent) so a forbidden field per event type doesn't compile; a
    synchronous, mutex-guarded sink (file via lumberjack, or raw stdout)
    with an always-on write-failure counter; per-field sanitisation of every
    caller/operator-controlled string (fixed-prefix credential patterns only —
    never the generic high-entropy rule, which would mask args_sha256/
    email_hash themselves) plus a whole-line defence-in-depth pass that is
    asserted to be the identity on every constructor's output.
  • internal/server: authz + tool_call lines at the call_tool_*
    variants, direct dispatch, the REST direct path, and the nested
    code_execution wrapper (parent_id + surface:code_execution); caller
    identity derived for every kind (API key, socket, anonymous, agent token
    owned/ownerless, session_admin, internal, and native stdio, which
    needed a new transport.ConnectionSourceStdio tag since stdio installs an
    admin context with no connection source today).
  • internal/serveredition/auth: one auth_event line per terminal login
    attempt (every FR-013 reason) and one per logout, with stage-dependent
    identity (user_id once the store is reached, email_hash for a
    verified-but-unresolved email, neither pre-identity).
  • internal/config: AuditLogConfig + EffectiveAuditLog(cfg, transport)
    encoding the stdio rule (FR-014: stdout can never double as the sink when
    stdout carries JSON-RPC — absent block under stdio → disabled + WARN,
    explicit stdout-only under stdio → StartupError exit 4), validation
    reachable from boot/PATCH/apply alike, hot-reload restart-pinning, a
    mcpproxy_audit_write_failures_total counter and a doctor finding.
  • Docs: docs/features/audit-log.md, docs/schemas/audit-line-v1.schema.json,
    docs/operations/deploying-for-a-team.md, release notice bullets.

Stacked on #1293#1292#1287 — merge in order.

Example lines

{"event":"authz","decision":"allow","server":"a","tool":"echo","reason":"none","operation":"read","caller":{"kind":"agent_token","user_id":"01M...","user_email":"alice@example.com","token_name":"t1","token_prefix":"mcp_agt_9f6b"},"request_id":"...-a-echo-1","schema_version":1}
{"event":"tool_call","outcome":"success","server":"a","tool":"echo","args_sha256":"7804fe...","args_bytes":52,"caller":{"kind":"agent_token","user_id":"01M...","user_email":"alice@example.com"},"request_id":"...-a-echo-1","schema_version":1}
{"event":"authz","decision":"deny","server":"b","tool":"echo","reason":"token_scope","disclosed":false,"caller":{"kind":"agent_token","...":"..."},"request_id":"...-b-echo-3","schema_version":1}
{"event":"auth_event","surface":"login","reason":"ok","caller":{"kind":"session_user","user_id":"01M...","role":"user","provider":"oidc"},"schema_version":1}

Config keys

{
  "audit_log": {
    "enabled": true,
    "stdout": false,
    "path": "/var/log/mcpproxy/audit.jsonl",
    "max_size_mb": 50,
    "max_backups": 10,
    "max_age_days": 90,
    "compress": true
  }
}

Defaults: personal edition {enabled:false}; server edition with the block
absent {enabled:true, stdout:true} — except under the native stdio
transport, where stdout carries JSON-RPC and the default resolves to
{enabled:false} with one startup WARN naming audit_log.path instead. An
explicit stdout:true under stdio is refused at boot (StartupError,
exit code 4), never silently disabled. An unwritable path is also exit
code 4, caught by a pre-flight open/close probe before the rotating writer
installs (lumberjack opens lazily and would otherwise swallow it).

Verification gate table (full detail in verification.md)

Gate Result
Both builds (personal / -tags server) PASS
go vet ×2 PASS
golangci-lint v2, both tag sets PASS — only pre-existing, out-of-scope findings
go test -race non-server (excl. internal/server) PASS
internal/server, personal tags, CI-skip regex PASS
Server-edition package list, -tags server -race PASS
go test ./cmd/..., ./tests/oauthserver/... PASS
make swagger-verify + TestContractsInSync PASS
Frontend unit (vitest) + build PASS — 130 files / 1312 tests
Frozen tool-surface goldens PASS — unregenerated
python3 scripts/gen-roadmap.py --check PASS
Isolated ./scripts/test-api-e2e.sh + new audit assertions (T113) PASS — 5/5 audit assertions green
Real-instance verification (T116, dev-server-edition.sh --phase d + manual extensions) PASS — allow/deny/tool_call lines, sentinel-absence (incl. a caller-supplied tool name), auth_event, schema validation via TestExternalJSONLValidates, stdio WARN/exit-4 rules, unwritable-path exit 4
SC-009 benchmark (audit-on vs audit-off, same-tree A/B) PASS — all deltas well within the 10%/5ms bound

Two rig bugs found and fixed while running T116 (in scripts/dev-server-edition.sh,
not in the audited feature code): the scratch config left fixture servers
quarantined, and an isError assertion didn't accept the omitempty case.
One pre-existing, out-of-scope discrepancy documented but not fixed: --listen ""
does not actually reach native stdio through mcpproxy serve today
(Config.Validate() resets an empty Listen back to the HTTP default) —
--listen ":0" is the only path that works; flagged for separate follow-up.

Benchmark deltas (SC-009, same-tree audit-on vs audit-off, bound = max(10%, 5ms))

operation delta verdict
call_tool_read (admin) +0.14ms PASS
retrieve_tools (admin) -0.05 to -0.10ms (audit ON measured faster) PASS
tools/list (admin) ~0ms (no audit line fires on this path) PASS
retrieve_tools scoped vs admin scoped faster than admin in every arm PASS (bound 20ms)

Dumbris and others added 10 commits September 17, 2026 12:29
…-validated builder, synchronous sink (Spec 107 PR-D)
…ation, nested observer and error classes (Spec 107 PR-D)
… nested code_execution observer, error classes (Spec 107 PR-D)

T096-T119: Edition-neutral audit line emission at authorization and tool-call decision points.

- internal/audit/error_class.go: ErrorClass enum (upstream_error, upstream_unavailable, validation, sanitisation, cancelled, internal) with errors.Is/As routing and ErrorClassOf() type switch
- internal/jsruntime/runtime.go: AuthzObserver interface (report cached decision), AuthzGateReport with ParentID, ExecutionOptions.{AuthzObserver, ParentID} for nested tracking, nestedAuthzObserver implementation
- internal/server/audit_funnel.go: auditDispatch (attempt wrapper), installAuditAttempt (RFC 8785 JCS arg hash, mount/source/origin/profile derivation), auditCallerFromContext (full table: stdio, socket, agent_token, session_admin, anonymous), auditAuthz (deny reason from telemetry.BlockReason, disclosed:false for scopes), auditToolCall (pairs authz, error_class routing), auditToolCallShed (rejected outcome)
- internal/server/mcp.go, mcp_routing.go, mcp_code_execution.go: ctx as first param to emit* activity functions; handleCallToolVariant surface/intent gates; code_execution bridge with parent_id + nested observer
- internal/server/server.go: ServerOption pattern, WithAuditSink, stdioAuthContext tags
- internal/server/serveredition_wire.go: AuditSink dependency wiring
- internal/server/audit_funnel_test.go: 11 tests covering allow/deny, hidden-server scope, intent rejection, nested calls, limiter shed, stdio/socket callers, schema validation
- activity_result_status_test.go, code_exec_activity_test.go, preflight_telemetry_test.go: statusArgIndex 5→6, ctx passed to emit functions

All audit lines validated against contracts/audit-line.schema.json; no secrets/tokens in clear; one authz per attempt, one tool_call per call.
…ults, doctor finding, write-failure metric, settings wiring (Spec 107 PR-D)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
T116: ran scripts/dev-server-edition.sh --phase d end to end, plus manual
extensions for cases the script doesn't cover (sentinel in a caller-supplied
tool name, native stdio transport rules, unwritable path). Fixed two rig
bugs found along the way (not in the audited feature code): the scratch
config left fixture servers quarantined, and the isError=null vs "false"
assertion didn't accept the omitempty case. Documented a pre-existing,
out-of-scope discrepancy: `--listen ""` doesn't actually reach native stdio
through `mcpproxy serve` (Validate() resets it to the HTTP default);
`--listen ":0"` does.

T117: re-ran the full gate set since the adversarial-review commit changed
code after the prior recording. All green; two transient flakes under heavy
concurrent load (a Docker-status test, a launcher-lifecycle respawn test)
confirmed non-reproducing / out of this PR's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Spec 107's four PRs (A, B, C, D) are all open; PR-D is #1296.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 52a10e6
Status: ✅  Deploy successful!
Preview URL: https://d0c973e5.mcpproxy-docs.pages.dev
Branch Preview URL: https://107-d-audit-line.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 107-d-audit-line

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (21 MB)
  • smart-mcp-proxymcpproxy-goQ7DZCE.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 35242884720 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Sep 17, 2026

Copy link
Copy Markdown

opencode CLI (gpt-5.6-sol / gpt-5.6-terra) reviewed the PR-D diff in 5
chunks; all 5 returned findings (16 total). 8 verified genuine and
fixed: NaN/Infinity silently hashed as JSON null instead of refused
(canonical.go), client.version unmasked in the audit line, a short
sink write not counted as a failure, a malformed args_json dispatch
producing no audit line at all, work_session_id never stamped on the
attempt, auth_event lines never carrying client.ip, two missing
FR-014 startup notices, and a missing sidebars.js entry for the new
audit-log doc. 3 rejected as false positives with evidence (redact.go
patterns are a superset of the log sanitizer's, not narrower; no
request_id==transport_request_id binding rule exists in the
contracts doc; the unknown-server fail-open in mcp_code_execution.go
is a documented deliberate design choice). 5 more confirmed genuine
but deferred to a later round given their blast radius (JCS float
formatting, per-kind caller-identity validation, auth_event flags
vocabulary, Attempt.Operation sourced from the caller's variant
instead of the resolved target tier, and a batch-cancellation gap in
the tool_call/authz pairing) — all documented in verification.md.

Full round detail, rejection evidence and verification commands in
specs/107-server-edition-sso-hardening/verification.md under PR-D /
Cross-review / Round 1.
Fixes 8 findings from round 2 (opencode gpt-5.6-sol/terra, 5 chunks),
three of them round-1's own deferred list independently re-confirmed:

- ES6 Number::toString fixed/exponential threshold in formatNumberJCS
  (args_sha256 could diverge from a real JCS reference implementation)
- length-cap the per-field audit redaction pass (unbounded client.name
  could drop a required line at the sink's record-size limit)
- Server.ReplayToolCall wrote zero audit lines (bypassed the funnel
  entirely via runtime.ReplayToolCall's direct managed-client call)
- malformed args_json recorded authz allow before the remaining gates
  ran; now authz deny (reason: other)
- audit line operation now reflects the target tool's real tier, not
  the caller's chosen call_tool_* door
- batch dispatch no longer skips the audit-attempt bridge on an
  already-cancelled execution context
- UpdateUserLogin returns the record a subject_mismatch/user_disabled
  refusal was decided against, closing a race that could silently drop
  auth_event's required user_id
- HandleLogin now flags redirect_rejected on a pre-redirect failure,
  not only via the callback's stored pending state
- EffectiveAuditLog now honours an explicit audit_log block on the
  personal edition; only the absent-block default is edition-keyed

See specs/107-server-edition-sso-hardening/verification.md for the full
per-finding table and verification commands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants