Skip to content

fix(cli-memd): withhold URLs whose credentials cannot be attributed - #165

Merged
PeterGuy326 merged 10 commits into
mainfrom
fix/credential-url-gate
Sep 10, 2026
Merged

PeterGuy326 merged 10 commits into
mainfrom
fix/credential-url-gate

Conversation

@waterbro-8

@waterbro-8 waterbro-8 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Refs #112

Summary

A configured URL that carries credentials in a shape url.Parse does not report
as userinfo reaches output today. This adds one shared gate that redacts such
a value when it can prove it is a transport URL, and withholds the value whole
when it cannot, and wires it into all three egresses R3 names: the API client,
memd's log lines, and mem doctor.

The adjudicated direction this implements is recorded on #112 as R3
(comment 5536659086): gate on "parses as a recognized transport scheme with
userinfo redacted, otherwise withhold the whole value"
.

Scope: all three named egresses

R3 names three egresses — doctor, apiclient, memd's log line. All three are
covered here.

egress on main at the base covered here
apiclient — request construction leaks gated
apiclient — all 4 http.Client.Do sites leaks, ungated entirely gated, one test case per site
apiclient — newRequest on the workspace-transfer path leaks gated (arrived with #164's workspace_transfer.go)
memd startup log line leaks (User != nil gate) gated
memd fatal log line leaks via a third-party error gated
mem doctor (text + JSON) not present gated through the same package

How doctor got onto this branch

mem doctor is not on main; it only existed on #164 (which sits on #131's
original doctor commit). Because #131 was closed as superseded by #164, doctor
had exactly one carrier, so the surviving branch had to absorb it before #164
could be closed. Rather than re-author it, this branch merges the work:

c9e0e63  docs: correct doctor's stated secret guarantee                        (this change)
d30f1ff  fix(cli): route mem doctor's URL reporting through the shared gate   (this change)
ad4e78f  Merge origin/main
d7f2dcb  Merge #164 (mem doctor) into the credential-url-gate branch
b229537  fix(client,memd): withhold URLs whose credentials cannot be attributed
fffcd4c  fix(cli): redact malformed URLs and request build errors   ← #164, author PeterGuy326
46499b2  feat(cli): add mem doctor and first-run guidance           ← #131, author waterbro-8

Both earlier commits are ancestors of this head, so #164's and #131's authorship
is preserved on the merge rather than claimed as mine. #164's unrelated
per-probe --timeout fix and its workspace_transfer.go coverage came across
with the merge and are kept.

What d30f1ff itself does is replace doctor's two local string-level helpers
with calls into internal/redact, so there is one policy rather than three:

func redactURL(raw string) string          { return redact.URL(raw, redact.APIURLs) }
func sanitizeProbeError(err error) string  { return redact.TransportError(err, redact.APIURLs) }

c9e0e63 is prose only. It corrects three statements — in CHANGELOG.md,
docs/DEPLOYMENT.md and a comment in cmds_doctor.go — that asserted the doctor
report contains no secret value. That was never true of the query-parameter
shape described under Known gap, measured at 3 sentinel occurrences per output
format on this head. The non-goal stands; the promise was the thing that was
wrong, so the docs now name the residual instead of denying it.

The shape, and why the previous gate missed it

url.Parse("admin:pw@host")
→ Scheme="admin"  Opaque="pw@host"  User=nil

A gate written as if parsed.User == nil { return raw } therefore echoes the
credential verbatim — not as a corner case, but as the normal path for any
value whose scheme happens to contain a colon.

The gate also refuses to scrub error text, because that cannot be made tight:
url.Error renders with %q, so a " inside a password arrives escaped and a
scanner that pairs quotes mis-pairs, replaces nothing, and leaves its cursor
inside the URL. Withholding is chosen over partial trimming for the same reason:
a delimiter inside a credential splits the message into pieces that no longer
look like a URL, and the piece without the @ is exactly the half that leaked.

memd's fatal line is included because queue.NewClient wraps asynq's parse
error, which embeds the whole DSN:

queue: parse redis url: asynq: could not parse redis uri:
  parse "redis://:PASS@ho st:6379/0": invalid character " " in host name

slog renders an error value as its text, so that reaches the log unmodified.

Known gap, stated rather than smoothed over

The gate proves the absence of userinfo, not of every credential. A secret
supplied as a query parameter (redis://host:6379/?password=x) parses as a
clean URL with User == nil and is still echoed. This is the adjudicated
scope, not an oversight, and closing it is a separate decision. It is pinned by
a named characterization test
(TestTextKnownGapQueryParameterCredentialsAreEchoed) that fails if someone
closes the gap without updating the expectation, so the residual cannot become
folklore.

Withholding also has a real diagnosability cost, accepted by design: the reason
a request failed is still reported, the host is not.

Error: Get [withheld]: unsupported protocol scheme "admin"

Validation ledger

Evidence level claimed: E3 — independently reproduced. Not E4: I am the
author, and E4 requires a non-author to verify the acceptance criteria end to
end.

Environment: Linux x64, Go 1.25.0, exact tree
9fa9c14de074b4ddb5320d4f83803f1657918b34HEAD^{tree} of the current head
c9e0e63, read back from GET /repos/bytefolk/mem/commits/c9e0e63… and equal to
it byte-for-byte, so the tree measured here and the tree on the PR are the same
object. (This head was pushed through the Git Data API because github.com:443
was down on this box; the local commit sha differs from the remote one, the tree
does not.)

Check Result
go build ./... pass
go vet ./... pass
gofmt -l . no output
go test -count=1 ./... 32 packages ok, 0 FAIL
cmd/mem (includes 16 mem doctor tests) 102 tests, 0 fail
internal/redact 30 tests, 0 fail
internal/apiclient 45 tests, 0 fail
cmd/memd 15 tests, 0 fail
git merge-base --is-ancestor origin/main HEAD yes (main not silently reverted)
CI on this head 15/15 check names success on c9e0e63 (run_attempt 1), including Go and PostgreSQL integration; also 15/15 on the immediately preceding d30f1ff

mem doctor end-to-end, measured on built binaries

Seven malformed credential URL shapes, each run through
mem doctor --server <shape> --timeout 1s in both text and json,
counting occurrences of a sentinel password in everything the process wrote:

# shape #164 head fffcd4c this head c9e0e63
1 http://admin:PW@127.0.0.1:1 0 0
2 http://admin:PW@127.0.0.1:%zz 0 0
3 http://admin:PW@ho st.example.com 0 0
4 http://admin:PW x@127.0.0.1:1 0 0
5 http://admin:PW@% 0 0
6 http://admin:PW@mem.internal:99999999 0 0
7 admin:PW@mem.internal:8787 (no scheme) 6 0

Shape 7 is the url.ParseUser=nil case from the section above: #164
prints the configured URL three times (server, detail, hint) in each of
the two formats, so 6 occurrences is one leak per field.

A zero from a binary that has no doctor command means nothing. This table
was first produced against a build of b229537 and came back 0/0/0/0/0/0/0,
which looked like a pass — the actual output was
Error: unknown command "doctor" for "mem", because that head had no doctor at
all. The harness now counts unknown command alongside unknown flag and every
row above was taken with both counters at 0.

Mutation controls

Run against a copy, with the mutation confirmed present in the source before the
suite is trusted:

  1. Reverting the gate to the pre-fix User != nil form turns 10 named cases
    red across 3 packages
    (internal/redact, cmd/memd, cmd/mem).
  2. Removing the gate at one apiclient Do site turns red exactly that
    site's
    test case and leaves the other three green. The four cases map
    one-to-one onto the four sites, so a regression at a single site cannot hide.
    Control 2 was found by this method — an earlier version of this port left one
    site raw and the table caught it.
  3. Putting fix(cli): redact malformed URLs and request build errors #164's original redactURL / sanitizeProbeError back into this
    tree — i.e. merging fix(cli): redact malformed URLs and request build errors #164 and not rewiring doctor — fails exactly one
    test
    , TestDoctorSchemelessServerURLDoesNotLeakCredentials, and its built
    binary leaks 2 sentinel occurrences on shape 7. Before that test was added,
    the same mutation failed zero tests. So the merge on its own was
    unprotected, and the one new test is what closes that.

Two pre-existing assertions were relaxed — named, because one is not mine

The gate withholds a value it cannot prove is a transport URL, where #164's
local helper echoed a partially cleaned one. Two assertions demanded the echo
shape specifically, so they were widened to accept redact or withhold:

location test author of the assertion as written
cmds_doctor_test.go:418 TestRedactURLStripsUserinfo waterbro-8 (#131)
cmds_doctor_test.go:456 TestDoctorMalformedServerURLDoesNotLeakCredentials PeterGuy326 (#164)

Both keep their original first clause — the secret must not appear, and a
cleaned URL must still carry REDACTED@<host>. Only the form of an
acceptable answer widened. Concretely at :418, strings.Contains(got, secret)
still fails the test, and the REDACTED@mem.internal:8787 check two lines above
it is untouched.

No assertion was deleted, and the count went up, not down: #164's 15 doctor
tests are all present on this head (0 dropped), plus TestRedactURLStripsUserinfo
and the new TestDoctorSchemelessServerURLDoesNotLeakCredentials = 16. The
way to check this claim is mutation control 3 above: if the widening had quietly
weakened coverage, restoring #164's scrubber would have gone green, and instead
it fails a test.

Anyone who considers the withhold form unacceptable for :456 should say so on
#164's thread rather than assume I settled it unilaterally — I changed an
assertion another author wrote to pass my gate.

What I did not verify

  • The leak table was rebuilt and re-run against c9e0e63 after the docs commit;
    that commit is prose and comments only, and the result is unchanged (0 on all
    seven shapes). Earlier heads d30f1ff and b229537 each had 15/15 CI at the
    time they were written.
  • There is no prior CI baseline for the doctor half to compare against.
    fix(cli): redact malformed URLs and request build errors #164's head fffcd4c has 0 check-runs and no workflow run has ever been
    recorded for it, so doctor has never been green in CI anywhere — including
    here, where it is now inside the Go job for the first time. That is a gain,
    not a regression, but it does mean no one has seen this code pass CI before.
  • Nothing was exercised on Windows or macOS. All results are Linux x64.
  • memd was not driven end-to-end to its fatal redis path. Reaching it
    requires a reachable PostgreSQL (the queue client is constructed after db Open), and the only local server on 5432 is an unrelated instance whose
    credentials I do not have — my attempt failed at db open: ... failed SASL auth before touching the queue. That egress is therefore evidenced by
    executing the gate on the asynq error string measured from the real library
    in this tree
    , not by a live memd run.
  • No claim is made about #112's exit-code contract, R2/R3 acceptance, or
    feat(cli): add mem doctor and first-run guidance that names the documented deploy/compose path #131/fix(cli): redact malformed URLs and request build errors #164 merge readiness.

Non-goals

This PR stays Refs on #112 rather than Closes, because merging it would not
by itself settle #112's acceptance — that needs a reviewer's decision, not just
a diff landing.

Review strength of this PR, stated plainly: 15/15 checks pass on
c9e0e63, and I am the author of b229537, d30f1ff and c9e0e63 — automated checks and
my own sign-off are not a review. What this PR is missing is exactly one
independent human review; nothing else blocks it (mergeable=true, no conflict
with main, which is an ancestor of this head). It needs one human approval,
and I am not that reviewer — on the doctor half especially, since I edited an
assertion PeterGuy326 wrote.

waterbro-8 and others added 3 commits August 31, 2026 13:02
Consumed revision: R1

## Problem

A first-time user's most common failure is a missing prerequisite they
cannot name. Verified at the base revision: the only signals were a
per-command `not logged in` hint and raw transport errors, and the hint
sent somebody with no server at all to `mem auth login` against a server
that does not exist yet.

## Goal and expected behavior

REQ-001: a read-only `mem doctor` reporting four checks in a fixed order
-- server reachability, credential presence, the workspace the server
resolved, CLI/server version skew -- each carrying its SPEC 7.1 exit
code, with `--format json` per SPEC.md:579.

REQ-002: every command that fails closed on a missing credential names
the documented deployment path when the host has no configuration at all.

REQ-003: diagnosis only -- zero writes, zero remediation, zero
dependency installation, no secret value or DSN printed.

Non-goals: no environment-certification matrix, no host container-runtime
detection, no TTY wizard, no SPEC edit.

## Implementation

- `server/cmd/mem/cmds_doctor.go`: the command, the four probes, and the
  shared probe-failure classification that maps `apiclient.APIError`
  kinds onto SPEC 7.1 codes. Reachability is probed without a credential
  so a bad token is not misread as an outage. A check an earlier failure
  made impossible is reported `skipped`, naming the blocker, instead of
  guessed as a pass.
- `errNotLoggedIn()` replaces 23 duplicated `newCliError(3, "not logged
  in", ...)` constructions. It branches on `configFileExists()`, the only
  signal that separates "never configured" from "configured, but not
  logged in", because `loadConfig` deliberately succeeds without a file.
  Hosts that already have a configuration keep the previous hint text
  unchanged.
- `cliVersion` becomes a variable so the skew check has something to
  compare. Nothing injects it today -- `release.yml` passes only `-trimpath
  -ldflags=-s -w` -- so the check reports "skew not computable" rather than
  inventing a comparison.
- `redactURL` strips userinfo from any reported server URL, because a URL
  is a place operators put credentials. `mem version` now uses it too.
- `docs/schemas/mem-doctor.v1.schema.json` pins the `mem.doctor` v1
  document: closed status and name enums, fixed check order,
  `additionalProperties: false`.

## Acceptance-criteria mapping

- AC-001: `cmds_doctor_test.go` fixtures for unreachable server, missing
  credential, rejected credential, unresolved workspace, quota refusal and
  version skew, each asserting its named check, its exit code and a hint
  that names the documented path, over an `httptest` stub following the
  `cmds_ingest_test.go` pattern. Plus real compiled-binary runs:
  connection-refused (exit 5), a stalled listener (exit 5, timeout hint),
  and a healthy stack stand-in (exit 0, empty stderr).
- AC-002: the stub transport records every request and fails the test on
  any non-GET path, any unexpected path, or any request body; a separate
  assertion scans stdout and stderr for the token value. A server-side
  audit of the compiled-binary runs logged 7 requests, all GET, all
  body=0. A URL carrying credentials is reported redacted, with zero
  occurrences of either secret in either stream.
- AC-003: `--format json` is validated against the checked-in schema by an
  in-test structural validator (closed enums, required keys, pinned
  `prefixItems` names) and against a golden file, and independently by
  `python3 -m jsonschema` (Draft 2020-12, 4.19.2) on the compiled
  binary's real stdout.

## Deliberate assertion change

`TestAuthStatusWithoutTokenReturnsAuthExitCode` asserted the old hint by
exact equality, which REQ-002 makes conditional on config presence. It is
widened, not deleted: the exit code and the login step are still pinned,
and the deployment path is now required.

## Configuration and environment

- Base: `731a468` (`origin/main`)
- OS: Linux 6.18.33.2 WSL2, x86_64; Go 1.25
- No new third-party dependency; `go.mod`/`go.sum` untouched

Refs #112
A configured URL that carries credentials in a shape url.Parse does not
report as userinfo reaches output today. "admin:pw@host" parses as
Scheme="admin" with the credential in Opaque and User unset, so any
implementation that gates on User != nil echoes the credential verbatim.
Measured on a binary built from 87d235b: `mem auth status` against such a
base URL prints the password in clear text, and the same holds for every
http.Client.Do failure in the client, which no redaction covered at all.

Route both egresses through one shared gate that redacts a value it can
prove is a transport URL and withholds the value whole otherwise. Error
text is not scrubbed, because that cannot be made tight: url.Error renders
with %q, so a quote inside a password arrives escaped and a scanner that
pairs quotes mis-pairs and replaces nothing.

memd's fatal line is included because it carries third-party errors that
embed a whole DSN -- asynq puts the URI it failed to parse into the
message -- and slog renders an error value as its text.

The gate proves the absence of userinfo, not of every credential: a secret
supplied as a query parameter parses as a clean URL and is still echoed.
That shape is out of scope here and is pinned by a named characterization
test rather than left as folklore.
@waterbro-8 waterbro-8 added type:bug Something is broken or behaves incorrectly evidence:e3-reproduced Maintainer reproduced with deterministic steps status:in-progress Implementation is actively in progress area:server Go API, CLI, MCP, storage, or server runtime area:cli Command-line interface labels Sep 4, 2026
Carries the `mem doctor` surface from pull/164 (which itself carries the
commit from pull/131) so the CLI feature and the URL gate land together.

`server/internal/apiclient/{apiclient.go,apiclient_test.go}` keep this
branch's implementation: it gates request construction and all four
`http.Client.Do` sites, and its test file already contains the same
`TestRequestBuildErrorRedactsCredentialedURL` plus the schemeless
transport cases. `CHANGELOG.md` keeps both entries.
`mem doctor` had its own copy of the redaction rule. Two of its shapes were
wrong in the same way the API client was: `redactURL` returned the value
unchanged whenever `url.Parse` succeeded without reporting userinfo, and
`sanitizeProbeError` scanned error text for a quoted `://` substring, which
cannot be made tight because `url.Error` renders with %q.

Both now delegate to internal/redact, so the CLI, the API client and memd share
one policy and there is no second implementation left to drift.

TestRedactURLStripsUserinfo and the server_reachability assertion in
TestDoctorMalformedServerURLDoesNotLeakCredentials required the value to come
back as a cleaned URL. The gate withholds it instead, which is the stronger
behavior, so both now accept either outcome while still asserting that neither
the credential nor the raw URL appears.

TestDoctorSchemelessServerURLDoesNotLeakCredentials is new and is what makes
this change load-bearing: with the previous scrubber restored, it is the only
test that fails.
@waterbro-8 waterbro-8 changed the title fix(client-memd): withhold URLs whose credentials cannot be attributed fix(cli-memd): withhold URLs whose credentials cannot be attributed Sep 6, 2026
The CHANGELOG, DEPLOYMENT.md and cmds_doctor.go all claimed the doctor
report contains no secret value. Measured on this tree, a credential in
the server URL's query string is still printed verbatim, because it
parses as a clean URL with no userinfo and so is outside what the gate
proves. Narrow the claim to what is actually guaranteed and name the
residual.
@waterbro-8

Copy link
Copy Markdown
Collaborator Author

Scope widened: this branch now carries mem doctor too

The head moved twice since this PR was last read:
b229537d30f1ffc9e0e63. The body has been updated to match; this
comment is the notification, because body edits are silent.

Why

#131 was closed as superseded by #164, and mem doctor exists only on #164.
So #164 could not be closed until its doctor came here — otherwise absorbing one
credential fix would have deleted a command. Rather than re-author it, this
branch merges #164, which keeps fffcd4c (author PeterGuy326) and
46499b2 (#131's, author waterbro-8) as ancestors with their authorship
intact. d30f1ff then replaces doctor's two local string helpers with the shared
gate:

func redactURL(raw string) string         { return redact.URL(raw, redact.APIURLs) }
func sanitizeProbeError(err error) string { return redact.TransportError(err, redact.APIURLs) }

The title went fix(client-memd)fix(cli-memd) to cover the CLI half.
(pr-policy.yml:34 allows [a-z0-9._/-] in the scope, so a comma-form
fix(cli,memd) would have failed the gate — checked before renaming.)

Doctor measured end-to-end, on built binaries

Seven malformed credential URL shapes × mem doctor --server <shape> × two
output formats, counting a sentinel password in everything the process wrote:

shape #164 head fffcd4c this head
well-formed http://admin:PW@127.0.0.1:1 0 0
http://admin:PW@127.0.0.1:%zz 0 0
space in host 0 0
space in password 0 0
http://admin:PW@% 0 0
non-numeric port 0 0
admin:PW@mem.internal:8787 (no scheme) 6 0

That table nearly misled me and I want the failure mode on record. Run against
b229537 — this PR's previous head — it returned all zeros, and the only
reason it looked wrong is that I checked: that build has no doctor subcommand
and answers Error: unknown command "doctor" for "mem". The guard only counted
unknown flag, which is a different cobra error. It counts unknown command
now, and every row above was taken with both counters at 0.

Mutation control. Restoring #164's original redactURL /
sanitizeProbeError into this tree fails exactly one test —
TestDoctorSchemelessServerURLDoesNotLeakCredentials — and its binary leaks 2 on
shape 7. Before that test was added the same mutation failed zero tests, so
the merge by itself was unprotected. Full detail, including the two assertions I
widened (one of them authored by PeterGuy326), is on #164.

One extra commit, and why it is here

c9e0e63 is documentation-only, and it fixes a claim I wrote. CHANGELOG.md,
docs/DEPLOYMENT.md:194 and a comment in cmds_doctor.go all asserted the
doctor report contains no secret value. Measured on this head:

mem doctor --server "http://mem.internal:8787?password=s3ntinel…"
  → 3 sentinel occurrences per format, text and json alike

A credential in the query string parses as a clean URL with User == nil, so
the gate has nothing to act on. This PR has always declared query-string handling
a non-goal — that stands, and it is pinned by
TestTextKnownGapQueryParameterCredentialsAreEchoed. What was wrong is the prose
promising a property the code never had. The three statements now say what is
guaranteed and name the residual. The docs under-promise rather than
over-promise; no new behavior, so I did not add a CLI-level test for a gap the
gate-level characterization test already pins.

A review point of mine was wrong — retracted

I had carried probeVersionapiclient.New(server, "") (empty token) as an
open defect from #131. It is not one: /v1/version is registered on the
public router at internal/api/api.go:211, outside the
r.Group(... r.Use(s.authMiddleware) ...) block at :220, and apiclient.New's
doc names it as a valid unauthenticated call. All three client sites in
cmds_doctor.go are correct. Retracted on #131 as well.

Current state

head c9e0e63, tree 9fa9c14de074b4ddb5320d4f83803f1657918b34 — equal to the remote head's tree
go test -count=1 ./... 32 packages ok, 0 FAIL
gofmt -l . / go vet ./... clean / pass
doctor tests 16 (14 from #131, +1 from #164, +1 new) — none dropped
origin/main (7a194f1) ancestor of HEAD yes — no conflict, nothing silently reverted
CI 15/15 success on c9e0e63 at run_attempt 1, including Go and PostgreSQL integration; also 15/15 on d30f1ff
mergeable_state blocked

What this PR needs is one independent human approval, and I am not it. I
authored b229537, d30f1ff and c9e0e63; 15/15 checks and my own sign-off are
not a review. The doctor half in particular should not be approved by me, since
I changed an assertion another author wrote in order to make the gate pass.

Not a merge authorization and not acceptance of #112, #131 or #164.

@PeterGuy326 PeterGuy326 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking security review finding:\n\n leaves query strings untouched after parsing a URL, and the PR's characterization test explicitly accepts secrets in and . That means a credential-bearing server/DSN URL can still be emitted by the doctor/error surfaces. The body documents this as a known gap, but this PR cannot be approved as a complete credential egress gate while that path is intentionally echoed.\n\nPlease either extend the gate to withhold/redact credential-bearing query/fragment values (with regression tests), or split this into an explicitly narrow, non-security-complete change with a separately tracked blocking follow-up. Do not treat the current green checks as proof of no credential leakage.

@PeterGuy326 PeterGuy326 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking security review finding:

server/internal/redact/redact.go leaves query strings untouched after parsing a URL, and the PR's characterization test explicitly accepts secrets in redis://.../?password=... and postgres://...?password=.... That means a credential-bearing server/DSN URL can still be emitted by the doctor/error surfaces. The body documents this as a known gap, but this PR cannot be approved as a complete credential egress gate while that path is intentionally echoed.

Please either extend the gate to withhold/redact credential-bearing query/fragment values (with regression tests), or split this into an explicitly narrow, non-security-complete change with a separately tracked blocking follow-up. Do not treat the current green checks as proof of no credential leakage.

@wadrzl

wadrzl commented Sep 6, 2026

Copy link
Copy Markdown

Local Verification Summary for Issue #112

Branch tested: pr-165 at head c9e0e635abf20ce221c3cdb829599cfab37ddca3
Base: main (with merged #164 doctor implementation)

Issue #112 Requirements Coverage

Requirement Status Evidence
REQ-001: mem doctor read-only diagnostic command PASS Command exists with 4 fixed checks: server_reachability, credential, workspace, version_skew
REQ-001: --format json with versioned schema PASS mem.doctor/v1 schema with schema_version: 1
REQ-001: SPEC §7.1 exit codes (0/2/3/4/5) PASS Exit code 5 for unreachable server, 3 for auth failure
REQ-002: First-run guidance names deploy/compose path PASS All hints reference deploy/compose, see docs/DEPLOYMENT.md
REQ-003: Diagnosis only, zero writes PASS Write-guard transport test proves no non-GET requests

Acceptance Criteria Coverage

AC Status Evidence
AC-001: Fixture per finding class PASS 14 doctor tests covering all finding classes
AC-002: No write requests PASS TestDoctorPerformsNoWriteRequests - stub transport fails on non-GET
AC-002: Never prints secrets PASS TestDoctorSchemelessServerURLDoesNotLeakCredentials - schemeless URL with credentials leaks 0 occurrences
AC-003: Golden file validation PASS TestDoctorJSONMatchesCheckedInSchema validates against testdata/doctor_healthy.golden.json

Test Results

Test Suite Result
go test -run Doctor ./cmd/mem/... 14 tests PASS
go test ./cmd/mem/... PASS (0.680s)
go test ./internal/redact/... PASS
go test ./internal/apiclient/... PASS (0.398s)
go test ./... (full server) All packages PASS
gofmt -l . PASS (no formatting issues)
go vet ./... PASS

Manual Verification

Text output (unreachable server):

mem doctor (mem.doctor v1)
server: http://127.0.0.1:1
server_reachability  fail     cannot reach the configured server: ...
                              hint: start the documented container path: deploy/compose, see docs/DEPLOYMENT.md
credential           fail     no token configured
                              hint: run `mem auth login` first; ...
workspace            skipped  not evaluated: server_reachability is failing
version_skew         skipped  not evaluated: server_reachability is failing
Exit code: 5

JSON output: Correctly structured with contract: "mem.doctor", schema_version: 1, fixed check order, and proper exit codes.

Credential redaction test:

  • Input: admin:password123@mem.internal:8787 (schemeless URL)
  • Result: 0 occurrences of "password123" in output - credential properly withheld

Key Implementation Details

  1. Shared redaction gate (internal/redact): One policy used by CLI, apiclient, and memd
  2. Withhold vs redact: URLs that cannot be proven safe are withheld entirely, not partially trimmed
  3. Exit codes follow SPEC §7.1: 0 ok, 2 not_found, 3 auth, 4 plan/quota, 5 provider/timeout
  4. Skipped checks: Dependent probes report "skipped" with blocker name instead of guessing

Schema and Golden File

  • Schema: docs/schemas/mem-doctor.v1.schema.json (JSON Schema Draft 2020-12)
  • Golden file: server/cmd/mem/testdata/doctor_healthy.golden.json
  • Both validate correctly against the implementation

Notes

pgx reads postgres://host/db?password=... as the real password, so a URL
that parses cleanly with no userinfo is not thereby proven credential-free.
The gate masked userinfo and let the parameter through, which put a database
password in memd's startup log and in three fields of a doctor report.

Every query value now goes to REDACTED and only the parameter names
survive, so a log line still says which settings are on. A fragment has no
name to keep, so such a URL is withheld whole. No key-name blacklist: that
is fail-open.
@waterbro-8

Copy link
Copy Markdown
Collaborator Author

Fixed on e0b7972b4fe5 (one commit, 6 files, +79/-44). The finding is accurate, and one executed number makes it worse than the write-up suggests.

Why this is not a documentable residual. pgx v5's pgxpool.ParseConfig honours ?password= as the real database password; asynq's ParseRedisURI ignores it. I ran both drivers against the shapes in question:

shape driver result
postgres://mem@db.internal:5432/mem?sslmode=require&password=<sentinel> pgx ACCEPT, Config.ConnConfig.Password = <sentinel>
redis://queue.internal:6379/0?password=<sentinel> asynq ACCEPT, Password = ""

So the one shape the userinfo gate lets through is exactly the shape where the value is a live credential. On the binary built from the previous head, memd's own startup line read:

"db":"postgres://REDACTED@db.internal:5432/mem?password=<sentinel>&sslmode=require"

The userinfo half of the gate fired correctly on the same string. mem doctor --server 'http://mem.internal:8787/?password=<sentinel>' echoed the sentinel 3 times in each of the text and JSON reports, which is the number this PR's own body quotes - so the gap was correctly described, just not as something a release can carry a "no secret in this document" claim next to.

What the gate does now. rewrite() blanks every query value to REDACTED and keeps only the parameter names, and withholds the URL whole when a fragment is present (a fragment has no name worth keeping). Deliberately no key-name blacklist: ?secret=/?token=/?apikey= versus a driver-specific spelling is a guess, and a guess here is fail-open. This is the same posture the package already takes for a URL it cannot prove is a credential-free transport URL - withhold rather than partially trim. The re-parse round-trip still runs on the blanked form, so String() re-encoding cannot reintroduce a value.

Text()'s doc paragraph, UserMarker's doc, the top-of-file caveat in cmds_doctor.go, two CHANGELOG.md entries and the docs/DEPLOYMENT.md paragraph that told readers not to trust a doctor report without reading it first are all updated to describe the behaviour rather than the gap.

Evidence, with both sides anchored to a tree sha. The "before" numbers come from a checkout whose git write-tree equals the head commit's tree 9fa9c14de074b4ddb5320d4f83803f1657918b34; the "after" numbers come from the fixed tree 5e3bce7a303f47898aced6f925230620a34f9076, which is byte-identical to the tree this commit now carries on the remote.

  • go test ./... from server/: 32 packages ok, 0 fail. gofmt -l empty, go vet clean.
  • TestQueryAndFragmentCredentialsAreWithheld replaces the characterization test that pinned the leak. Mutation control: deleting the new block turns exactly 2 test functions / 7 assertions red; restoring it goes green again - the test is load-bearing, which the one it replaced could not have been.
  • Same binary, same input, before vs after: mem doctor 3 sentinel occurrences -> 0 in both formats; memd startup log 1 -> 0.
  • rc=5 on an unreachable --server is unchanged, so feat(cli): add mem doctor and first-run guidance that names the documented deploy/compose path #112's R2 exit-code question is untouched by this commit and still open there.

Residual I am not claiming away. Parameter names still egress, so a secret smuggled into a name would still show. No driver I can find treats a name position as a credential, which is why I took the trade rather than withholding every URL that has a query string - ?sslmode=... and friends are what make the startup line diagnosable.

I am the author, so I have not voted on this and this comment carries no approval. Both CHANGES_REQUESTED still stand against the old head - @PeterGuy326, a re-review on e0b7972b4fe5 is what this needs. All 15 check-runs on this exact head completed success, attempt 1.

@PeterGuy326 PeterGuy326 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review result

APPROVE for the current head e0b7972b4fe5951a754e1ca8329e2edcbfba2158.

The follow-up now blanks query values and withholds fragment-bearing URLs. I ran go test ./internal/redact ./cmd/mem locally: both packages passed. The current head has green Go, PostgreSQL integration, HTTP/CLI/MCP, Web, Worker, wrapper-compatibility, CodeQL, dependency-review, and policy checks. This approval supersedes the earlier changes-requested review at the old head.

@PeterGuy326
PeterGuy326 enabled auto-merge (squash) September 10, 2026 16:06
@PeterGuy326
PeterGuy326 merged commit d9d0969 into main Sep 10, 2026
20 checks passed
@PeterGuy326
PeterGuy326 deleted the fix/credential-url-gate branch September 10, 2026 16:14
waterbro-8 added a commit that referenced this pull request Sep 14, 2026
Resolves the only collision, in CHANGELOG.md's [Unreleased] "Fixed" list, by
keeping both entries: this branch's CI Web bullet and main's #165 bullet about
withholding unverifiable URL credentials. No duplicate "### Fixed" heading was
introduced.

main's "### Changed" side won on its own: the branch's "retaining the published
npm scope, MCP identity, and existing cache paths" wording is false after #162
changed mcpName to io.github.bytefolk/mem-mcp, and the merged text no longer
carries that clause. GOVERNANCE.md arrived from main without conflict because
this branch never added one.

This commit is the conflict resolution only; the CI evidence fixes follow.
waterbro-8 added a commit that referenced this pull request Sep 17, 2026
## Canonical requirement

Refs bytefolk/.github#32

- Canonical Issue URL: bytefolk/.github#32
- Consumed revision: R1
- No automatic close keywords: acknowledged

Decision reference: the initial R1 Issue body. It explicitly records
that local candidates preceded this prospective publication record; no
retrospective approval is claimed.

## Requirement trace

| REQ/AC IDs | Changed files / domain | Tests or review evidence |
|---|---|---|
| REQ-001 / AC-001 | 4 exact-pinned version annotations | Exact
expected-byte replacement PASS |
| REQ-002 / AC-002 | 2 files in bytefolk/mem | Repository inventory
PASS; aggregate 7 repositories, 13 files, 21 lines |
| REQ-003 / AC-003 | Existing workflow content and modes | Parsed YAML
and comment-stripped bytes identical |
| REQ-004 / AC-004 | Current-head CI and independent review | Local
independent replay recorded in the canonical R1 Issue linked above;
hosted CI collected on head `f464f686` (19 of 20 checks succeed, see
Validation); independent human review requested and still pending |

## File domains

`.github/workflows/bytefolk-scorecard.yml` (47);
`.github/workflows/bytefolk-security.yml` (58, 65, 68).

Prepared parent / merge base: `2986fe38175f54d99f15dd38a498708c6ecd88cd`
PR base at publication: `87db0dfe0507be2190fe2fdcce0e267be8224f4d`.
Since that baseline `main` advanced by six commits through `3c13f04e`
(#162, #160, #165, #188, #158, #204) — not only `web/package-lock.json`
as previously stated here. None of them touched `.github/workflows/`, so
the F9 workflow blobs and the PR diff are unchanged. The reviewed commit
and original parent are preserved.
Head: `f464f68636adc6bb5295c3818aa6654c46a3caad` — `d2a9ec5` plus one
non-forced `Merge branch 'main'` commit (`f464f686`) that brought the
branch up to `3c13f04e` so it is no longer `BEHIND`. Verified: `git
rev-parse d2a9ec5:.github/workflows/bytefolk-scorecard.yml` and
`...:bytefolk-security.yml` return the same blobs (`2058126c`,
`48a507e0`) as at `f464f686`, and `git diff main...f464f68` is still
exactly these 2 files, `+4/-4`. The comment-only payload is therefore
byte-identical to the reviewed commit and the equality proof above holds
on the current head.

## Scope and non-goals

Correct only `# v4.37.4` to `# v4.37.9` on CodeQL uses-lines pinned to
`cdf488f595d80d6e07e03d4674febd5ab45fa938`. The [official tag
object](https://api.github.com/repos/github/codeql-action/git/tags/a35ac6e6798d72df5475948b28efb89edc2e19ca)
resolves to that existing pin. Action SHAs, permissions, triggers,
steps, matrices, other pins, and runtime code are unchanged.

## Validation

- Exact commands: `ruby evidence/verify.rb --baseline` and `ruby
evidence/verify.rb --committed` from the retained review packet; `git
diff --check 2986fe3
d2a9ec5` from this repository.
- Observed counts/results: PASS 2/2 files and 4/4 replacements here;
aggregate PASS 13/13 files and 21/21 replacements. Baseline
intentionally exits 1 after detecting all 21 stale annotations;
committed verification exits 0.
- Check URLs: collected on head `f464f686` — 19 of 20 checks succeed.
The single failure is [`HTTP, CLI and MCP
lifecycle`](https://github.com/bytefolk/mem/actions/runs/34807101354/job/103861020551),
whose log is `pull access denied for minio/minio` at ~13s: a
container-image pull failure in an unrelated job. The same workflow was
green on `main` at `3c13f04e`, and the identical failure is present on
#198 and #199, so it is not caused by this comment-only change.
Root-cause tracking is separate and open.

The strict verifier checks the changed-file allowlist; exact old blobs
and line inventory; complete expected-byte replacement; absence of stale
target annotations; parsed YAML equality; comment-stripped byte equality
and SHA-256 digests; whitespace and unchanged modes; one commit with the
exact parent; and clean worktrees with no untracked files. All passed.
The independent replay is recorded in canonical R1. The verifier and
inventory are retained outside repository commits.

| ID | REQ/AC | Observable acceptance criterion | Command or manual
steps | Environment | Expected | Observed | Status |
|---|---|---|---|---|---|---|---|
| V1 | AC-001, AC-002, AC-003 | Exact annotations with executable YAML
unchanged | `ruby evidence/verify.rb --committed` | Ruby 2.6.10, Psych
3.1.0, isolated review packet | Exact scoped replacements and equality |
2/2 files; 4/4 lines; all invariants pass | PASS |
| V2 | AC-004 | Hosted checks on this exact head | Inspect this PR's
checks at `f464f686` | GitHub Actions | Applicable checks succeed | 19
of 20 succeed; `HTTP, CLI and MCP lifecycle` fails on `pull access
denied for minio/minio` (infra, unrelated job, also failing on
#198/#199, green on `main`) | PARTIAL |

## Security and compatibility

Documentation annotation only. No dependencies, permissions,
credentials, data flows, or runtime behavior change. The diff and commit
identity were inspected for public-safe content. No CHANGELOG entry or
behavior-documentation update is needed because only explanatory
comments change.

## Known limitations

Runtime suites, build, coverage, and dependency audits were not rerun
for this comment-only change; no runtime test result is claimed. Hosted
CI is separate from local equality proof. Two limits now apply: (1) the
strict verifier's `one commit with the exact parent` invariant describes
the reviewed payload commit `d2a9ec5`, not the current branch shape,
which carries two additional `Merge branch 'main'` commits; (2) this PR
is **not merge-ready yet** — repository `AGENTS.md` step 6 requires
passing CI *and* an approval from someone other than the author, and
`HTTP, CLI and MCP lifecycle` is red on the unrelated `minio` pull, so
the green-CI half is unmet until that infrastructure failure is fixed.

## Risk and rollback

Low-risk annotation correction. Roll back through an ordinary revert of
this single commit. There is no migration or release action.

## Product review handoff

- Implementation/publication owner: @PeterGuy326
- Automated pre-review result: independent local replay recorded in R1;
no human approval implied.
- Human final review: PENDING; no human review requested by this
publication.
- Merge ledger owner: @PeterGuy326
- Product reviewer: @PeterGuy326
- Milestone or release packet: N/A: bounded documentation annotation
maintenance
- Merge, CI, release, and model judgment do not accept or close the
Issue: acknowledged


## Maintenance update (2026-09-14, @waterbro-8)

Records written by the maintainer account, not by the implementation
owner:

- `f464f686 Merge branch 'main'` was pushed to this head branch
(non-forced, `main` at `3c13f04e` is an ancestor of the head) to clear
the `BEHIND` state this PR's own body said blocked merging. No workflow
file content changed: both blobs are identical to `d2a9ec5`.
- The stale facts above were corrected in place: the recorded head SHA,
the "Main advanced only `web/package-lock.json` in PR #192" claim, the
`NOT VERIFIED` hosted-CI rows, and the "this is a draft, not
merge-ready" note.
- This PR was marked ready for review and an independent review was
requested. The maintainer account that pushed the merge commit did
**not** approve it: `AGENTS.md` step 6 requires an approval from someone
other than the author, and a commit author on the head cannot supply
that approval for their own push. `@PeterGuy326` remains implementation
and merge-ledger owner.

Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli Command-line interface area:server Go API, CLI, MCP, storage, or server runtime evidence:e3-reproduced Maintainer reproduced with deterministic steps status:in-progress Implementation is actively in progress type:bug Something is broken or behaves incorrectly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants