Skip to content

feat(orthrus): write-mode support + muzzle allowlist parity (#1160, #1161)#1166

Open
Wikid82 wants to merge 31 commits into
developmentfrom
feature/orthrus
Open

feat(orthrus): write-mode support + muzzle allowlist parity (#1160, #1161)#1166
Wikid82 wants to merge 31 commits into
developmentfrom
feature/orthrus

Conversation

@Wikid82

@Wikid82 Wikid82 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds opt-in Orthrus write-mode: full Docker Engine API access through this agent's External Docker Proxy once enabled (see "Write-mode security model" below), rate limiting, audit logging, PATCH/proxy-status API extensions, and the corresponding UI (write-mode toggle with typed-name confirmation, audit-log deep-linking, i18n).
  • Fixes GH orthrus: path-normalization order differs between backend and agent muzzle filters #1160: the backend and remote-agent Docker API muzzle filters normalized request paths in a different order, allowing three independent accept/reject divergences (including on a write endpoint) that could theoretically bypass the read-only guarantee if the agent's filter were ever reached without the backend filter running first.
  • Fixes GH orthrus: unify the two independently hand-maintained muzzle allowlists (and give agent/ CI coverage) #1161: unifies enforcement of the two independently hand-maintained muzzle allowlists via a new stdlib-only structural parity checker (scripts/ci/check_muzzle_allowlist_parity.go), and brings the agent/ Go module up to the same CI bar as backend/ (lint, staticcheck, vet, coverage gate).

Root cause (#1160)

Backend (backend/internal/orthrus/muzzle.go) stripped the /vX.Y Docker API version prefix before path.Clean; the agent (agent/muzzle/muzzle.go) did path.Clean first and used a looser path.Match("v*", ...) wildcard instead of a numeric-anchored regex. Three divergences were found and closed, all agent-more-permissive-than-backend:

  1. /foo/../v1.44/images/x/json (the original issue example)
  2. /vFOO/containers/json (non-numeric version segment)
  3. POST /vFOO/containers/abc/start — same class of bug, but on a write endpoint, once write-mode is enabled

This fix is unaffected by the write-mode redesign below — it lives entirely in normalizeDockerPath, which still governs the read-only allowlist both filters keep.

Design decision (#1161)

Evaluated a shared importable Go package and codegen from a single source-of-truth file; rejected both — agent/ is intentionally a minimal, dependency-free FROM scratch binary, and codegen was disproportionate to how rarely this data changes. Instead: a go/ast-based structural drift checker, wired into CI, layered on top of a shared behavioral test corpus (backend/internal/orthrus/testdata/muzzle_corpus.json) consumed by both modules' test suites. Originally diffed 8 paired declarations; now diffs 4 (versionPrefixRe, allowedDockerPaths, allowedDockerPatterns, allowedDockerPrefixSuffixPatterns) — the write-endpoint-allowlist and HostConfig-field-allowlist declarations it used to also compare no longer exist in either file, per the redesign below.

Write-mode security model: full-access redesign (supersedes the section below)

Write-mode originally shipped with, and this PR then iteratively hardened, an exhaustive allowlist of specific HostConfig fields considered safe for /containers/create, plus a fixed 7-operation write-endpoint allowlist. In practice, real update-checker tools (Dockhand-style) recreate containers by echoing back their full previously-inspected HostConfig, and Docker's ~60-field HostConfig/Resources schema mostly lacks omitempty — so legitimate, harmless fields kept surfacing one at a time as separate bug reports (CpuQuota, CgroupnsMode, VolumesFrom, PidMode, Runtime, ...), each requiring a new value-classification pass mirrored across two independently-maintained files. The section below documents that iterative hardening process; all of it has since been removed in favor of a different security model, at the operator's explicit request: write mode is now a deliberate full-access, informed-consent grant. Once an agent has write_enabled, every Docker Engine API endpoint and every request body is forwarded unconditionally — no field allowlist, no endpoint allowlist. Read-only sessions (write_enabled: false, the default) are completely unaffected.

What stayed in place: the read-only GET-path allowlist (unchanged), rate limiting, and audit logging on every write-mode request. What changed in the write-mode dialog: the warning copy now states plainly that enabling write mode is equivalent to direct Docker socket access, replacing the previous copy that described a narrow six-operation grant. The per-agent VolumesFrom source allowlist added during the hardening pass below was removed as part of this redesign — it becomes meaningless once HostConfig is unrestricted.

HostConfig field allowlist hardening (superseded, kept for history)

Once write-mode landed, real update-checker tools (Dockhand-style) recreating containers by echoing back their full inspected HostConfig began surfacing previously-unconsidered fields one at a time — most HostConfig/Resources struct fields lack omitempty, so Docker always serializes them even at zero/default values. Rather than fixing each reported field in isolation, every round did a proactive pass over the related Docker HostConfig schema and applied the same safe/value-checked/dangerous classification framework to catch adjacent fields before they became separate bug reports. All changes were mirrored across both muzzle implementations with matching unit tests and shared-corpus cases, verified against scripts/ci/check_muzzle_allowlist_parity.go. This entire allowlist — every field and validator described below — has been removed; see "Write-mode security model" above.

  • CapDrop, UsernsMode: CapDrop only ever reduces privilege (unlike CapAdd); UsernsMode got a value-level check rejecting only "host".
  • CpuQuota, ContainerIDFile, +27 other resource-limit/cosmetic/networking-convenience fields: a proactive pass across the CPU/memory/IO/pid resource-limit family, restriction-only fields, cosmetic/metadata fields, and networking-convenience fields. ContainerIDFile got a value-level check (empty string only).
  • CgroupnsMode, IOMaximumBandwidth + 3 Windows-only-but-Linux-serialized siblings: CgroupnsMode was reclassified from blanket-excluded to value-checked.
  • VolumesFrom: added OrthrusAgent.AllowedVolumesFromSources, an explicit per-agent operator-configured allowlist of container names/IDs, negotiated over a handshake header — since removed (see above).
  • Isolation: Windows-only platform selector, was allowed unconditionally.
  • PidMode, IpcMode, UTSMode: each got a value-level check closing their "host"/"container:<id>" values.

Test plan

  • Backend + agent unit tests green, including the rewritten write-mode test suites proving the full-access behavior (previously-blocked endpoints like exec/build/prune, and previously-rejected HostConfig bodies, now succeed under write_enabled: true; unchanged under write_enabled: false)
  • check_muzzle_allowlist_parity.go passes (4/4 declarations)
  • Frontend: type-check, lint, full Vitest suite, and build all pass; AgentWriteModeDialog tests updated for the new warning copy and payload shape
  • Full backend (go test ./...) and agent (go test ./...) suites green
  • golangci-lint: 0 new issues on any touched file (both modules)

Wikid82 added 16 commits July 20, 2026 05:21
…view rounds

Records the fully revised, Supervisor-approved spec for opt-in per-agent
Docker write access through Orthrus, including the closed
VolumeOptions.DriverConfig bind-mount bypass and the agent-side
body-inspection mechanism. Status line documents the direct user
sign-off gate and the earlier corrected self-certification attempt.
Playwright specs for the write-mode toggle UX per the approved spec's
Phase 1: default-off, typed-name confirmation gating enable (not
disable), reconnect notice, WRITE badge, and the audit-log deep link.
All test.fixme until the corresponding UI lands.
Foundation commit for opt-in per-agent Docker write access (spec at
docs/plans/current_spec.md):

- models.OrthrusAgent gains WriteEnabled bool (default false).
- AgentSession/NewAgentSession, ExternalProxyStatus, and Muzzle/NewMuzzle
  gain write-mode-related fields and parameters (writeEnabled,
  writeLimiter, auditLogger, agentUUID), but no write-endpoint allowlist
  branch exists yet — every call site passes false/nil, so behavior is
  byte-for-byte unchanged.
- agent/muzzle.Filter becomes connection-scoped instead of
  process-scoped (New(writeEnabled bool)); agent/leash.Leash constructs
  a fresh Filter per successful dial instead of once per process,
  closed over by that connection's streams, so a mid-connection
  write-mode toggle can't retroactively affect an already-negotiated
  session.

Full backend and agent test suites pass unmodified — this is pure
plumbing, not yet reachable behavior.
…tion, rate limiting, and audit logging

Core write-path implementation for the Orthrus write-mode feature
(docs/plans/current_spec.md), in both independently-enforcing muzzle
filters:

- backend/internal/orthrus/muzzle.go and agent/muzzle/muzzle.go each
  gain a fixed write-endpoint allowlist (image pull, container
  start/stop/restart/create/remove), consulted only when a session
  negotiated write mode. Every other endpoint (exec, volume/network
  mutation, image delete, build, prune, auth, commit, Swarm/service)
  stays permanently blocked regardless of the flag.
- POST /containers/create gets hybrid allowlist/value-level body
  validation: an exhaustive HostConfig key allowlist (fail-closed
  against unknown future Docker API fields), a value check on
  NetworkMode (rejects "host" and "container:*"), and a Mounts check
  that rejects bind-type mounts AND any Mounts entry carrying
  VolumeOptions.DriverConfig at all — closing the documented bypass
  where Docker's "local" volume driver can make a Type:"volume" mount
  behave as an arbitrary host bind mount. Identical logic in both
  files, duplicated by design (agent/ is a separate minimal-binary
  module), verified identical via a new shared JSON test corpus
  (backend/internal/orthrus/testdata/muzzle_corpus.json) loaded by
  both packages' test suites — the concrete first implementation of
  the anti-drift mitigation GH #1161 already recommended.
- Write traffic gets its own session-scoped rate limiter
  (0.5 req/s, burst 5 — one full pull/stop/remove/create/start cycle),
  since the External Proxy's raw http.Server has never been covered by
  Charon's existing Gin-based rate limiting.
- Every write attempt (allowed, blocked, or rate-limited) produces one
  SecurityAudit entry via a new narrow AuditLogger interface (avoids
  an orthrus->services import cycle).
- X-Orthrus-Write-Enabled handshake header negotiates write mode
  atomically at WebSocket upgrade time; the agent's Filter becomes
  connection-scoped so a DB toggle only takes effect on next reconnect.
- CI: orthrus-build.yml and nightly-build.yml now run the agent
  module's own test suite (previously zero test execution in CI for
  agent/, confirmed via direct workflow grep) before the Docker build
  step, gating both the PR-facing and the nightly force-push-based
  publish paths.

Full backend and agent test suites green, including the dangerous-body
corpus cases (Privileged, CapAdd, Binds, NetworkMode:host,
bind-type Mounts, the VolumeOptions.DriverConfig bypass, Devices,
Sysctls, unrecognized future fields) — all independently rejected by
both filters.
…it logging

Section 3.2 of docs/plans/current_spec.md:

- OrthrusService.Patch gains a writeEnabled *bool parameter, following
  the existing "*T present = intend to change" pattern used by every
  other field on this endpoint.
- OrthrusHandler.Patch emits a SecurityAudit entry
  (orthrus_write_enabled/orthrus_write_disabled) whenever an operator
  toggles write_enabled, using this codebase's existing
  actorFromContext(c) convention for admin-initiated audit events
  (matches security_handler.go/crowdsec_handler.go) rather than
  growing OrthrusService's dependencies with a *gin.Context-derived
  actor — this is a handler-layer concern, separate from the
  per-write-request audit entries Muzzle already emits directly.
- GetProxyStatus response gains configured_write_enabled (DB value)
  and active_write_enabled (live session value, from the extended
  ExternalProxyStatus), so the frontend can detect and surface the
  same "configured differs from active, reconnect needed" situation
  already modeled for external_proxy_port.
- routes.go wires orthrusServer.SetAuditLogger(securityService) so
  Muzzle's per-write-request audit entries (added in the previous
  commit) actually reach the database in the running app, not just in
  tests.

New test coverage: write_enabled toggling on/off at the service layer,
audit-entry emission (and non-emission when write_enabled isn't part
of the request) at the handler layer via SecurityService.Flush() +
ListAuditLogs, and the two new proxy-status response fields. Full
backend suite green; GORM security scan clean (0 critical/high).
Section 3.5 of docs/plans/current_spec.md:

- OrthrusAgent/PatchAgentRequest/ExternalProxyStatus types gain
  write_enabled / configured_write_enabled / active_write_enabled,
  matching the backend response shapes from the previous two commits.
- New AgentWriteModeDialog, deliberately separate from
  AgentExternalProxyDialog rather than a section bolted onto it (see
  in-file rationale: transport-layer vs. permissions-escalation
  concerns, each with independent "configured vs. active" state).
  Toggling on requires typing the agent's exact current name before
  Save enables; toggling off requires no confirmation. Shows the fixed,
  non-editable list of permitted operations, a security warning
  distinct from the external-proxy dialog's, a reconnect notice
  mirroring the existing configuredDiffersFromActive pattern, and a
  link into a pre-filtered audit-log view.
- OrthrusAgentManager gains a write-mode action button (ShieldCheck
  icon) and a WRITE badge, alongside the existing PROXY badge.
- AuditLogs.tsx now initializes its filter state from the URL query
  string on mount (event_category, resource_uuid, actor, action,
  start_date, end_date) via react-router-dom's useSearchParams,
  auto-expanding the filter panel when any are present — closes a
  real, previously undocumented deep-linking gap found during this
  feature's research: a link into a filtered audit view landed
  unfiltered. Generic fix, not Orthrus-specific.
- EventCategory/AuditAction TS unions extended with orthrus_write and
  its five action values.
- i18n: new hecate.writeMode.* keys and agentManager.writeMode across
  all five locales (en fully localized; fr/de/es/zh carry English
  placeholder text for the new keys, matching this repo's existing
  handling of other not-yet-translated strings like configureProxy).

Full frontend suite green: 2988 tests passed, 0 failures. Type-check
and production build both clean.
…y claims

Final commit for the Orthrus write-mode feature (docs/plans/current_spec.md):

- tests/orthrus-write-mode.spec.ts: un-skip all Phase 1 specs now that
  the UI exists. Fixed two issues found only by running against the
  real app (not visible from code review): the Switch component's
  underlying <input role="switch"> is sr-only with near-zero hit area,
  so clicking it directly (even with force:true) can land on a
  clipped coordinate — clicking the wrapping <label> instead is both
  more robust and closer to real user interaction. Also split the
  "enable succeeds" test in two: a real save always closes the dialog
  (matching AgentExternalProxyDialog's existing pattern), so the
  reconnect notice can only be observed on a later re-open, not
  within the same session — the original single test's premise didn't
  match the dialog's actual (correct) behavior.
- docs/features/orthrus.md: rewrites the three absolute "cannot be
  changed" / "no way to turn it off" claims (Section 2.8 of the spec)
  to accurately describe the read-only default (still unconditional,
  can't be loosened by accident) alongside the new opt-in, per-agent,
  audited write capability — including the six-operation fixed list,
  the typed-confirmation requirement, and the multi-network
  limitation from Section 4.2.

Validation: full backend/agent test suites, GORM scan, and frontend
suite already green from prior commits. This commit's own testable
surface — tests/orthrus-write-mode.spec.ts — run 3x consecutively
against a freshly rebuilt E2E container (9/9 passing each time, no
flakiness). Full `npx playwright test --project=firefox` run: 848
passed, 37 skipped, 14 failed — all 14 failures verified via
`git diff 0d81fc9..HEAD` to be pre-existing and untouched by any
commit in this branch (zero diff on every failing test's file).
…zation divergence (#1160)

Add 7 shared corpus entries (backend/internal/orthrus/testdata/muzzle_corpus.json)
covering the version-prefix normalization order divergence between
backend/internal/orthrus/muzzle.go and agent/muzzle/muzzle.go: a
traversal-disguised version prefix, a non-numeric fake version prefix on a
read path/HEAD path/write path, plus three already-agreed traversal/double-
slash regression cases migrated from agent-only tests.

No production code changes. TestMuzzle_SharedCorpus (backend) passes
unchanged. TestFilter_SharedCorpus (agent) fails on exactly the 4 divergence
rows -- this is the intentional red state proving the bug before the fix
lands in a following commit.
…le (#1160)

Extract ServeHTTP's existing strip-then-clean sequence into a named
normalizeDockerPath(rawPath string) string helper, purely for naming
symmetry with the forthcoming agent-side helper (agent/muzzle/muzzle.go)
and to give the upcoming structural parity checker (#1161) a stable,
identically-named anchor in both files.

No behavior change -- full pre-existing test suite passes unmodified.
Adds direct unit tests for normalizeDockerPath covering the two GH #1160
divergence rows (traversal-disguised version prefix, non-numeric fake
version prefix) plus existing traversal/double-slash regression cases.
)

Introduce normalizeDockerPath in agent/muzzle/muzzle.go, applying
versionPrefixRe strip-then-path.Clean in the same order as
backend/internal/orthrus/muzzle.go's helper of the same name, and route
every allowlist check (read, HEAD /_ping, and write) through it.

Collapses the previously-duplicated allowedPatterns list into
allowedDockerPaths (exact) + allowedDockerPatterns (dynamic), renames
imageDistributionPatterns to allowedDockerPrefixSuffixPatterns, and drops
all now-redundant "/v*/..." pattern entries -- version handling is unified
through normalizeDockerPath instead of a loose path.Match "v*" wildcard,
which required no digits and was strictly more permissive than backend's
numeric-anchored ^/v\d+\.\d+ regex.

allowWrite's signature changes to accept a pre-normalized path computed
once by its caller (Allow), instead of separately re-deriving an
"unversioned" value for its exact-path branch only while its pattern branch
matched the raw path directly -- closing a second, independent instance of
the same divergence class reaching a write endpoint.

Turns green the 4 corpus cases added red in the prior commit. Full agent
and backend/internal/orthrus test suites pass.
Add scripts/ci/check_muzzle_allowlist_parity.go, a stdlib-only (go/ast,
go/parser, go/constant) drift guard that structurally compares all eight
paired declarations between backend/internal/orthrus/muzzle.go and
agent/muzzle/muzzle.go: the seven allowlist/body-size-constant data
declarations plus versionPrefixRe's regex source string (extracted from its
regexp.MustCompile(...) call expression). Fails loudly, not silently, on
any declaration it cannot find or extract, and reports every mismatch with
the specific missing/extra entries.

The existing shared behavioral corpus proves outcome parity for the inputs
it contains but cannot catch "a new pattern was added to one file's
declaration and nobody added a corresponding corpus case" -- the exact
mechanism behind the production incident that motivated this plan. This is
a second, independent guard layered on top of the corpus.

Wired into lefthook.yml's pre-commit stage (glob-scoped to the two muzzle
files) and a new always-run quality-checks.yml job (not gated on agent/**
paths, so a lefthook bypass via --no-verify is still caught in CI).

Manually verified (throwaway edit + revert, not committed) that the checker
independently catches both AST-extraction shapes: an added
allowedWritePatterns entry, and a loosened versionPrefixRe regex.
…for agent module (#1161)

Extends agent/'s CI parity with backend/ beyond "tests run" (already landed
in the write-mode commits) to "tests run, lint enforced, coverage gated":

- Move backend/.golangci-fast.yml to repo-root .golangci-fast.yml (single
  shared source of truth); golangci-lint-fast.sh/-full.sh now loop over
  both backend and agent.
- Split lefthook.yml's go-vet into module-scoped go-vet (backend/**/*.go)
  and go-vet-agent (agent/**/*.go), and add agent-test-coverage to the
  manual `testing` pipeline.
- Add scripts/agent-test-coverage.sh: same line-coverage-from-coverprofile
  gate arithmetic as scripts/go-test-coverage.sh, minus encryption-key
  bootstrapping (agent has none). Threshold calibrated to the module's
  actual post-this-PR aggregate (65%) per R8 -- agent/leash's pre-existing
  44% coverage is explicitly out of scope to raise here.
- Add agent-quality job to quality-checks.yml: go vet, golangci-lint (fast,
  root-shared config), coverage gate -- runs unconditionally on every PR,
  not gated on agent/** paths. Deliberately omits backend-quality's
  encryption-key resolution, GORM scan, and Perf Asserts steps (agent has
  none of those concerns).
- Implement the previously-dangling scripts/check-module-coverage.sh
  (Makefile referenced it but it didn't exist on disk -- a pre-existing,
  unrelated bug found during this PR's research and fixed alongside the new
  agent script); Makefile's check-module-coverage echo text corrected from
  the already-inaccurate "backend + frontend" to "backend + agent".
- Add agent/cert/cert_test.go (LoadOrGenerate had 0% coverage despite being
  genuine ECDSA/x509 logic) and agent/protocol/message_test.go (wire-format
  constant-value regression guard).
- Extend scripts/local-patch-report.sh, backend/cmd/localpatchreport, and
  backend/internal/patchreport to compute and report agent/ patch coverage:
  patchreport.ParseUnifiedDiffChangedLines gains a third (agent) return
  value; ParseGoCoverageProfile and normalizeGoCoveragePath gain a
  moduleDir parameter (was hardcoded to backend's prefix-stripping rules,
  which would have silently produced 0% patch coverage for every agent file
  -- the same class of masked-by-a-different-mechanism bug the Root Cause
  Analysis Protocol warns about). Verified end-to-end: a local run against
  a diff touching agent/muzzle/muzzle.go produces a populated, non-zero
  "Agent" row in local-patch-report.md.
- Add agent-codecov job to codecov-upload.yml (uploads agent/coverage.txt
  under a distinct `agent` flag) and exclude agent/main.go from that flag's
  Codecov accounting in codecov.yml, mirroring backend/cmd/api/**'s
  existing entrypoint-exclusion treatment.

Also fixes 10 pre-existing errcheck/shadow findings in agent/leash and
agent/muzzle (unchecked Close() return values, one shadowed err) --
necessary for the new agent-quality lint gate to pass cleanly rather than
inheriting a backlog of findings never checked before this commit. No
behavior change: every fix is an explicit `_ = x.Close()` or a renamed
local variable.

Also fixes make lint-staticcheck-only, broken pre-existing (golangci-lint
v2 doesn't support the v1 --disable-all flag) -- switched to --enable-only,
found while wiring up the same target for agent.
CHANGELOG.md: add Security entry for the GH #1160 muzzle normalization
order fix, and a CI/CD entry summarizing the GH #1161 agent CI parity work
(lint/vet/coverage gates, structural allowlist parity checker, agent patch
coverage, agent-codecov upload).

ARCHITECTURE.md: update the Pre-Commit Checks and Continuous Integration
sections to describe agent/'s new lint/vet/coverage parity with backend/,
the muzzle allowlist parity checker, and the agent Codecov flag. Also
corrects a stale reference to ".pre-commit-config.yaml" (the repo migrated
to lefthook.yml; the file no longer exists).
The GH #1160/#1161 implementation spec was iterated on and approved
but never committed to feature/orthrus, leaving a dangling reference
in check_muzzle_allowlist_parity.go's doc comment (which points readers
to this file's "Design decision" section) unresolved at HEAD.
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

@github-advanced-security

Copy link
Copy Markdown
Contributor

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

✅ Supply Chain Verification Results

PASSED

📦 SBOM Summary

  • Components: 1502

🔍 Vulnerability Scan

Severity Count
🔴 Critical 0
🟠 High 0
🟡 Medium 4
🟢 Low 2
Total 10

📎 Artifacts

  • SBOM (CycloneDX JSON) and Grype results available in workflow artifacts

Generated by Supply Chain Verification workflow • View Details

Wikid82 added 11 commits July 20, 2026 21:15
…d enforce threshold locally

scripts/local-patch-report.sh always resolved its diff baseline to
origin/main first, never matching PR #1166's actual GitHub base
(development), so the local tool's patch-coverage numbers silently
diverged from what Codecov reports on the PR. Separately, the tool
computed WARN-level coverage scopes but never failed the process on
them, so CLAUDE.md's Definition of Done Step 2 ("local patch coverage
preflight is MANDATORY") was unenforceable in practice.

- scripts/local-patch-report.sh: three-tier baseline resolution -
  explicit $CHARON_PATCH_BASELINE override, then `gh pr view`'s real
  baseRefName, then a development-preferring static heuristic (main
  is only ever the nightly-promotion target, not the default
  integration branch). Restructures the Go-tool invocation so a
  strict-mode non-zero exit still lets the script's own artifact
  checks run before propagating the real exit code.
- backend/cmd/localpatchreport/main.go: new `-advisory` flag (default
  strict - non-zero exit when any coverage scope is below threshold,
  checked only after both report artifacts are written and the WARN
  diagnostic lines are printed, so a failing gate still leaves a full
  report to read); report.Mode now truthfully reflects "strict" or
  "advisory" instead of the previous hardcoded "warn" literal.
- backend/internal/patchreport: new HasWarnStatus helper, unit-tested
  independently of os.Exit/CLI parsing.
- New scripts/tests/local-patch-report_baseline.bats covering all four
  baseline-resolution branches (gh success, gh absent, gh erroring,
  explicit override winning).
- .gitignore: cover the backend/localpatchreport compiled binary
  (previously untracked but unignored stray build artifact).

No behavior change to agent/muzzle or agent/leash in this commit -
this only fixes the measurement/gating tool itself, so its own
validation gate can still correctly report warn/exit 1 against the
genuinely-uncovered agent code, proving the fix works before the
follow-up commit closes that coverage gap for a real reason.
…flagged by Codecov on PR #1166

Codecov's real patch-coverage comment on PR #1166 flagged agent/muzzle/muzzle.go
(81.72%, 9 missing/8 partial) and agent/leash/leash.go (36.36%, 7 missing) -
existing, already-shipped fail-closed validation branches and the
connection-scoped write-mode dispatch wiring that had never been exercised
by any test. No production code changes; test-only.

agent/muzzle/muzzle_test.go adds:
- 4 new malformed-body cases isolating still-untested fail-closed unmarshal
  branches in validateContainerCreateBody (NetworkMode, Mounts, Mounts
  VolumeOptions, HostConfig itself).
- TestFilter_Allow_ContainersCreate_EmptyBodyAllowed, locking in the one
  deliberately permissive branch (empty body allowed through).
- TestFilter_ServeProxy_BodyReadError_ReturnsError and
  _BodyTooLarge_Returns403AndError for ServeProxy's body-handling branches.
- TestFilter_ServeProxy_DockerWriteError_ReturnsError for the
  req.Write(conn) forwarding-failure branch. Forcing specifically the write
  (not the dial, not the response read) to fail via a real kernel socket is
  inherently racy with no synchronization hook available from outside
  ServeProxy; an accept-then-SO_LINGER=0-close approach was tried first per
  Supervisor review and found flaky (write often succeeds locally before
  the close propagates). Uses a bounded-retry technique instead (closing
  the listener before ever accepting, retried on a fresh socket up to 200
  times, accepting only the specific error this branch produces) -
  verified flake-free across 100+ in-process and 40+ freshly-exec'd process
  runs under -race.

agent/leash/leash_test.go adds two integration-style tests proving the
write-mode connection-scoped filter dispatch wiring actually executes for a
real accepted yamux stream, not just in isolation:
- TestLeash_Connect_DockerStreamDispatchesThroughFilter: connect()'s
  AcceptStream loop -> handleStream's streamTypeDocker dispatch ->
  handleDockerStream -> filter.ServeProxy -> net.Dial(dockerSock).
- TestLeash_Connect_PortForwardStreamDialsTarget: handleStream's
  streamTypePortForward dispatch -> handlePortForward's successful-dial
  path and its deferred conn.Close().

Both drive the real exported Leash/Config/Run surface via a test WebSocket
server running a yamux server session, mirroring TestLeash_Reconnect's
existing harness (NewWSNetConn) rather than adding white-box test surface.
Each new test was verified to fail for the intended reason by temporarily
breaking its target code path and confirming the failure, then reverting.
Dockhand's standard image-update flow renames the old container out of
the way (e.g. to <name>_old) before starting the new one under the
original name. POST /containers/*/rename was missing from both
dual-maintained write-mode allowlists, so a live update pulled the new
image successfully but then 403'd on the rename step.

Add the pattern to allowedWritePatterns in both
backend/internal/orthrus/muzzle.go and agent/muzzle/muzzle.go,
following the existing no-body/query-param handling already used for
/start, /stop, and /restart (Docker's rename endpoint takes the new
name via ?name=, not a request body). Covered by new corpus and unit
test cases in both modules; scripts/ci/check_muzzle_allowlist_parity.go
confirms the two allowlists stay byte-identical.
…nt restart

Turning write mode on for an Orthrus agent only takes effect after the
agent's next reconnect, but the dialog gave no indication of that at
save time -- an operator could enable write access, believe it was
live immediately, and be confused when writes still failed until a
manual restart.

AgentWriteModeDialog now checks the PATCH response's fresh agent
status: if write mode was just turned on (off->on transition) and the
agent is currently online, fire a toast (via the existing
react-hot-toast mechanism, no new dependency) naming the agent and
instructing the operator to restart it on its remote machine. No toast
fires when turning write mode off, on a no-op save, or for an agent
that isn't currently connected, since a not-yet-connected agent will
simply pick up the new value on its first connection.

New i18n key hecate.writeMode.restartRequiredToast added to all 5
locale files, matching this block's existing precedent of leaving some
strings English-only in non-en locales.
… design

Append an addendum to the PR #1166 spec documenting both fixes: the
dual-allowlist rename gap (root cause, fix, and validation) and the
write-mode restart toast's design research (toast mechanism precedent,
connection-status source, exact file/function targets, and the Vitest
test matrix), for traceability alongside the already-shipped
GH #1160/#1161 muzzle-parity work documented earlier in the same file.
…ds in muzzle allowlist

Docker's HostConfig echo-back on container recreate keeps surfacing
previously-unconsidered fields one at a time, since most HostConfig/
Resources struct fields lack omitempty tags. Adds CgroupnsMode with a
value-level check (reject only "host", matching NetworkMode/UsernsMode's
shape) plus CPUCount, CPUPercent, IOMaximumIOps, and IOMaximumBandwidth
as unconditionally-safe resource-limit fields (Windows-only in Docker's
schema but always serialized on Linux daemons too).

Mirrors the change across backend/internal/orthrus/muzzle.go and
agent/muzzle/muzzle.go with matching test coverage and shared corpus
cases, so an update-checker tool recreating containers with these
fields set no longer gets rejected.

Claude-Session: https://claude.ai/code/session_01GrUDjmb2PcRiQbX7StjqXb
Wikid82 added 4 commits July 24, 2026 06:44
VolumesFrom (host container's mounts inheritance) can't be blanket-allowed
like the other recently-added HostConfig fields, since the muzzle has no
way to inspect what the *referenced* container's mounts actually are —
unlike NetworkMode/UsernsMode/CgroupnsMode, there's no single safe/unsafe
value shape. Instead, adds OrthrusAgent.AllowedVolumesFromSources: an
explicit, per-agent, operator-configured allowlist of container names/IDs
a write-enabled agent may reference via VolumesFrom, negotiated at connect
time over a new X-Orthrus-Allowed-Volumes-From handshake header (mirroring
how X-Orthrus-Write-Enabled already works) and enforced independently by
both muzzles. An unconfigured allowlist keeps VolumesFrom fail-closed by
default, same as before this change.

Also allows Isolation (Windows-only "default"/"process"/"hyperv" container
platform selector) unconditionally — it only selects an isolation
technology, never grants new access, and can still appear in a Linux
daemon's inspect response like the other Windows-only fields already
allowed.

Adds a UI control on the write-mode dialog for managing the source
allowlist, and unit test coverage across both muzzle implementations, the
service/handler layer, and the frontend.

Claude-Session: https://claude.ai/code/session_01GrUDjmb2PcRiQbX7StjqXb
PidMode fits the same pattern already established for NetworkMode,
UsernsMode, and CgroupnsMode: a closed value space where most values are
safe but one specific value is a genuine host-escape primitive.
PidMode:"host" shares the host's PID namespace (visibility into, and
signal/ptrace access to, every host process), so validatePidModeValue
accepts only the empty string — unlike the other value-checked fields,
PidMode's only other value ("container:<id>") has the same
unverifiable-target problem as VolumesFrom, so there's no safe non-empty
value to allow.

Proactively extends to IpcMode and UTSMode, PidMode's siblings in the same
namespace-placement doc-comment group: UTSMode mirrors
UsernsMode/CgroupnsMode exactly (binary, reject only "host"); IpcMode gets
an explicit allowlist of its three safe values ("", "shareable", "private")
rather than a denylist, rejecting "host" and "container:<id>" plus any
unrecognized future value by default.

Mirrored across both muzzle implementations with matching unit tests and
shared-corpus cases, verified against the allowlist parity checker.

Claude-Session: https://claude.ai/code/session_01GrUDjmb2PcRiQbX7StjqXb
…rite mode

The field-by-field HostConfig allowlist kept surfacing new legitimate-but-
unconsidered fields one at a time (CpuQuota, CgroupnsMode, VolumesFrom,
PidMode, Runtime, ...) as real update-checker tools echoed back full
container configs. Rather than continuing to chase Docker's ~60-field
HostConfig schema indefinitely, write mode is now a deliberate full-access
operator-consent model: once an agent has write_enabled, every Docker
Engine API endpoint and request body is forwarded unconditionally, with no
allowlist restricting either. Read-only (write_enabled: false, the
default) sessions are completely unaffected — the GET-only path allowlist
is unchanged.

This removes the entire HostConfig field allowlist, all nine value-level
validators, the fixed 7-operation write-endpoint allowlist, and the
per-agent VolumesFrom source allowlist added the round before (now
meaningless under full access) — from both independently-maintained muzzle
implementations. The agent-side proxy also no longer buffers request
bodies into memory at all, since nothing inspects body content anymore;
requests stream straight through, same as GET traffic already did.

scripts/ci/check_muzzle_allowlist_parity.go now compares 4 declarations
instead of 8 (the write-endpoint/HostConfig-allowlist targets no longer
exist in either file) — its remaining scope is the read-only path-matching
logic both filters still share.

The write-mode dialog's warning copy is rewritten to state plainly that
enabling write mode is equivalent to direct Docker socket access, not a
narrow six-operation grant, matching the new reality.

Claude-Session: https://claude.ai/code/session_01GrUDjmb2PcRiQbX7StjqXb
Since the full-access write-mode redesign, ServeHTTP routed every request
through forwardWrite once write_enabled was true — including plain GET
polling. The write-path rate limiter is sized for occasional mutation
bursts (5 tokens, refilling one every 2s — "one full update cycle is
exactly 5 write calls"), not for a Docker client's routine container
list/inspect polling, which runs far more often than that. Once the burst
was spent, update-checker tools (e.g. Dockhand) polling GET
/containers/json started intermittently getting 429'd, making containers
appear to vanish from their UI and reappear on refresh — exactly the
reported symptom.

GET/HEAD now bypass the write-path branch entirely for write-enabled
sessions, forwarded the same unaudited, un-rate-limited way a read-only
session's GET traffic already is. Only mutating methods
(POST/PUT/PATCH/DELETE) go through forwardWrite's rate limiter and audit
log, matching the limiter's actual intent.

Claude-Session: https://claude.ai/code/session_01GrUDjmb2PcRiQbX7StjqXb
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