Skip to content

[DO NOT MERGE — awaiting D-1] feat(effects): M1 — request-aware proxy/direct Net transport - #613

Draft
sunholo-voight-kampff wants to merge 1 commit into
devfrom
sprint/iter156-net-proxy-boundary-m1
Draft

[DO NOT MERGE — awaiting D-1] feat(effects): M1 — request-aware proxy/direct Net transport#613
sunholo-voight-kampff wants to merge 1 commit into
devfrom
sprint/iter156-net-proxy-boundary-m1

Conversation

@sunholo-voight-kampff

Copy link
Copy Markdown
Collaborator

DRAFT — HELD, not ready to merge. Milestone M1 of m-net-effect-proxy-boundary, implemented to the reviewed design doc's letter. It is opened as a draft because implementing the design faithfully breaks four existing SSRF tests on CI's own poisoned-proxy legs, and the decision that resolves that (D-1) is still awaiting human ratification.

What landed

A package-private request-aware netProxyRoundTripper (internal/effects/net_proxy.go, 150 lines) with separate direct and proxy transport paths:

  • no proxyresolveAndValidateIP exactly once, dial the returned IP with no hostname re-resolution (anti-DNS-rebinding pinning preserved)
  • proxy → ordinary proxy dialing, zero local target resolution/validation; the proxy address never enters the target-IP substitution closure

All three Net constructors routed through it (net.go:96/:212/:587); the three preflight resolveAndValidateIP calls (:85/:201/:565) and the redirect-validator call (:317) removed; public error categories preserved via a typed internal error unwrapped through *url.Error at both enumerated sites (:567, :631). 781 lines of tests.

Acceptance — AC-M1.1 (controller-run, then independently reproduced by the evaluator)

rc=0, === RUN count 17 (≥4), all four top-level --- PASS.
The same command on the pristine tree is rc=0 with === RUN count 0 / [no tests to run]vacuously green — so the count assertion is the load-bearing part, not the exit code.

⚠ Why this is held — B1

HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 NO_PROXY=localhost,127.0.0.1 \
  go test -count=1 ./internal/effects -run TestNetIPValidation
tree result
this branch rc=1 — 4 of 7 subtests FAIL
pristine dev (79caa15b3) rc=0 — 7 PASS

The only variable is this change. ci.yml sets exactly that poison on its own test legs (:89-91, :350-352), so this will red CI.

Mechanism, and it is D-1 by construction: IP validation now happens only on the direct route, so a target that is a literal private IP (https://10.0.0.1/…) is handed to the proxy unvalidated. The loopback subtests still pass precisely because NO_PROXY routes them direct — which corroborates the mechanism.

The decision, with a third option the design doc does not currently carry

resolveAndValidateIP already has a // Special case: raw IP address (skip DNS) branch that calls validateIP with zero network I/O. So for a literal-IP target there is no resolution, no TOCTOU and no rebinding risk — the rationale for skipping validation on proxied routes simply does not apply to that case.

  • (a) Ratify D-1 as written, and update TestNetIPValidation to expect E_NET_REQUEST_FAILED for literal private IPs when a proxy is configured. This narrows the shipped security guarantee.
  • (b) Apply the literal-IP narrowing: validate when net.ParseIP(host) != nil, even on the proxy route. Restores parity for the unambiguous case and shrinks D-1 to hostnames only.

The sprint evaluator (independent, sonnet) reviewed this at HOLD 71/100 and recommends (b).

Verification

  • Controller mutation D (neuter the production http.ProxyFromEnvironment default) → gate rc=1, but reds exactly one subtest — the production default has a single point of coverage (NB1). sha256 restored byte-identical.
  • Evaluator ran three further, distinct mutations (bypass direct validation; reverse routing; re-add preflight resolution to the proxy path); each reddened the expected subtests. All restorations sha256-verified.
  • Gates green: scoped go build, go vet, gofmt, make check-file-sizes / check-boundaries / check-changelog / fmt-check, and go test ./internal/effects ./internal/executor/... ./internal/testutil (rc=0).
  • No behavioural drift: the original transports were bare http.Transport{} with only a custom DialContext; so are the new ones. No goroutine leak measured across 20 sequential / 50 concurrent requests.

🤖 Generated with Claude Code

…get-IP pinning on the direct route

Implements M1 of m-net-effect-proxy-boundary to the reviewed design doc's letter.

- New package-private `netProxyRoundTripper` (internal/effects/net_proxy.go) owning
  SEPARATE direct and proxy transport creation paths; never mutates one shared
  transport between modes.
  - no proxy  -> resolveAndValidateIP exactly once, dial the returned IP with no
    hostname re-resolution (anti-DNS-rebinding pinning preserved)
  - proxy     -> ordinary proxy dialing, ZERO local target resolution/validation;
    the proxy address never enters the target-IP substitution closure
- All three Net client constructors routed through it (net.go:96, :212, :587).
- Removed the three preflight resolveAndValidateIP calls (net.go:85, :201, :565)
  and the redirect-validator call (:317). validateRedirect keeps redirect-count,
  protocol checks and the caller's cross-origin Authorization stripping.
- Public error categories preserved via a typed internal targetValidationError
  unwrapped through *url.Error, at both enumerated sites (net.go:567 preflight,
  :631 post-client.Do).
- Unexported test seams on NetContext (lookupIP, dialContext, proxySelector), nil
  in production, making resolver-call and dial-call counts falsifiable.
- 781 lines of tests: TestNetProxyBoundary, TestNetProxyDirectPin,
  TestNetProxyNoProxy, TestNetProxyRedirectControls (AC-M1.1..M1.4).

AC-M1.1 gate (controller-run): rc=0, `=== RUN` count 17 (>=4), all four top-level
`--- PASS`. Baseline on the pristine tree was rc=0 with `=== RUN` count 0
(`[no tests to run]`) — vacuously green — so the count assertion is load-bearing.

*** DO NOT MERGE — see the PR body. ***
This faithfully implements the reviewed design, and in doing so it makes
`TestNetIPValidation`'s four non-loopback subtests FAIL under the poisoned proxy
that ci.yml sets on its own test legs (ci.yml:89-91, 350-352). Negative control:
the identical command on the pristine tree is rc=0 / 7 PASS. Root cause is D-1 by
construction: SSRF IP-blocking now applies only on the direct route, so a target
that is a LITERAL private IP is handed to the proxy unvalidated. D-1 is still
awaiting human ratification.

Co-Authored-By: DeepSeek V4 Flash 0731 (pi)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sunholo-voight-kampff pushed a commit that referenced this pull request Aug 7, 2026
…; the outage-era greens were not spendable

Gate 4 record + one Gate-5 skill edit.

Landed: #606 (squash 49a6af7) and #608 (squash 1d35524) — three iterations
of outage backlog cleared; #602 and #603 auto-closed.

Held: m-net-effect-proxy-boundary M1 as #613, a DRAFT titled DO-NOT-MERGE. Its
own acceptance passes (AC-M1.1 rc=0, `=== RUN` 17, four top-level PASS against a
vacuously-green baseline), but implementing the reviewed design faithfully hands
a LITERAL private IP to the proxy unvalidated, so TestNetIPValidation goes rc=1
with 4-of-7 failing under the poison ci.yml sets on its own legs — rc=0 with 7
PASS on pristine dev, a real negative control. D-1 is now a demonstrated loss of
an existing control rather than an abstract trade, and it has a third option the
doc does not carry (validate when net.ParseIP(host) != nil, zero DNS, no TOCTOU),
which the independent evaluator endorses. Not applied unattended: rule vii says
the reviewed doc wins and Standing rule 2 forbids narrowing a quorum-cleared
security boundary without the human.

SKILL EDIT (Gate 3b, the workflow_dispatch clause) — two frictions, same gap:
  1. iteration 155 fired a dispatch at 21:30Z and banked the mechanism from
     `total=0 -> total=1`; that run was still queued with jobs=0 seven hours
     later, and could not be cancelled.
  2. iteration 156 measured what a dispatch actually buys: all four required
     contexts success on the head SHA via commits/<sha>/check-runs (18 rows),
     while `gh pr checks --required` showed one and the PR stayed BLOCKED —
     branch protection is gated on the `pull_request` suite the outage wedged.
So the clause as written would tell a future iteration a PR is unblocked when it
is not. Corrected, with the fix that does work (a tree-identical empty commit via
the git API), plus mission-world iter-59's P1 green-direction rule — adopted only
after corroborating it first-party here: CI[dev] success 17:32Z, failure 20:03Z,
success 21:57Z, with runs from 17:02Z wedged through all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sunholo-voight-kampff added a commit that referenced this pull request Aug 7, 2026
…een, and

the charter's own scale warning was a claim nobody had measured

PICK: #545, because both items above it are parked on a human (#604 on D-2,
#613 on D-1) and iteration 157's own Next names it as the fallback.

HEADLINE: the charter has carried #545 since iteration 149 behind a warning
reading "47 files ... a surface that moved a lot ... a rebase-and-revalidate
job, NOT a merge". Every number in it is true and the conclusion is false.
The merge produces THREE conflicted files, one hunk each: a changelog union
with zero content overlap, a two-line struct-literal union, and one that looks
enormous (427 lines ours vs 1 theirs) and is a code MOVE — dev extracted the
agent path into cmd/ailang/eval_benchmark_agent.go, and #545's real delta to
that block is four lines. Three iterations deferred the item on that sentence.
A "surface moved a lot" warning measures how much OTHER work landed, never how
much of it collides.

Also recorded: the union resolution justified rather than assumed (dev's new
zero-cost fallback resolves through the same models.yml key as #545's
task.Pricing, so it does not reintroduce the two-price-table defect); the
changelog claims a v0.33.1 release for which NO TAG EXISTS (caught in my own
dashboard draft before publishing); Sonar at six consecutive analysed reds,
measured on HEAD; and the Gate-0 weekly sweep, which found 7 zero-mention
issues, closed #598 as a verified duplicate of #602, and batched the other six
into one queue row positioned below every existing pick.

STATUS rotation done with the arithmetic asserted before writing
(before=after=1864, moved=1) and a post-edit queue-row grep, per the rules
iteration 127's mass-deletion bought.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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