Skip to content

fix(cli): scope warning credential stripping to the URL authority - #8137

Closed
LHMQ878 wants to merge 14 commits into
QwenLM:mainfrom
LHMQ878:fix/warning-sanitizer-port-and-at-in-password
Closed

fix(cli): scope warning credential stripping to the URL authority#8137
LHMQ878 wants to merge 14 commits into
QwenLM:mainfrom
LHMQ878:fix/warning-sanitizer-port-and-at-in-password

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Makes the provider warning sanitizer find credentials the same way its sibling already does: by bounding the search to the URL's authority instead of scanning the whole message with indexOf. The bespoke credential heuristics are deleted and each URL-looking span is handed to sanitizeProviderBaseUrl, which already computes where the authority ends and takes lastIndexOf('@') inside it. Net deletion of production logic, no new helper.

Why it's needed

sanitizeProviderWarningSegment located the userinfo with indexOf(':', markerLength) and indexOf('@', markerLength) over the entire span, with no idea where the authority stopped. That produced two distinct failures.

A port made it delete the text after the URL. The : before the port was read as the start of a password, so any later @ in the message was taken as the end of the userinfo and everything between them was cut:

in : Cannot reach https://api.example:8443/v1 — contact admin@example.com
out: Cannot reach https://example.com

The port, the path, the em-dash and contact are all gone. Any warning naming a URL with an explicit port and containing a later @ — an email address, an npm scope, a user@host — is mangled this way. This is not over-redaction; the message the user is shown is factually wrong about which endpoint failed.

An @ inside the password made it leak the rest of the password. indexOf('@') found the one inside the password, so the cut landed too early:

in : Failed loading provider https://user:p@ssw0rd-tail@broken.example/v1
out: Failed loading provider https://ssw0rd-tail@broken.example/v1

sanitizeProviderBaseUrl gets both of these right already — it bounds the search with findAuthorityEnd and falls back to a port check for inputs new URL() rejects. The warning path only reached it in the fallback branch, which the heuristics pre-empted.

Both call sites are live in the /status provider: workspace-providers-status.ts:207 for every resolvedCliConfig.warnings entry, and the catch at :223 for any ModelsConfig construction error.

The span-splitting loop is kept deliberately. It is what allows a password containing a space or a quote to be found at all — those are legal in userinfo but excluded by any [^\s'"]-style URL pattern, so a single global regex cannot match them. The existing https://user:p ass@… test case depends on this.

Reviewer Test Plan

How to verify

npx vitest run packages/cli/src/serve/workspace-providers-status.test.ts
npx vitest run packages/cli/src/utils/acpModelUtils.test.ts

To see the bugs, revert workspace-providers-status.ts alone and keep the tests:

git stash push -- packages/cli/src/serve/workspace-providers-status.ts
npx vitest run packages/cli/src/serve/workspace-providers-status.test.ts

Two of the three added cases fail, with exactly the outputs quoted above. The third (strips credentials from every URL in a message and leaves the rest intact) passes on main too — it is there as a guard, not as a repro.

Evidence (Before & After)

Not user-visible in the TUI; this is the /status JSON payload. Behaviour measured directly:

input main this PR
Cannot reach https://api.example:8443/v1 — contact admin@example.com Cannot reach https://example.com (unchanged input)
Set https://registry.example:4873/ then install @scope/pkg Set https://scope/pkg (unchanged input)
Failed loading provider https://user:p@ssw0rd-tail@broken.example/v1 …https://ssw0rd-tail@broken.example/v1 …https://broken.example/v1
Auth failed for https://user:p ass@api.example/v1 Auth failed for https://api.example/v1 same — no regression
Invalid baseUrl "https://api.example/v1" — contact admin@example.com unchanged unchanged

Test results:

  • workspace-providers-status.test.ts22 pass (19 existing + 3 added).
  • acpModelUtils.test.ts31 pass, untouched; sanitizeProviderBaseUrl's own behaviour does not change.
  • Control with only the source reverted — 2 fail / 20 pass.
  • npx eslint … --max-warnings 0 — clean. npx prettier --check — clean.
  • tsc -p packages/cli/tsconfig.json --noEmit — no errors in the touched file. The pre-existing errors on this checkout are stale-dist resolution failures in unrelated packages (acpAgent.ts, Session.test.ts, …), identical before and after.
  • grep confirms nothing else referenced sanitizeProviderWarningSegment, hasCredentialPrefix, or URL_LIKE_PATTERN.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ✅ tested
🐧 Linux ⚠️ not tested

Environment (optional)

Unit tests only, via npx vitest run. No platform-specific code in the diff.

Risk & Scope

  • Main risk or tradeoff: messages that previously had text deleted will now retain it. That is the intended fix, but if any consumer was parsing the truncated form it would see a change — I found none; both call sites pass the result straight into the status payload as a string.
  • Not validated / out of scope: sanitizeProviderBaseUrl itself. Its own gaps are separate and untouched here — notably it leaves a URL alone when a scheme is not at position 0 (a leading space defeats the ^-anchored match) and it does not redact credentials passed as query parameters (?api-key=…). Neither is reachable from the two warning call sites in a way this change affects, so I have kept them out. Happy to file them separately if useful.
  • Breaking changes / migration notes: none. Private functions only; no exported API change.

Linked Issues

Closes #8136

`sanitizeProviderWarningSegment` located the userinfo with `indexOf(':')` and
`indexOf('@')` across the whole span, with no idea where the authority ended.
Two consequences:

- A port was read as the start of a password, so a later `@` anywhere in the
  message was taken as the end of the userinfo and everything between them was
  deleted. `Cannot reach https://api.example:8443/v1 — contact admin@example.com`
  became `Cannot reach https://example.com`.
- `indexOf('@')` found the one inside a password, cutting too early and emitting
  the rest of it: `https://user:p@ssw0rd-tail@host/v1` kept `ssw0rd-tail`.

`sanitizeProviderBaseUrl` already gets both right — it bounds the search with
`findAuthorityEnd` and takes `lastIndexOf('@')` inside the authority, with a port
check for inputs `new URL()` rejects. Delegate to it and delete the bespoke
heuristics.

The span-splitting stays: it is what lets a password containing a space or a
quote be found at all, since no `[^\s'"]`-style URL pattern can match one.

Closes QwenLM#8136
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 30, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — and for the thorough issue write-up in #8136.

Template looks good ✓

Problem: observed bug with a clear reproduction. The issue includes before/after output for both failure modes (port-induced truncation and @-in-password leak), names the exact call sites, and explains why the sibling function already handles these cases correctly. This is not theoretical.

Direction: aligned. Credential sanitization in the /status payload is a security-adjacent correctness concern, and the fix reuses the authority-scoped logic that already exists in sanitizeProviderBaseUrl rather than adding anything new.

Size: not applicable — no core paths touched. 43 production lines (10 added, 33 deleted), 57 test lines. Net deletion of production logic.

Approach: the scope feels right. Deleting the bespoke sanitizeProviderWarningSegment / hasCredentialPrefix heuristics and calling sanitizeProviderBaseUrl directly is the minimal fix — the function was already imported and used in the fallback branch. The span-splitting loop is correctly kept (it handles passwords with spaces/quotes that a URL regex can't match). No unrelated changes in the diff.

Risk: no elevated risk signals — no high-risk paths matched.

Moving on to code review. 🔍

中文说明

感谢贡献!也感谢 #8136 中详尽的问题描述。

模板完整 ✓

问题:已观测到的 bug,有明确的复现。issue 中包含了两种故障模式(端口导致截断、密码中含 @ 导致泄露)的 before/after 输出,指出了确切的调用点,并解释了为什么兄弟函数已经能正确处理这些情况。这不是理论性问题。

方向:对齐。/status 载荷中的凭据脱敏是安全相关的正确性问题,修复方案复用了 sanitizeProviderBaseUrl 中已有的 authority 范围限定逻辑,没有新增任何内容。

规模:不适用——未触及核心路径。生产代码 43 行(新增 10 行,删除 33 行),测试 57 行。生产逻辑净删除。

方案:范围合理。删除自定义的 sanitizeProviderWarningSegment / hasCredentialPrefix 启发式逻辑,直接调用 sanitizeProviderBaseUrl,是最小修复——该函数已被导入并在回退分支中使用。span 分割循环被正确保留(用于处理密码中含空格/引号的情况,URL 正则无法匹配)。diff 中无无关改动。

风险:无升级风险信号——未匹配高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at f0e21ccebd011627a423554ed60e0010e2737813 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal first: given a bug where sanitizeProviderWarningSegment uses unbounded indexOf(':') / indexOf('@') over the whole span, the minimal fix is to delete the bespoke heuristics and call sanitizeProviderBaseUrl directly on each span — it already computes findAuthorityEnd and takes lastIndexOf('@') within the authority. The function is already imported and used in the fallback branch. The span-splitting loop must stay because it handles passwords with spaces/quotes that a single URL regex can't match.

The PR does exactly this. No simpler path exists.

Traced the logic through all three new test cases:

  • Port, no credentials (https://api.example:8443/v1 — contact admin@example.com): findAuthorityEnd stops at the / after 8443, so the authority is api.example:8443. No @ in the authority → new URL() fails on the trailing prose → fallback checks the @ in the email but the port check (/^\d+$/ on 8443) returns -1 → URL left unchanged. ✓
  • @ in password (https://user:p@ssw0rd-tail@broken.example/v1): lastIndexOf('@') within the authority finds the last @ (before broken.example), so the entire userinfo user:p@ssw0rd-tail is stripped. ✓
  • Multi-URL message: span-splitting correctly isolates each URL, and each is sanitized independently. The email admin@example.com after the second URL is untouched because it's outside any span. ✓

No correctness issues, no security concerns, no convention violations. The diff is a net deletion of production logic with no new abstractions.

Testing

Check Conclusion
Qwen Code CI action_required (fork PR — workflow not yet approved)
Serve A/B action_required
SDK Java action_required
precheck-pr / precheck success
label success
authorize success

The main CI suite (Qwen Code CI) has not executed — fork PRs require a maintainer to approve the workflow run first. The action_required conclusion means the run completed without starting. No test results are available from CI.

The author reports 22 pass in workspace-providers-status.test.ts (19 existing + 3 added) and 31 pass in acpModelUtils.test.ts — this is the author's claim, not independently verified.

Sandboxed verification would settle this: @qwen-code /verify — that the two new sanitization behaviours (port-bearing URL left intact, @-in-password fully stripped) actually hold is not observable from the diff alone, and CI has not run. This is a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and the run carries a pre-execution risk screen plus a full workspace wipe. Read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

独立方案:既然 bug 在于 sanitizeProviderWarningSegment 对整个 span 使用无界的 indexOf(':') / indexOf('@'),最小修复就是删除自定义启发式逻辑,直接对每个 span 调用 sanitizeProviderBaseUrl——它已经通过 findAuthorityEnd 计算 authority 边界并在其中使用 lastIndexOf('@')。该函数已被导入并在回退分支中使用。span 分割循环必须保留,因为它处理密码中含空格/引号的情况,单一 URL 正则无法匹配。

PR 正是这样做的。不存在更简路径。

手动追踪了三个新测试用例的逻辑,均正确。无正确性问题、无安全隐患、无规范违反。diff 是生产逻辑的净删除,无新抽象。

测试

主 CI 套件(Qwen Code CI)尚未执行——fork PR 需要维护者先批准工作流运行。action_required 结论意味着运行未完成启动。CI 无测试结果。

作者报告 workspace-providers-status.test.ts 22 通过(19 现有 + 3 新增),acpModelUtils.test.ts 31 通过——这是作者的声明,未经独立验证。

沙盒验证可以确认:@qwen-code /verify——两个新的脱敏行为(含端口的 URL 保持不变、密码中的 @ 被完整剥离)是否真正成立,仅从 diff 无法观察,且 CI 尚未运行。这是赞助运行:维护者的 @qwen-code /verify 评论会批准其对应的 head,运行带有执行前风险筛查和完整工作区清除。请以与 fork CI 日志相同的审慎态度阅读生成的报告。

Qwen Code · qwen3.8-max-preview

Reviewed at f0e21ccebd011627a423554ed60e0010e2737813 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix that reuses existing well-tested logic; only gap is that CI hasn't executed yet (fork workflow approval pending).

This is what a good bugfix looks like: the problem is real and reproduced, the fix deletes more code than it adds, and the correct logic already existed in the codebase — the PR just wires it up. I traced the authority-bounded lastIndexOf('@') through all three new test cases by hand and the behaviour is correct in each. The span-splitting loop is the right thing to keep; a single URL regex would regress the existing space-in-password case.

The only reservation is procedural: the main CI suite hasn't run because fork workflow runs need a maintainer's approval first. The code review is clean enough that I'm approving now, but a maintainer should still approve the CI run and confirm green before merging.

中文说明

置信度:4/5 ——干净、最小的修复,复用了已有的经过充分测试的逻辑;唯一的缺口是 CI 尚未执行(fork 工作流等待批准)。

这是一个好的 bugfix 应有的样子:问题真实且有复现,修复删除的代码比新增的多,正确的逻辑已经存在于代码库中——PR 只是将其接入。我手动追踪了 authority 范围限定的 lastIndexOf('@') 在三个新测试用例中的行为,每种情况都正确。span 分割循环是应该保留的;单一 URL 正则会回退现有的密码含空格用例。

唯一的保留是程序性的:主 CI 套件尚未运行,因为 fork 工作流运行需要先获得维护者批准。代码审查足够干净,我现在批准,但维护者仍应批准 CI 运行并在合并前确认全绿。

Qwen Code · qwen3.8-max-preview

Reviewed at f0e21ccebd011627a423554ed60e0010e2737813 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

const segmentEnd = findUrlSegmentEnd(warning, next.index, next.marker);
const segment = warning.slice(next.index, segmentEnd);
result += sanitizeProviderWarningSegment(segment, next.marker.length);
result += sanitizeProviderBaseUrl(warning.slice(next.index, segmentEnd));

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.

[Critical] Passing the full segment (URL + trailing non-URL text) to sanitizeProviderBaseUrl over-strips when the URL has no path component and the trailing text contains @. findAuthorityEnd extends the "authority" to end-of-string when no /, ?, or # exists after the scheme, so lastIndexOf('@') lands on an @ in trailing prose. new URL() succeeds (WHATWG percent-encodes spaces), parsed.username is truthy, and the code strips at that @. This is a regression: the removed hasCredentialPrefix rejected candidates whose "username" span contained spaces, preserving such messages.

Failure scenario: Cannot reach https://api.example.com — contact admin@example.comCannot reach https://example.com — the host mutates from api.example.com to example.com, and the email address and surrounding text are deleted. Confirmed by probe: sanitizeProviderBaseUrl('https://api.example.com — contact admin@example.com') returns 'https://example.com'. All three new tests use URLs with paths (/v1, /v2), so this pathless case is untested.

Suggested change
result += sanitizeProviderBaseUrl(warning.slice(next.index, segmentEnd));
const rawSegment = warning.slice(next.index, segmentEnd);
const urlOnly = rawSegment.match(/^[^\s'"`<>]+/)?.[0] ?? rawSegment;
result += sanitizeProviderBaseUrl(urlOnly) + rawSegment.slice(urlOnly.length);

Alternatively, add a whitespace guard in findAuthorityEnd so the authority stops at the first space — but that would change behavior for the existing (correct) space-in-password handling, so a segment-level fix is safer.

— qwen3.8-max-preview via Qwen Code /review

Handing the whole span to sanitizeProviderBaseUrl over-stripped when the
URL had no path and the trailing text contained an `@`. A pathless URL
has no `/`, `?` or `#` to bound its authority, so findAuthorityEnd ran
to end-of-span and the `@` in an email address was read as the end of a
userinfo:

  Cannot reach https://api.example.com — contact admin@example.com
  -> Cannot reach https://example.com

The host mutated and the rest of the message was deleted. All three tests
added with the delegation used URLs with paths, so the case was untested.

The URL now ends at the first space, except that a recognised
`user:password@` prefix is consumed first — a password may legally contain
spaces, and finding those is why the message is split into spans at all.
Bounding the URL at the first space unconditionally would have leaked
every spaced password, including the one the existing
'sanitizes credentials from provider construction errors' test covers.

The prefix pattern rejects `host:8443 ` via a negative lookahead, so a
port is still not read as the start of a password.
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 98cc5be. The Critical is real — I reproduced it before touching anything, and confirmed your probe output exactly:

"https://api.example.com — contact admin@example.com"   -> "https://example.com"
"https://api.example.com:8443 — contact admin@example.com" -> "https://example.com"
"https://api.example.com/v1 — contact admin@example.com" -> unchanged   <-- why the 3 new tests missed it

Your diagnosis of the mechanism is right: with no /, ? or # after the scheme, findAuthorityEnd runs to end-of-span, lastIndexOf('@') lands in the email, new URL() succeeds because WHATWG percent-encodes the spaces, parsed.username is truthy, and the strip happens there. And you're right that this is a regression the deleted hasCredentialPrefix was accidentally guarding against — it rejected any "username" span containing whitespace.

I did not take the suggested diff, because it leaks passwords. Bounding the span at /^[^\s'"<>]+/reintroduces exactly what the span-splitting exists to prevent — a password containing a space stops the match before the@, so sanitizeProviderBaseUrl` never sees a credential and the raw tail is re-appended verbatim. I ran it against the existing tests:

=== bot suggestion ===
XX  "Failed loading provider https://user:sec ret@broken.example/v1"
      got  "Failed loading provider https://user:sec ret@broken.example/v1"    <-- password intact
      want "Failed loading provider https://broken.example/v1"
XX  "Moving from https://user:sec ret@a.example/v1 to https://user:other@b.example:8443/v2; see admin@example.com"
      got  "Moving from https://user:sec ret@a.example/v1 to https://b.example:8443/v2; see admin@example.com"
XX  "Failed loading provider https://user:sec ret@broken.example"
      got  "Failed loading provider https://user:sec ret@broken.example"
failures: 3

The first of those is the pre-existing sanitizes credentials from provider construction errors test, so it would have gone red in CI. Trading an over-redaction for a credential leak is the wrong direction, and this is the sanitizer's whole job.

What I did instead — same idea (bound the URL before handing it over), but the bound consumes a recognised credential prefix first:

const CREDENTIAL_PREFIX_PATTERN = /^[^\s/?#'"`<>]+:(?!\d+(?:\s|$))[^/?#]*@/;

function findUrlEnd(segment: string, markerLength: number): number {
  const credentials = CREDENTIAL_PREFIX_PATTERN.exec(segment.slice(markerLength));
  const from = credentials ? markerLength + credentials[0].length : markerLength;
  const space = segment.slice(from).search(/\s/);
  return space === -1 ? segment.length : from + space;
}

So the URL ends at the first space after any user:password@. The negative lookahead is what keeps your other point intact: host:8443 is rejected, so a port still isn't read as a password start — a colon in the authority followed by whitespace is only ever a port.

I also considered your alternative of adding a whitespace guard inside findAuthorityEnd, and agree it's the wrong place: sanitizeProviderBaseUrl is called directly elsewhere with a bare base URL, where a space-bearing password is legitimate, so the guard belongs at the segment level where the prose actually is.

Verification. 56 tests pass across workspace-providers-status.test.ts and acpModelUtils.test.ts, up from 53. Three new cases: pathless URL + trailing email, same with a port, and — the counterpart that pins the fix doesn't over-narrow — a spaced password on a pathless URL followed by prose. Stashing only the source file makes the first two fail and the rest pass; the third passes either way by design, since it guards the fix rather than the bug.

Beyond the suite I ran 19 probe cases split into 9 must-strip and 10 must-preserve, including https://user:p@ss word@broken.example/v1, https://user:tok en@broken.example?x=1, two spaced-password URLs in one message, and https://example.com:443 then admin@x.com — 19/19. prettier --check and tsc -p packages/cli are clean. Fast-forward push, no force-push.

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

* a password — that is the only way a colon in the authority can be followed by
* whitespace and still not be credentials.
*/
const CREDENTIAL_PREFIX_PATTERN = /^[^\s/?#'"`<>]+:(?!\d+(?:\s|$))[^/?#]*@/;

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.

[Suggestion] The greedy [^/?#]*@ matches to the LAST @ in the span. When a pathless URL carries credentials AND is followed by prose containing an @ (e.g. an email address), the credential prefix consumes the prose, findUrlEnd returns the whole segment, and sanitizeProviderBaseUrl strips at that last @ — deleting the prose and rewriting the host. — Failure scenario: Cannot reach https://user:pass@host.com — contact admin@example.comCannot reach https://example.com (host mutates host.comexample.com, the prose — contact admin@ is deleted); the pre-diff first-@ code produced the correct Cannot reach https://host.com — contact admin@example.com. Credentials are still stripped (no leak), but this is a message-mangling regression of the exact class this PR sets out to fix, and the added pathless tests cover only the no-credential case. Confirmed by probe.

Suggested change
const CREDENTIAL_PREFIX_PATTERN = /^[^\s/?#'"`<>]+:(?!\d+(?:\s|$))[^/?#]*@/;
const CREDENTIAL_PREFIX_PATTERN = /^[^\s/?#'"`<>]+:(?!\d+(?:\s|$))[^/?#]*?@/;

— qwen3.8-max-preview via Qwen Code /review

* a password — that is the only way a colon in the authority can be followed by
* whitespace and still not be credentials.
*/
const CREDENTIAL_PREFIX_PATTERN = /^[^\s/?#'"`<>]+:(?!\d+(?:\s|$))[^/?#]*@/;

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.

[Suggestion] The negative lookahead (?!\d+(?:\s|$)) rejects any colon + digits + whitespace as a port, including a legitimate credential whose password starts with digits followed by a space — so the password is never stripped and leaks into the status payload. — Failure scenario: a provider base URL https://user:123 secret@host.com/v1 in a ModelsConfig error message → the pattern fails to match (123 + space trips the port heuristic), findUrlEnd cuts the URL at the space after user:123, sanitizeProviderBaseUrl parses https://user:123 as host user port 123 with no userinfo, and the prose secret@host.com/v1 is appended verbatim — the password 123 secret is shown in the UI (confirmed by probe). The pre-diff code (hasCredentialPrefix + first-@) stripped this to https://host.com/v1, so this is a credential-leak regression; the comment's premise that a port is "the only way" a colon in the authority can be followed by whitespace without being credentials is not correct. The trigger is a rare password shape (hence Suggestion, not Critical), but please consider tightening the lookahead so it rejects digits only when no @ follows before the next //?/#, and/or correct the comment and add a test documenting the gap.

— qwen3.8-max-preview via Qwen Code /review

…diness

Both Suggestions on the previous head are real; a probe reproduced each before
anything changed, and turned up a third case of the same shape.

The `@` that ends the userinfo was being chosen by the tail's greediness, and
neither setting is correct. A greedy tail runs past the host into an `@` in
trailing prose, so `https://user:pass@host.example — contact admin@example.com`
became `https://example.com`: credentials stripped, but the prose deleted and
the host rewritten from it. Making the tail lazy fixes that case and breaks the
mirror image — it stops at an `@` inside the password, so
`https://user:p@s s@broken.example/v1` leaves `s s@` behind in the message. The
delimiter is therefore chosen structurally instead: the `@` must be followed by
something a provider base URL can address — a dotted name, a bracketed IPv6
literal, or `localhost`, with an optional port. A bare single label is excluded
so the `s` in `p@s s@host.example` cannot pass for a host.

The port guard was also too eager. `user:123 secret@host` is locally identical
to `host:8443 ` — colon, digits, space — so rejecting the latter rejected the
former and the password `123 secret` reached the status payload verbatim. The
digits now count as a port only at end of span, or when another space follows
before the `@`, which is prose rather than a one-space password. The comment
claiming a port is "the only way a colon in the authority can be followed by
whitespace and still not be credentials" was wrong and is gone.

Both halves are load-bearing: dropping the port guard and keeping only the host
lookahead re-breaks the four pathless-URL-plus-port cases, including the one the
previous round's Critical was about.

46 probe cases pass, covering every combination of {path, no path}, {port, no
port}, {credentials, none}, {password containing a space, an `@`, both, leading
digits} and {trailing prose with an email, without}. 60 tests pass across
workspace-providers-status.test.ts and acpModelUtils.test.ts.

Two of the four added tests fail without the source change; the other two pass
either way and are there to pin that the new delimiter rule did not narrow what
already worked — `p@s s@` was handled correctly by the greedy tail, and the
localhost case checks the port heuristic still holds for a host that is not a
dotted name.

Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both Suggestions are real. Fixed in da101de. I reproduced each against 98cc5be before changing anything, and the probe turned up a third case of the same shape that neither review named.

input                                            98cc5be output
"…https://user:pass@host.example — contact admin@example.com"
                                              -> "…https://example.com"          <-- Suggestion 1
"https://user:123 secret@host.example/v1"     -> unchanged (password leaks)      <-- Suggestion 2
"https://user:123 456@host.example/v1"        -> unchanged (password leaks)      <-- also broken
"https://user.name:123 secret@host.example/v1"-> unchanged (password leaks)      <-- also broken

I did not take the suggested *?, because greediness is the wrong axis. The lazy tail fixes Suggestion 1 and breaks the mirror image: it stops at the first @, which is inside the password when the password contains one.

candidate probe result
98cc5be greedy 38/46 — prose deleted + host rewritten
suggested *? lazy 38/46 — https://user:p@s s@broken.example/v1https://s s@broken.example/v1, password fragment survives
lazy + fixed port guard 43/46 — same p@s s leak remains
da101de 46/46

The p@s s case is one the greedy tail at 98cc5be already handled correctly, so switching to lazy would have traded one leak for another rather than removing one.

So the delimiter is now chosen structurally — the @ must be followed by something a provider base URL can actually address:

const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s]|$)`;

A bare single label is deliberately excluded, or the s in p@s s@host.example would pass for a host and end the userinfo one @ too early.

On Suggestion 2, your correction of my comment is right and I've removed the claim. I wrote that a port is "the only way a colon in the authority can be followed by whitespace and still not be credentials". It isn't — user:123 secret@host is locally identical to host:8443 , and my guard rejected the password along with the port. The digits now count as a port only at end of span, or when a further space follows before the @:

(?!\d+(?:$|\s(?:[^@\s]*\s)))

That distinguishes prose (:8443 — contact admin@…, two spaces before the @) from a one-space password (:123 secret@host, one).

Both halves are load-bearing — I checked rather than assuming. Dropping the port guard and keeping only the host lookahead re-breaks four pathless-URL-plus-port cases, including the one last round's Critical was about:

without port guard: "https://api.example.com:8443 — contact admin@example.com" -> "https://example.com"

Verification. 46 probe cases across every combination of {path, no path} × {port, no port} × {credentials, none} × {password with a space, an @, both, leading digits} × {trailing prose with an email, without} — 46/46. Suite: 60 passed across workspace-providers-status.test.ts (29) and acpModelUtils.test.ts (31), was 56. prettier --check clean, eslint clean, tsc --noEmit -p packages/cli exit 0.

Two of the four added tests are regression tests; two are not, and I'd rather say so than imply otherwise. Stashing only the source file and re-running:

× keeps the host when a pathless URL carries credentials and prose has an email
× strips a password that begins with digits and a space
✓ strips a password containing both an at sign and a space
✓ keeps a pathless URL on localhost with a port and the email after it intact
2 failed | 27 passed

The two that fail are the two Suggestions. The two that pass either way pin that the new delimiter rule did not narrow what already worked — p@s s@ was correct under the greedy tail, and the localhost case checks the port heuristic still holds for a host that is not a dotted name (it is also the case that rules out "just require a dot before the colon" as a simpler fix).

* literal, or `localhost`, with an optional port. A bare single label is not
* included, so the `s` in `p@s s@host.example` cannot pass for one.
*/
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s]|$)`;

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.

[Critical] HOST_AFTER_USERINFO excludes bare single-label hosts (other than localhost), so CREDENTIAL_PREFIX_PATTERN never recognises a credential prefix before a single-label host. This causes two distinct failures:

(A) Credential leak — when the password contains a space, findUrlEnd falls back to cutting at the first space after the scheme marker (inside the password), and sanitizeProviderBaseUrl receives a truncated https://user:pass fragment with no @, which it returns unchanged. The full credentials appear in the output.

(B) Message corruption — when a pathless credentialed URL with a single-label host is followed by prose containing an email, the lazy [^/?#]*? crosses the space into the prose and selects the email's @, so sanitizeProviderBaseUrl strips there — rewriting the host and deleting the prose.

— Failure scenario A: Failed https://user:my password@internalhost/v1 → credentials fully visible in the status payload. The removed indexOf('@') code stripped this correctly.
— Failure scenario B: Cannot reach https://user:pass@intranet — contact admin@example.comCannot reach https://example.com — the oncall sees a wrong server name and the contact line is deleted.

Single-label hosts are common for intranet proxies, ollama, redis, and k8s service names. Confirmed by probe.

Suggested change
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s]|$)`;
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d][A-Za-z\d-]*(?:\.[A-Za-z\d-]+)*)(?::\d+)?(?:[/?#\s]|$)`;

This adds [A-Za-z\d][A-Za-z\d-]*(?:\.[A-Za-z\d-]+)* — a label starting with an alphanumeric, optionally followed by dotted labels — which matches both single-label hosts (intranet, ollama) and dotted names, while the two-character minimum ([A-Za-z\d][A-Za-z\d-]*) still rejects the one-character s in p@s s@host.example.

— qwen3.8-max-preview via Qwen Code /review

* must be followed by something addressable as a host.
*/
const CREDENTIAL_PREFIX_PATTERN = new RegExp(
String.raw`^[^\s/?#'"\`<>]+:(?!\d+(?:$|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,

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.

[Critical] The port-detection negative lookahead (?!\d+(?:$|\s(?:[^@\s]*\s))) only rejects digits followed by end-of-string or space-word-space. Digits followed by punctuation (comma, semicolon, paren) pass through and are treated as the start of a password, so the regex consumes prose up to the next @ and rewrites the host from an email address.

— Failure scenario: Cannot reach https://api.example:8443, contact admin@example.com\d+ matches 8443 but the next char is , (not $ or \s), so the lookahead succeeds and credential detection proceeds. [^/?#]*?@ expands through 8443, contact admin to the email's @, and HOST_AFTER_USERINFO matches example.com. Output: Cannot reach https://example.com — the real host api.example:8443 and the contact prose are both lost. Confirmed by probe.

The existing tests use :8443/v1 (slash stops the match) and :8443 — (space triggers the \s branch), so neither covers punctuation immediately after port digits.

Suggested change
String.raw`^[^\s/?#'"\`<>]+:(?!\d+(?:$|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,
String.raw`^[^\s/?#'"\`<>]+:(?!\d+(?:$|[^\s\d]|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,

Adding [^\s\d] to the lookahead rejects digits immediately followed by any non-space, non-digit character (comma, period, semicolon, paren, etc.), which covers sentence punctuation while still accepting user:123 secret@host (space after digits, not punctuation).

— qwen3.8-max-preview via Qwen Code /review

* literal, or `localhost`, with an optional port. A bare single label is not
* included, so the `s` in `p@s s@host.example` cannot pass for one.
*/
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s]|$)`;

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.

[Critical] HOST_AFTER_USERINFO's trailing delimiter (?:[/?#\s]|$) rejects common sentence punctuation (., ,, ;, !, )) after an otherwise-valid dotted host, so the credential-prefix lookahead fails when a URL ends a sentence — leaking spaced passwords and corrupting messages even for fully valid dotted hosts. This is a distinct defect from the single-label host exclusion (different part of the same regex; fixing one does not fix the other).

— Failure scenario A (credential leak): Failed https://user:my pass@host.example. Retry later → the trailing . after host.example is not in [/?#\s] and not $, so the lookahead fails at the only @. findUrlEnd falls back to the first space, cutting at https://user:my; credentials fully visible. Confirmed by probe.
— Failure scenario B (message corruption): Cannot reach https://user:pass@host.example. Contact admin@example.com → the lazy [^/?#]*? skips the first @ (trailing . fails the lookahead) and lands on the email's @; output is Cannot reach https://example.com. Confirmed by probe.

A trailing period is one of the most common characters in prose error messages, yet the existing tests only use space-delimited hosts (host.example — retry).

Suggested change
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s]|$)`;
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|localhost|[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)+)(?::\d+)?(?:[/?#\s.,;:!?\])}]|$)`;

Widening the trailing delimiter class to include common sentence/clause punctuation lets the lookahead succeed when a URL ends a sentence. The punctuation is not consumed (it's inside a lookahead), so it does not affect the host match itself.

— qwen3.8-max-preview via Qwen Code /review

…contains

The userinfo delimiter is chosen by what follows the `@` -- it must be
something addressable as a host -- so whatever that host pattern fails to
recognise, the sanitizer fails to sanitize. Two omissions in it, plus one
in the port guard, each producing both a credential leak and a corrupted
message:

A host was required to contain a dot, so a bare label was not one. Those
are exactly the hosts an intranet has: a proxy name, a container name like
`ollama`, a k8s service. `https://user:my password@internalhost/v1` left
the credentials fully visible, because with the `@` unrecognised the URL
was cut at the first space -- inside the password. Pathless, the lazy tail
instead ran into the prose and stripped at an email's `@`, turning
`https://user:pass@intranet - contact admin@example.com` into
`https://intranet` with the contact line deleted.

What may follow a host was enumerated as `[/?#\s]|$`, which does not
include a period, so a URL ending a sentence was not recognised either --
the most ordinary shape an error message has. Replaced with a lookahead
for anything a host cannot continue into, so the list cannot be
incomplete again; the trailing dot is left to the sentence.

A port was only recognised at end of span or before a space, so the comma
in `:8443, contact` made `8443` read as the start of a password and the
tail found the email's `@`. Digits followed by a non-space are now a port.

Five tests added, one per confirmed failure. Reverting the source file
fails exactly those five and leaves the other 29 passing.
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

All three were real, and all five scenarios reproduce. Fixed in 7ab462b.

I ran every one of them against the actual code before changing anything, since a claim about a regex is cheap to make and cheap to check. Temporarily exporting sanitizeProviderWarning and feeding it the five inputs plus nine cases the current tests cover:

C1-A leak      LEAK   "Failed https://user:my password@internalhost/v1"
C1-B corrupt          "Cannot reach https://example.com"
C2 corrupt            "Cannot reach https://example.com"
C3-A leak      LEAK   "Failed https://user:my pass@host.example. Retry later"
C3-B corrupt          "Cannot reach https://example.com"

Two full credential leaks, three messages where the host is replaced by a domain the user never configured and the following prose is deleted. After the fix, with the same probe:

C1-A leak             "Failed https://internalhost/v1"
C1-B corrupt          "Cannot reach https://intranet — contact admin@example.com"
C2 corrupt            "Cannot reach https://api.example:8443, contact admin@example.com"
C3-A leak             "Failed https://host.example. Retry later"
C3-B corrupt          "Cannot reach https://host.example. Contact admin@example.com"

and all nine regression cases (:8443/v1, :8443 —, spaced password, digit-leading password, p@ss@, email-only, p@s s@, IPv6, localhost) unchanged.

The common cause, which is why I didn't take the three suggestions verbatim

These are three symptoms of one thing. The @ is chosen by what follows it, so HOST_AFTER_USERINFO is not a validator — it is the sanitizer's definition of "there is a credential here". Anything it doesn't recognise as a host is a credential it doesn't strip. That makes both halves of it security-relevant, and the trailing-delimiter half was written as an enumeration ([/?#\s]|$), which is the kind of thing that is wrong again the next time someone thinks of a character.

So rather than lengthening the list to [/?#\s.,;:!?\])}], I inverted it: (?![A-Za-z\d-]) — anything a hostname cannot continue into. A trailing period then belongs to the sentence, not the name, and there is no list left to be incomplete.

For the single-label host I used [A-Za-z\d][A-Za-z\d-]+ rather than the suggested [A-Za-z\d][A-Za-z\d-]*(?:\.[A-Za-z\d-]+)*. Two reasons:

  • the suggestion's stated safety property doesn't hold. It's described as having a "two-character minimum ([A-Za-z\d][A-Za-z\d-]*)", but * matches zero, so that alternative matches the single character s — and test('strips a password containing both an at sign and a space') would break. + is what actually enforces the minimum.
  • collapsing dotted and bare hosts into one alternative also loses the case where a dotted host's first label is a single character (a.example, which test redacts every credentialed URL in one message relies on). Keeping them as separate alternatives — [A-Za-z\d](?:[A-Za-z\d-]*\.)+[A-Za-z\d-]+ for dotted, [A-Za-z\d][A-Za-z\d-]+ for bare — allows a.example while still requiring two characters when there's no dot.

localhost is no longer named explicitly; it's covered by the bare-label alternative.

The port lookahead I took as suggested ([^\s\d] added), since the reasoning there is exactly right: digits followed by punctuation are a port, and the space case still has to stay for user:123 secret@host.

Tests

Five added, one per confirmed failure, each commented with what it caught rather than what it asserts. packages/cli/src/serve/workspace-providers-status.test.ts is 34 passed; reverting only the source file fails exactly those 5 and leaves the other 29 green. Prettier, ESLint and tsc --noEmit clean.

One note on the review comments themselves, since it may be worth knowing: C1 and C3 were filed as separate [Critical]s on the same regex, and C3 says explicitly that "fixing one does not fix the other". That's true as stated, but both suggestions edit the same constant, so applying them in sequence as written would have had the second overwrite the first — C3's suggestion keeps the dot-required host and C1's keeps the narrow delimiter list. Worth landing as one change, which is what this is.

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

* the list being incomplete was not a cosmetic problem. A trailing dot is
* allowed to belong to the sentence rather than the name.
*/
const HOST_AFTER_USERINFO = String.raw`(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z\d](?:[A-Za-z\d-]*\.)+[A-Za-z\d-]+|[A-Za-z\d][A-Za-z\d-]+)(?::\d+)?(?![A-Za-z\d-])`;

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.

[Suggestion] The structured credential detection is deliberately more conservative than the removed indexOf heuristic — which is the right call: bounding the search to the authority is exactly what fixes the host-rewrite and over-strip bugs this PR targets. The tradeoff is that a few pathological shapes now leak that the old code stripped. All are narrow and live in the same-principal display path (credentials from the user's own config, surfaced in their own /status payload), so none is blocking — but they're worth pinning with tests and a one-line doc note so a future tweak doesn't silently move the boundary:

  • A genuine single-character host with a spaced password — Failed https://user:my pass@h/v1 — is no longer stripped: the bare-label alternative needs at least two chars, so @h/ isn't recognised. The removed hasCredentialPrefix stripped this to https://h/v1 (narrow regression).
  • An empty-username URL with a spaced password — Failed https://:my pass@host.example/v1 — isn't recognised either, because the username class on line 291 requires at least one char before :. Also a narrow regression; + to * on that class would close it.
  • A 2+char password fragment can still read as a bare host — https://user:p@ss word@realhost.example/v1 leaks word (pre-existing; the old code leaked it too).
  • The documented port tradeoff (the comment above this constant) has the same root; its two-space variant https://user:123 secret word@host is the untested flip side of the user:123 secret@host case already pinned in the tests.

One caution on the fix space: raising the bare-label minimum to 3+ chars (a natural response to the third bullet) widens the first bullet's leak to 2-char hosts — the length lever trades one leak for another. If short hosts must be supported, a structural signal (e.g. requiring a / or delimiter after the candidate host) is the robust route.

— qwen3.8-max-preview via Qwen Code /review

expect(result.initialized).toBe(false);
});

it('keeps a port and the text after it when the message has no credentials', async () => {

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.

[Suggestion] The bracketed-IPv6 branch of HOST_AFTER_USERINFO (\[[0-9A-Fa-f:.]+\], workspace-providers-status.ts:269) isn't exercised by any of the new tests. It works today — Failed loading provider https://user:pass@[::1]:8443/v1 correctly strips to https://[::1]:8443/v1 — so this is a coverage gap rather than a live bug, but a future edit to that branch would ship undetected. A case alongside these would pin it, e.g.:

it('strips credentials from a bracketed IPv6 base URL', async () => {
  coreMock.throwModelsConfigError = true;
  coreMock.modelsConfigErrorMessage =
    'Failed loading provider https://user:pass@[::1]:8443/v1';
  const provider = createWorkspaceProvidersStatusProvider({ env: {} });
  await writeUserSettings({
    security: { auth: { selectedType: 'openai' } },
    modelProviders: { openai: [{ id: 'model-a', name: 'Model A' }] },
  });

  const result = await provider(workspace, true);

  expect(result.errors?.[0]?.error).toBe(
    'Failed loading provider https://[::1]:8443/v1',
  );
  expect(JSON.stringify(result)).not.toContain('pass');
});

— qwen3.8-max-preview via Qwen Code /review

The bare-label alternative in HOST_AFTER_USERINFO required two characters, so
`https://user:my pass@h/v1` was not recognised as credentialed and the fallback
cut the URL at the first space -- inside the password. Raising or lowering that
floor only moves the leak: at two characters a one-character host leaks, at
three a two-character host does.

The floor is only needed where a label has nothing after it, which is the case
the constant was written for. A label followed by `/`, `?` or `#` is not a word
that happens to look like a host; it is an authority with a path. So the two
cases are now separate constants -- UNDELIMITED_HOST keeps the length rule,
DELIMITED_HOST replaces it with the delimiter -- and `@h/v1` is recognised
without also recognising the `s` in `p@s s@host.example`, whose only boundary
is a space.

Also allows an empty username. `https://:token@host` is a legal URL and the
shape a token-only config produces, but the userinfo class required a character
before the colon, so that password was left in the status payload too.

Two shapes stay unstripped and are now pinned by test rather than left to be
rediscovered: `p@ss word@host.example`, where the password's tail is itself a
valid host, and `123 secret word@host`, where the second space before the `@` is
the same evidence that distinguishes a port from a password. Neither is
decidable from the text, so a test records the behaviour and the comment says
why, which makes a later change to either rule a deliberate one.

Adds a case for the bracketed IPv6 branch, which no test covered.
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Both suggestions taken, in 556ffd4 — and the caution at the end of the first one is what the fix is built on, so thanks for putting it there.

The two narrow leaks are fixed, without the length lever

I probed all four shapes named in the review before touching anything:

LEAK  single-char host + spaced pw   "Failed https://user:my pass@h/v1"
LEAK  empty username + spaced pw     "Failed https://:my pass@host.example/v1"
LEAK  pw with @ then space           "https://ss word@realhost.example/v1"
LEAK  two-space digit pw             "Failed https://user:123 secret word@host.example/v1"

One caution on the fix space: raising the bare-label minimum to 3+ chars widens the first bullet's leak to 2-char hosts — the length lever trades one leak for another. If short hosts must be supported, a structural signal (e.g. requiring a / or delimiter after the candidate host) is the robust route.

That's the whole shape of it. Any floor N leaks hosts of N-1 characters, so I took the structural route: the two-character minimum exists only because a bare label at the end of a span has nothing to distinguish it from a word. A label followed by /, ? or # is not a word that resembles a host — it is an authority with a path.

So the constant splits in two. UNDELIMITED_HOST keeps the length rule for the case it was written for; DELIMITED_HOST drops the floor entirely and requires [/?#] instead. @h/v1 is recognised; the s in p@s s@host.example, whose only boundary is a space, still is not.

The empty-username case was a one-character change (+ to * on the userinfo class, as you noted) and I took it as suggested.

      single-char host + spaced pw   "Failed https://h/v1"
      empty username + spaced pw     "Failed https://host.example/v1"

The other two are recorded rather than fixed

p@ss word@realhost.example and 123 secret word@host are not decidable from the text: the password's tail is a valid host in the first, and in the second the second space before the @ is the same evidence that tells :8443 — contact admin@… from a password. Guessing either way picks a leak or a corruption.

So both now have a test that asserts the current output and a name that says it is not a fix — leaves a password whose tail reads as a host, and says so — plus a paragraph on CREDENTIAL_PREFIX_PATTERN explaining why. That is the "pin with tests and a one-line doc note so a future tweak doesn't silently move the boundary" ask, and it means a later widening has to overwrite an explicit assertion rather than quietly change behaviour.

IPv6 coverage

Added, essentially as written. It is worth having for a reason beyond coverage: the bracketed literal is the one host shape whose own characters include a colon, so a future change to the port or delimiter rules could break it while every other case here stays green.

Verification

39 tests in workspace-providers-status.test.ts (was 34), 70 with acpModelUtils.test.ts. Reverting only the source file fails exactly the two regression tests and leaves the other 37 green:

× strips a spaced password from a URL whose host is a single character
× strips a spaced password when the username is empty
2 failed | 37 passed (39)

The three characterization/coverage tests passing either way is intended — they pin behaviour that already held. prettier --check clean, eslint clean, tsc --noEmit -p packages/cli exit 0.

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

* the list being incomplete was not a cosmetic problem. A trailing dot is
* allowed to belong to the sentence rather than the name.
*/
const UNDELIMITED_HOST = String.raw`(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z\d](?:[A-Za-z\d-]*\.)+[A-Za-z\d-]+|[A-Za-z\d][A-Za-z\d-]+)(?::\d+)?(?![A-Za-z\d-])`;

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.

[Suggestion] A single-character host with a port but no path (@h:8443) falls through both UNDELIMITED_HOST (bare-label branch requires ≥ 2 chars) and DELIMITED_HOST (requires /, ?, or #), so CREDENTIAL_PREFIX_PATTERN fails to recognise the real @ and extends to a later @ in trailing prose, corrupting the message. — Failure scenario: Cannot reach https://user:pass@h:8443 — contact admin@example.comCannot reach https://example.com (wrong host, contact line deleted; credentials stripped, no leak). The old code handled this case via indexOf('@'). The two-char host hh:8443 works correctly; the gap is specifically single-char + port + no path + prose containing @.

Consider adding a port-qualified single-character alternative (e.g. |[A-Za-z\d](?::\d+) before the closing ) of the host alternation), or pinning this case with a test to document the boundary deliberately.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +318 to +320
const CREDENTIAL_PREFIX_PATTERN = new RegExp(
String.raw`^[^\s/?#'"\`<>]*:(?!\d+(?:$|[^\s\d]|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,
);

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.

[Suggestion] The port heuristic's third alternative (\s(?:[^@\s]*\s)) requires two spaces before the @, so a port followed by a single word ending in @ (e.g. :8443 support@example.com) is misread as a password, rewriting the host and deleting the prose. — Failure scenario: Cannot reach https://api.example:8443 support@example.comCannot reach https://example.com. The old deleted code produced the same output (not a regression), but this shape is neither documented in the comment nor pinned by a test, unlike the two analogous tradeoff cases (p@ss word@host.example and 123 secret word@host).

Consider adding a pinning test alongside the existing two, and a sentence to the CREDENTIAL_PREFIX_PATTERN comment naming this shape.

— qwen3.8-max-preview via Qwen Code /review

* so that widening either rule has to choose deliberately.
*/
const CREDENTIAL_PREFIX_PATTERN = new RegExp(
String.raw`^[^\s/?#'"\`<>]*:(?!\d+(?:$|[^\s\d]|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,

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.

[Suggestion] The port-detection lookahead's [^\s\d] alternative treats a digit run followed by any non-space/non-digit (including a letter) as a port, but a real port is pure digits — so a spaced password beginning with \d+[A-Za-z] is misread as a port and its credentials are never stripped. — Failure scenario: Failed https://user:123abc secret@host.example/v1\d+ matches 123, [^\s\d] matches a, the negative lookahead fires, CREDENTIAL_PREFIX_PATTERN returns null, findUrlEnd cuts at the first space, and sanitizeProviderBaseUrl receives https://user:123abc (no @) — username and first password word leaked. The old code stripped at the first @ and produced https://host.example/v1, so this is a regression.

Consider restricting the punctuation alternative so it cannot match a letter continuation — e.g. [^\s\da-zA-Z] — or at minimum pin this shape with a test so the leak is a deliberate, documented tradeoff.

— qwen3.8-max-preview via Qwen Code /review

* the list being incomplete was not a cosmetic problem. A trailing dot is
* allowed to belong to the sentence rather than the name.
*/
const UNDELIMITED_HOST = String.raw`(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z\d](?:[A-Za-z\d-]*\.)+[A-Za-z\d-]+|[A-Za-z\d][A-Za-z\d-]+)(?::\d+)?(?![A-Za-z\d-])`;

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.

[Suggestion] HOST_AFTER_USERINFO uses ASCII-only character classes ([A-Za-z\d]), so a non-ASCII (IDN) host after @ is never recognised; when the password also contains a space, findUrlEnd fails to consume the credential prefix, bounds the URL at the space inside the password, and sanitizeProviderBaseUrl receives a truncated URL with no @ — leaking the username and first password word. — Failure scenario: Failed https://user:my pass@münchen.de/v1CREDENTIAL_PREFIX_PATTERN returns null (ü fails the ASCII host lookahead), output leaks user:my pass. The base code used indexOf('@') with a single-char check and handled IDN hosts whose first character is ASCII, producing https://münchen.de/v1. This is a regression.

Consider widening the host label classes to include Unicode letters/digits (e.g. [\p{L}\p{N}] with the u flag), or adding a fallback in findUrlEnd for authorities containing a : before the first space and a later @.

— qwen3.8-max-preview via Qwen Code /review

Three shapes reached the sanitizer and were not recognised as authorities, so
the credential prefix was not found in them. Two leaked the credentials they
exist to strip; one rewrote the host from an email address in the trailing prose.

A one-character host carrying a port fell through every alternative: the bare
label branch requires two characters and the delimited branch requires a path.
`@h:8443` was therefore not a host, the `@` was taken from the contact line
after it, and `https://user:pass@h:8443 - contact admin@example.com` became
`https://example.com`. A port is the same structural evidence of an authority
that a path is, so it now qualifies a single character the same way.

The port heuristic accepted any non-digit as the character following a port, so
a password beginning `123abc` read as port `123` and was left in the message
whole. A port is digits and nothing else; what closes one is now an explicit set
rather than a negated class, because a negated class also admits `_`, `=` and
`%`, all legal password characters -- `123%abc secret` leaked through the same
gap.

An internationalized host arrives here un-punycoded when it comes from a config
file someone typed, and the ASCII-only classes did not match it. With no
recognised prefix the URL was cut at the space inside the password and
`user:my pass` was displayed.

`:8443 support@example.com` is left as it is and pinned by test instead. A port
followed by exactly one word ending in `@` is character-for-character a
one-space password; the deleted code produced the same output, so this is a
boundary to document rather than a regression to fix.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Five findings from the review on 556ffd4c are addressed in ce14a6d, and one is answered with a test rather than a change. All were reproduced against the head before being fixed.

A one-character host carrying a port was not a host. @h:8443 fell through every alternative -- the bare-label branch requires two characters, the delimited branch requires a path -- so the @ was taken from the trailing prose instead:

in : Cannot reach https://user:pass@h:8443 - contact admin@example.com
out: Cannot reach https://example.com

The real host and the contact line are both gone. A port is the same structural evidence of an authority that a path is, so HOST_CHAR:\d+ is now one of the shapes. hh:8443 already worked, which is what made this look like a length rule when it is not one.

The port heuristic read a letter as the end of a port. [^\s\d] accepted anything non-digit, so user:123abc secret@host parsed as port 123, CREDENTIAL_PREFIX_PATTERN returned null, and the credentials survived. A port is digits and nothing else. Replaced with an explicit closing set (PORT_END), not a narrowed negated class, because a negated class also admits _, = and % -- all legal in a password. 123%abc secret leaked through exactly that gap, so both are now tests.

A non-ASCII host was not recognised. An internationalized host reaches this code un-punycoded when it comes from a config someone typed, and [A-Za-z\d] does not match it. With no prefix found, findUrlEnd cut at the space inside the password and user:my pass was displayed. Host classes are now [\p{L}\p{N}\p{M}] with the u flag. That flag made \`` an invalid escape in the username class, so the backtick is spelled \x60` with a comment saying why.

:8443 support@example.com is left alone and pinned. A port followed by exactly one word ending in @ is character-for-character a one-space password, and as your comment notes, the deleted code produced this same output. Fixing it would require picking one reading with no evidence for either, so it is a documented boundary now -- third in the list on CREDENTIAL_PREFIX_PATTERN, alongside the two that were already there, plus a test asserting the current output.

The earlier findings on f0e21cce, 98cc5be8 and da101de1 -- the over-strip through findAuthorityEnd, the greedy-then-lazy @ selection, the enumerated delimiter list, the single-label host exclusion -- were each fixed in the commit that followed them, which is why those reviews sit on superseded commits.

Verification:

$ npx vitest run packages/cli/src/serve/workspace-providers-status.test.ts \
                 packages/cli/src/utils/acpModelUtils.test.ts
75 passed          (44 + 31)

$ git stash push packages/cli/src/serve/workspace-providers-status.ts   # tests unchanged
$ npx vitest run packages/cli/src/serve/workspace-providers-status.test.ts
4 failed, 40 passed
    keeps a single-character host that carries a port
    strips a spaced password whose first word begins with digits
    strips a spaced password whose first word contains a URL-legal symbol
    strips credentials before a non-ASCII host

$ npx tsc --noEmit -p packages/cli/tsconfig.json
$ npx eslint packages/cli/src/serve/workspace-providers-status{,.test}.ts
(clean)

Four of the five new tests fail without the source change; the fifth is the pinning test, which passes either way by design -- it records a boundary rather than a fix.

The IPv6 coverage gap from the review on 7ab462b4 is also covered, at workspace-providers-status.test.ts:903.

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +569 to +571
coreMock.throwModelsConfigError = true;
coreMock.modelsConfigErrorMessage =
'Cannot reach https://api.example:8443/v1 — contact admin@example.com';

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.

[Suggestion] 25 new tests repeat an identical 7-line setup (set mock flag, set mock message, create provider, write settings, call provider); only the message string and the assertions differ. — Concrete cost: a change to the test setup — a new required field in the settings shape, a change to createWorkspaceProvidersStatusProvider's signature, or a different mock mechanism — requires 25 identical edits instead of one.

Suggested change
coreMock.throwModelsConfigError = true;
coreMock.modelsConfigErrorMessage =
'Cannot reach https://api.example:8443/v1 — contact admin@example.com';
async function sanitizedErrorMessage(message: string) {
coreMock.throwModelsConfigError = true;
coreMock.modelsConfigErrorMessage = message;
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
security: { auth: { selectedType: 'openai' } },
modelProviders: { openai: [{ id: 'model-a', name: 'Model A' }] },
});
return provider(workspace, true);
}

— qwen3.8-max-preview via Qwen Code /review

* legal in a password: `:123%abc secret@host` was read as a port and its
* password left in the message.
*/
const PORT_END = String.raw`[,.;:!?)\]}]`;

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.

[Suggestion] PORT_END omits quote characters (', ", backtick) and opening paren (, so a port followed by a closing quote or parenthetical is not recognised as a port, and the credential pattern treats the digits as the start of a password. — Failure scenario: Cannot reach "https://api.example:8443" — contact admin@example.com → the " after 8443 matches none of $, PORT_END, or \s(…), so the port is not detected; the pattern treats 8443" — contact admin as a password, matches the @ before example.com, and the output becomes Cannot reach "https://example.com — the real host and contact prose are deleted. Similarly Cannot connect https://api.internal:8443(ECONNREFUSED) — contact admin@example.comCannot connect https://example.com. Confirmed by probe.

Suggested change
const PORT_END = String.raw`[,.;:!?)\]}]`;
const PORT_END = String.raw`[,.;:!?)\]}'"(]`;

— qwen3.8-max-preview via Qwen Code /review

A closing delimiter sits exactly where the port heuristic expects a sentence
character or a space, so `"https://api.example:8443"` read `8443"` as the start
of a password, matched the `@` in the prose beyond, and deleted both the real
host and the text after it:

    Cannot reach "https://api.example:8443" - contact admin@example.com
    -> Cannot reach "https://example.com

Adding these characters to PORT_END would fix that case and open a leak, which
is the same trade the `%` case already rejected: `"` is as legal in a password
as `%` is, so `:123"abc secret@host` would then read as a port and keep its
password. Verified by probe before choosing.

Only a *balanced* delimiter is evidence, and that evidence is available where
the span is cut rather than in the pattern: the character before the URL is
already in hand there. So `findUrlSegmentEnd` bounds the span at the matching
closer when an opener precedes the URL, and PORT_END is untouched. The same
character stays inert inside a password, where nothing opened it.

An unbalanced closer is still ambiguous - `:8443(ECONNREFUSED)` has no opening
paren, so it cannot be told from a password beginning `123(`. Documented as a
fourth tradeoff alongside the existing three.

Tests: 6 delimiter pairs pinned, 3 password characters pinned as non-delimiters,
and one case proving a credential inside a quoted URL is still stripped. 4 of
the 11 fail without the change; the `()` and `[]` pairs pass either way because
`)` and `]` are already in PORT_END, so those two guard existing behaviour.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — one of the four is a real bug, and it took a different fix than the suggested one. c7976a3.

The live one: a delimiter after a port destroys the host

Confirmed exactly as described, and it is worse than the two reported cases — every quote and bracket does it:

Cannot reach "https://api.example:8443" - contact admin@example.com
  -> Cannot reach "https://example.com
Cannot reach 'https://api.example:8443' - contact admin@example.com
  -> Cannot reach 'https://example.com
Cannot reach `https://api.example:8443` - contact admin@example.com
  -> Cannot reach `https://example.com
Cannot reach <https://api.example:8443> - contact admin@example.com
  -> Cannot reach <https://example.com

Why I did not take the suggested PORT_END

Widening PORT_END to [,.;:!?)\]}'"(] fixes those three and opens a credential leak, which is the same trade the % case in that comment already rejected. With the suggestion applied verbatim:

--- FIXES
  ok   Cannot reach "https://api.example:8443" - contact admin@example.com
  ok   Cannot connect https://api.internal:8443(ECONNREFUSED) - ...
  ok   Cannot reach 'https://api.example:8443' - ...
--- LEAKS
  LEAK Cannot reach https://user:123"abc secret@host.example/v1
  LEAK Cannot reach https://user:123'abc secret@host.example/v1
  LEAK Cannot reach https://user:123(abc secret@host.example/v1

" is as legal in a password as % is, so a password beginning 123" then reads as a port and survives into the message. Three bugs fixed, three passwords leaked. On the current head all three of those strip correctly.

I also tried and rejected reusing the userinfo exclusion set (['" + backtick + <>]) as the terminator: that set constrains the username before the colon, not the password after it, so it leaked the same three cases.

What the evidence actually is

A closing quote only means "the URL ended" if a matching quote opened it. " on its own says nothing — which is precisely why it cannot live in PORT_END. But a balanced pair does, and that evidence is available where the span is cut rather than in the pattern: sanitizeProviderWarning already has the text before the URL in hand.

So findUrlSegmentEnd takes the preceding text and bounds the span at the matching closer; PORT_END is untouched:

const closer = URL_QUOTE_PAIRS.get(before.slice(-1));
if (closer !== undefined) {
  const closed = value.indexOf(closer, afterMarker);
  if (closed !== -1) lineEnd = Math.min(lineEnd, closed);
}

The same character stays inert inside a password, where nothing opened it. That asymmetry is the whole fix, and it is pinned in both directions.

The (ECONNREFUSED) half stays a tradeoff

https://api.internal:8443(ECONNREFUSED) has no opening paren before the URL, so it is genuinely indistinguishable from a password beginning 123( — I have not fixed that, and I do not think it can be fixed without choosing one reading arbitrarily. Wrapped in a balanced pair it now works; bare it does not. Documented as a fourth tradeoff next to the existing three.

The other three findings

The port-followed-by-one-word suggestion (:8443 support@example.com): already done in the previous push — it is the third entry in the CREDENTIAL_PREFIX_PATTERN comment and the test reads a port followed by one word ending in @ as a userinfo, and says so.

The IPv6 coverage gap: also already present, added in the previous round at workspace-providers-status.test.ts:903 (strips credentials from a bracketed IPv6 base URL, asserting https://[::1]:8443/v1). I did add it a second time before noticing, and vitest/no-identical-title caught the duplicate — so that lint rule earned its keep here.

The 7-line setup helper: I agree with the cost and am deliberately leaving it out of this PR. It rewrites ~25 existing tests that are not otherwise part of this change, which would bury a two-line security fix in a refactor diff and make the next git blame on this file less useful, not more. Happy to do it as a follow-up if a maintainer wants it.

Checks

$ vitest run workspace-providers-status.test.ts acpModelUtils.test.ts
85 passed

$ git stash push .../workspace-providers-status.ts   # tests unchanged
4 failed | 51 passed

The () and [] parametrizations pass either way, because ) and ] are already in PORT_END — they guard existing behaviour rather than the fix, which is why they are in the same table. tsc, eslint and prettier clean.

Comment on lines +472 to +475
const closer = URL_QUOTE_PAIRS.get(before.slice(-1));
if (closer !== undefined) {
const closed = value.indexOf(closer, afterMarker);
if (closed !== -1) lineEnd = Math.min(lineEnd, closed);

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.

[Critical] The balanced-delimiter bounding cuts the URL span at the first occurrence of the closing quote — including one that appears inside the password. When a URL is wrapped in a quote pair and the password contains that same character, the span is truncated before the @, CREDENTIAL_PREFIX_PATTERN sees no credential prefix, and sanitizeProviderBaseUrl receives a userinfo-less fragment it returns unchanged — so the password is emitted into the /status payload in full.

— Failure scenario: Cannot reach "https://user:pa"ss@host.example/v1" — retry. The " before https sets the closer to "; value.indexOf('"', afterMarker) then lands on the " inside pa"ss, so the segment becomes https://user:pa. No @ survives in it, nothing is stripped, and the output is the input unchanged — password pa"ss fully visible (probe-confirmed). This is a regression: the pre-PR code scanned to end-of-line and stripped this to https://host.example/v1. The existing 'still strips a credential from inside a quoted URL' test uses a spaced password but not a quote character in the password, so this path is untested.

One possible fix — bound the closer search by the credential prefix, so a closer inside the password is skipped:

const closer = URL_QUOTE_PAIRS.get(before.slice(-1));
if (closer !== undefined) {
  const creds = CREDENTIAL_PREFIX_PATTERN.exec(value.slice(start + marker.length));
  const closerFrom = creds ? start + marker.length + creds[0].length : afterMarker;
  const closed = value.indexOf(closer, closerFrom);
  if (closed !== -1) lineEnd = Math.min(lineEnd, closed);
}

— qwen3.8-max-preview via Qwen Code /review

The balanced-delimiter rule cut the span at the first matching closer,
but that character is legal in a password. In

  Cannot reach "https://user:pa"ss@host.example/v1" — retry

the first `"` sits inside the credential, so the span ended at
`https://user:pa`, no `@` remained in it, nothing was recognised as a
userinfo, and the password stayed in the message in full — a regression
against the pre-fix behaviour, which scanned to end of line and stripped
this correctly. All six delimiter pairs leaked.

Bounding the closer search by the credential pattern fixes the leaks but
undoes the bug the closer exists for: it asks the port heuristic to
validate the span, and that heuristic's failure on `:8443"` is the whole
reason a closer is consulted. Measured, it turns
`"https://api.example:8443" — contact admin@example.com` back into
`"https://example.com`, destroying the host.

So a closer now has to carry two signals: it must be the last one within
the span bound, and a port must sit immediately before it with no `@` in
between. Either test alone still leaks — without the port,
`:8443"abc secret@host` cuts inside the password; without lastIndexOf, a
closer in the password ends the span whenever no later one exists on the
line. Together they hold, and the port test is the principled half: the
closer only carries information about where the URL ended when a port is
what precedes it.

Computing the line/next-URL bound before the closer search also stops a
closer on a later line from ending this span.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, and fixed in f43af06. Thanks — this was a real regression I introduced, not a pre-existing gap, and the failure scenario reproduced exactly as described.

The finding, verified

I probed all six delimiter pairs on the reviewed head c7976a3 and on ce14a6d9 (the commit before this branch's fix):

input ce14a6d9 (pre-fix) c7976a3 (reviewed) f43af06 (now)
"https://user:pa"ss@host.example/v1" "https://host.example/v1" "https://user:pa"ss@host.example/v1" "https://host.example/v1"
'...pa'ss@...' stripped leaked stripped
`...pa`ss@...` stripped leaked stripped
<...pa>ss@...> stripped leaked stripped
(...pa)ss@...) stripped leaked stripped
[...pa]ss@...] stripped leaked stripped

All six leaked in full. The mechanism is exactly yours: the span ends at https://user:pa, no @ survives inside it, sanitizeProviderBaseUrl finds no userinfo and returns the span unchanged.

Why I did not take the suggested patch

Bounding the closer search by CREDENTIAL_PREFIX_PATTERN does stop all six leaks, but I applied it verbatim and measured the original bug coming back:

in : Cannot reach "https://api.example:8443" - contact admin@example.com
out: Cannot reach "https://example.com

The host is destroyed and the prose is eaten -- which is the case the closer rule was added for. It's circular: it asks the port heuristic to decide where the span ends, and that heuristic's failure on :8443" is the entire reason a closer is consulted in the first place.

What the fix does instead

A closer now has to carry two signals -- it must be the last one within the already-computed span bound, and a port must sit immediately before it with no @ in between:

const nextUrl = findNextUrlStart(value, afterMarker);
const bound = Math.min(lineEnd, nextUrl?.index ?? value.length);

const closer = URL_QUOTE_PAIRS.get(before.slice(-1));
if (closer !== undefined) {
  const closed = value.lastIndexOf(closer, bound - 1);
  if (closed >= afterMarker) {
    const upTo = value.slice(afterMarker, closed);
    if (PORT_AT_END.test(upTo) && !upTo.includes('@')) return closed;
  }
}

return bound;

Both halves are load-bearing; I measured each alone leaking a case the other catches:

  • without the port test -- "https://user:8443"abc secret@host.example/v1" cuts at the closer inside the password, secret survives
  • without lastIndexOf -- "https://user:pa"ss@host.example/v1\nsee the log" has no later closer on that line, so the password's closer ends the span again

The port test is also the principled half rather than a patch: the reason a balanced delimiter matters at all is that the port heuristic cannot see past it, so a closer only carries information about where the URL ended when a port is what precedes it. Moving the line/next-URL bound computation above the closer search is what bounds lastIndexOf to this line.

Tests

You were right that the existing 'still strips a credential from inside a quoted URL' uses a spaced password and never puts a closer in the password -- that path was untested. Added:

  • an it.each over all six pairs with the closer inside the password (pa"ss, pa'ss, ...), asserting both the exact output and not.toContain('pa"ss')
  • the port-lookalike password "https://user:8443"abc secret@..."
  • the closing quote on a later line

Revert control, on the reviewed head with the new tests applied: 8 failed (6 pairs + 2), while the original 6-pair wrapped in %s%s test still passes there -- so the new assertions discriminate on this change specifically and don't just re-test the old one. Full suite 93 passed (workspace-providers-status.test.ts + acpModelUtils.test.ts), tsc, eslint, prettier clean.

One case knowingly unchanged

Cannot connect https://api.internal:8443(ECONNREFUSED) - a@b.com still becomes Cannot connect https://b.com. That's the unbalanced-closer case -- no opening ( before the URL, so no delimiter rule applies -- and it behaves identically on ce14a6d9. It's the fourth tradeoff already documented in CREDENTIAL_PREFIX_PATTERN's comment; fixing it needs the port heuristic to accept an unbalanced closer, which is what leaks :123"abc secret@host. Left as-is deliberately rather than traded.

@qwen-code-ci-bot qwen-code-ci-bot 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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

result += sanitizeProviderWarningSegment(segment, next.marker.length);
const urlEnd = findUrlEnd(segment, next.marker.length);
result +=
sanitizeProviderBaseUrl(segment.slice(0, urlEnd)) + segment.slice(urlEnd);

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.

[Suggestion] The free-text sanitizer (CREDENTIAL_PREFIX_PATTERN / HOST_AFTER_USERINFO in this file) and sanitizeProviderBaseUrl in utils/acpModelUtils.ts encode related assumptions about URL authority structure — what counts as a host and where the userinfo ends — but share no code, no shared constant, and no cross-reference; acpModelUtils.ts has no pointer back to this file. — Concrete cost: a future change to sanitizeProviderBaseUrl's authority-bounding or @-selection (e.g. in findAuthorityEnd / findUnescapedUserInfoFallbackAt) could silently violate the assumptions findUrlEnd relies on and reproduce exactly the credential-leak / host-rewrite bugs this PR fixes, with only acpModelUtils.test.ts (a different directory) to catch it. Not a present-day bug — the two agree on every realistic input today — but the invariant is load-bearing and undocumented. Add a one-line cross-reference at both sites naming it, e.g. at sanitizeProviderBaseUrl: // The free-text sanitizer in serve/workspace-providers-status.ts (CREDENTIAL_PREFIX_PATTERN / HOST_AFTER_USERINFO) re-implements a parallel host grammar whose cut is fed into this function; keep the two notions of "host" consistent or a credential leaks / a host is rewritten., plus a matching back-pointer in the CREDENTIAL_PREFIX_PATTERN doc comment.

— qwen3.8-max-preview via Qwen Code /review

…s on

`sanitizeProviderWarning` decides how much of a message is URL, then hands the
slice to `sanitizeProviderBaseUrl` in utils/acpModelUtils.ts, which locates the
userinfo within it. The two encode the same notion of where an authority ends,
share no code, and neither referenced the other.

That invariant turned out to be both load-bearing and untested. Narrowing
`findAuthorityEnd` to stop only at `/` -- dropping the `?` and `#` cases, the
kind of simplification made when assuming a URL has a path -- leaves both
existing suites fully green, 64 and 31 tests, while changing the output of 18 of
54 pathless-URL shapes. The credentials are still stripped in every one of them;
what breaks is the host. `https://user:secret@host.example:8443?k=v@evil.com`
reports `evil.com`, because the later `@` becomes the userinfo terminator. That
is the host-rewrite bug this PR already fixes for prose, reachable again through
a query or fragment.

Since only an assertion on the surviving host can see it -- a
`not.toContain(password)` check passes either way -- this adds two cases,
covering `?` and `#`. Under the narrowed `findAuthorityEnd` they are the only
two failures in the file, and acpModelUtils.test.ts still passes all 31, which
is what shows the coupling is invisible from its own side.

Both files now name the other, with the concrete failure recorded rather than a
bare pointer, so a future change to either has the reason in front of it.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Accepted, and taken further than a comment — the invariant turned out to be testable, so it's pinned rather than just described. 8ab9eef.

The coupling is real, and it's worse than "only acpModelUtils.test.ts to catch it"

Nothing catches it. I mutated findAuthorityEnd the way the suggestion imagines someone might — narrowed it to stop only at /, dropping the ? and # cases, which is the simplification you make when you assume a URL has a path:

function findAuthorityEnd(baseUrl, authorityStart) {
  const slash = baseUrl.indexOf('/', authorityStart);
  return slash === -1 ? baseUrl.length : slash;
}

Both suites stay green:

                       baseline    mutant
serve                  64 passed   64 passed
acpModelUtils          31 passed   31 passed

So the invariant was undefended on both sides, not just this one.

What the mutation actually breaks

Differentially comparing the two variants over pathless-URL shapes, with a unique password so the substring check can't collide with the port:

inputs:                54
orig vs mutant differ: 18
credential-leak flips:  0
host-rewrite flips:    18

Zero leaks — the credentials are stripped either way. The failure is entirely the other mode you named, the host rewrite:

in:     https://user:Zq7pw@host.example:8443?k=v@evil.com
orig:   https://host.example:8443?k=v@evil.com    host kept
mutant: https://evil.com                          HOST REWRITTEN

That is exactly the bug this PR fixes for trailing prose (— contact admin@example.com), reached again through a query or fragment. And it explains why it was invisible: the natural assertion for a sanitizer is not.toContain(password), and that passes under the mutant. Only an assertion on the surviving host sees it.

So the fix is two tests plus the cross-reference

Added the ? and # cases to workspace-providers-status.test.ts, asserting the full output string rather than just the absence of the secret. Under the narrowed findAuthorityEnd:

failing tests under mutant:
  - keeps the host when a pathless URL carries credentials and its #f holds an @
  - keeps the host when a pathless URL carries credentials and its ?k=v holds an @
summary: 2 failed
acpModelUtils.test.ts: 31 passed   <-- still blind to it

Precisely the two new tests, nothing else — and the asymmetry in that last line is the point: the coupling cannot be detected from acpModelUtils' own suite, so the guard has to live here.

Both files now name the other, as suggested, but recording the concrete failure instead of a bare pointer — a comment that says "keep these consistent" doesn't tell you what inconsistency looks like, and this one has a specific shape worth writing down. sanitizeProviderBaseUrl gets a doc comment naming the caller, the mutation, and the resulting evil.com; CREDENTIAL_PREFIX_PATTERN gets the reciprocal note that it decides only how much of the message is URL and that the userinfo search happens downstream.

tsc --noEmit clean, eslint clean, prettier clean, 95/95 across both suites.

Comment on lines +367 to +368
String.raw`^[^\s/?#'"\x60<>]*:(?!\d+(?:$|${PORT_END}|\s(?:[^@\s]*\s)))[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,
'u',

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.

[Critical] Credential leak: a spaced password whose first word is digits immediately followed by a PORT_END punctuation char (. , ; ! ? ) ] }) is misread as a port, so CREDENTIAL_PREFIX_PATTERN never matches and the full credential is emitted into the /status payload.

The negative lookahead (?!\d+(?:$|${PORT_END}|…)) rejects the colon in user:123. secret@host because \d+ matches 123 and PORT_END matches .. With no credential match, findUrlEnd falls back to cutting at the first space — inside the password — handing https://user:123. (no @) to sanitizeProviderBaseUrl, which returns it unchanged; the remainder is appended as prose.

— Failure scenario: Failed https://user:123. secret@host.example/v1Failed https://user:123. secret@host.example/v1 (full username + password + host visible). Probe-confirmed; the same leak occurs for the 123, 123) 123! 123; and 1. variants. This is a regression — base stripped it to https://host.example/v1. The pinned control user:123 secret@… (no punctuation) still strips correctly; appending a single PORT_END char to the digit word is what opens the leak. Note the symmetry with the PORT_END comment: the closing set was chosen to fix the :123%abc secret@host leak, but including . / , / ; / etc. creates this one.

Fix direction: read leading digits as a port only when that reading actually terminates the authority — before rejecting on PORT_END, confirm there is no later @(?=HOST_AFTER_USERINFO) in the span. A naive "look for a later @" flip breaks the pinned :8443, contact admin@example.com test, so the port reading needs to require the digits-plus-punctuation to sit at the URL boundary (space / EOL / closer), not mid-password. Pin with user:123. secret@host.example/v1https://host.example/v1, password absent.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 7d6336a (test) on top of 9ae36ac (fix). Reproduced through createWorkspaceProvidersStatusProvider rather than against the regex alone, since the leak is in the interaction between the pattern and findUrlEnd's fallback:

in     Failed https://user:123. secret@host.example/v1
before Failed https://user:123. secret@host.example/v1   <- credential kept verbatim
after  Failed https://host.example/v1

Your reading of the mechanism is exactly right, including the part that matters most: the failure is not "the pattern matches too little", it is that not matching routes the span to the first-space cut, which lands inside the password and leaves a slice with no @ for sanitizeProviderBaseUrl to strip.

Two things I checked before choosing the fix.

The obvious fix is wrong. Dropping PORT_END from the lookahead does fix this case, and it also breaks two others — the new port-guard test and the pre-existing :8443, contact one both start rewriting the host to example.com. I kept it as a mutation:

mutation result
restore the bare ${PORT_END} arm leak test fails (1)
delete the ${PORT_END} arm outright port-guard test + :8443, contact fail (2)

So there are two tests, not one. The second pins the direction the ambiguity is resolved in, which is the part a later change could flip silently.

What separates the two readings turned out to be already in the pattern: a port that closes a sentence is not followed by a userinfo @, while a password's remaining words are. That is the same test the neighbouring \s(?:[^@\s]*\s) arm makes, so the PORT_END arm now makes it too — scoped to the rest of the span rather than the token, because the deciding @ is in the word after the space.

CREDENTIAL_FALLBACK_PATTERN cannot cover this one, which is worth stating since it was added for the sibling finding in the same batch. Its host is a single character by construction (FALLBACK_HOST) and the host here is host.example, so it never matches. I verified that rather than assuming it — with the fallback in place and the lookahead reverted, the leak still reproduces.

Since the change nests a lookahead inside a negative lookahead, I also scaled it rather than assuming it was cheap: linear on digit runs, punctuation runs and long space-separated tails up to n=2000, worst point 1ms → 3ms over 200 iterations. Worth measuring because a different candidate I tried while working this — a forward scan for a later @, in the host rule rather than here — turned out quadratic at 388ms/n=3200 against 6ms for an anchored equivalent, and nothing about reading it suggested that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction to my reply above, on the one point that changes what a reviewer should conclude: I described this as a defect in the pattern without confirming your "this is a regression" note. You were right, and I have now measured it — base strips Failed https://user:123. secret@host.example/v1 to https://host.example/v1 correctly, and the leak arrives with ce14a6d, one of my own earlier commits on this branch.

Full end-to-end walk of the branch, for this case and the two siblings in the same batch:

revision 123. secret@host.example my pass@h pass@h — contact a@b.com
base 6a432ad stripped stripped host kept
ce14a6d (mine) leak leak rewritten
f43af06, 8ab9eef leak leak rewritten
9ae36ac stripped stripped rewritten
7d6336a + 68fe634 stripped stripped host kept

Nothing about the fix or the mutation results changes. What changes is the framing: this is not a gap in a new grammar, it is one regression with three spellings that my own earlier commits introduced, and the branch was net-negative against base on these inputs for several commits. The PORT_END symmetry you pointed out is the mechanism — the closing set was widened to fix the :123%abc leak and that widening opened this one, which is the same trade the other two findings sit on.

I have written the fuller version of this on the sibling comment (#discussion_r3689070573), including whether the accumulated grammar should be restructured rather than widened a sixth time.

Comment on lines +287 to +289
String.raw`|${HOST_CHAR}${LABEL_CHAR}+` +
String.raw`|${HOST_CHAR}:\d+)` +
String.raw`(?::\d+)?(?!${LABEL_CHAR})`;

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.

[Critical] Credential leak (and host rewrite) for a bare single-character host. HOST_AFTER_USERINFO admits a single char only with a port (HOST_CHAR:\d+) or with a path (DELIMITED_HOST); a bare one-char host (@h) with neither matches no alternative, so CREDENTIAL_PREFIX_PATTERN cannot match at that @.

Two consequences, depending on what follows:

  • Leak — spaced password, no later @: Failed https://user:my pass@h retry later → the pattern fails outright, findUrlEnd cuts at the first space (inside the password: https://user:my), and the full credential user:my pass survives verbatim. Base stripped this to https://h retry later. (The unspaced user:pass@h case still strips, because the whole token has no internal space and sanitizeProviderBaseUrl / new URL handles it — the leak needs a spaced password.)
  • Host rewrite — a prose @ after: Cannot reach https://user:pass@h — contact admin@example.com → the lazy match falls back to the email's @ and rewrites the host to https://example.com (base kept https://h …, prose intact).

Both probe-confirmed; controls (@intranet, @h/v1, @h:8443) strip correctly, isolating the bare single-char gap. This is the narrower single-char case behind the already-fixed single-label blocker — multi-char bare labels (intranet, internalhost) work, one-char ones do not.

Fix direction: admit a one-char label as a host only when it is the target of a user:password@ prefix and no later @ resolves to a properly-delimited host (prefer the better-evidenced later @). Lowering the two-character floor outright breaks the pinned p@s s@host.example tradeoff (the s after p@ is a single char), so the distinguisher must be the presence of a later viable @, not length alone.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both halves confirmed, and they needed two different fixes — which I only found because I re-measured your second bullet against the first fix instead of assuming one commit covered the comment.

Leak half — fixed in 9ae36ac:

in     Failed https://user:my pass@h retry later
before Failed https://user:my pass@h retry later   <- credential kept verbatim
after  Failed https://h retry later

Host-rewrite half — still broken after that commit; fixed in 68fe634:

in     Cannot reach https://user:pass@h — contact admin@example.com
after 9ae36ac  Cannot reach https://example.com          <- host rewritten, prose deleted
after 68fe634  Cannot reach https://h — contact admin@example.com

The reason the first fix does not reach it is worth recording, because it looks like it should. CREDENTIAL_FALLBACK_PATTERN was consulted only when the primary pattern declined. In the leak case it declines, so the fallback runs. In the rewrite case it does not decline — it matches at the email's @, because a one-character host is invisible to HOST_AFTER_USERINFO and the lazy tail simply runs past it. Succeeding at the wrong @ and failing outright look the same from the outside but take different branches:

body = user:pass@h — contact admin@example.com
  primary  : "user:pass@h — contact admin@"    <- matches, so the fallback is skipped
  fallback : "user:pass@"                       <- had the right answer all along

So the fallback is now consulted either way, and when both match, which to believe is decided by the whitespace between the two @s.

Your fix direction was right about the discriminator but I could not use length or @-presence for it. I tried three:

rule result
prefer the shorter match fixes this, breaks pinned p@s s@host.example
prefer the fallback when a space lies between the two @s fixes this, breaks pinned p@s s@host.example
prefer the fallback when two or more spaces lie between all 27 cases pass

Two spaces is the line because that is already this file's notion of prose — it is the same test PORT_DIGITS's \s(?:[^@\s]*\s) arm makes to tell :8443 — contact from a one-space password. So the pinned tradeoff survives for the reason it was pinned (one space is genuinely ambiguous, and the later host is better evidenced) rather than by special-casing.

Three mutations, each killed by a different set, which is what convinced me the guard is load-bearing rather than decorative:

mutation failures
consult the fallback only when the primary declines 1
always prefer the fallback 2
treat one space as enough 2

The last two both "fix" the reported case and both break p@s s@host.example, so without the guard this would have been a trade rather than a fix.

Your controls reproduced as stated — @intranet, @h/v1 and @h:8443 all strip correctly, before and after — which is what isolated this to the bare one-char case. Also confirming your parenthetical: the unspaced user:pass@h does strip on base, so the leak does need a spaced password.

One thing I should add, because it is not visible from any single comment and it is not flattering to this branch. I measured every revision end-to-end against all three cases in this batch, and all three are regressions my own earlier commits on this branch introduced — base handles all three correctly:

revision 123. secret@host.example my pass@h pass@h — contact a@b.com
base 6a432ad stripped stripped host kept
ce14a6d (mine) leak leak rewritten
f43af06, 8ab9eef leak leak rewritten
9ae36ac stripped stripped rewritten
68fe634 stripped stripped host kept

So this is one regression with three spellings, introduced by a commit whose message claims to fix host recognition, and it took four further commits to get back to parity with base on these inputs. The cause is structural rather than careless: each of those commits widened the host grammar to recognise a shape that was being missed, and every widening moves the boundary between "host" and "password fragment" — which is exactly where these three inputs sit.

The reason I am stating it here rather than leaving it in per-comment replies: the branch is a sequence of grammar widenings, and if the review reads each finding as an isolated miss, the pattern that produced them is invisible. If you would rather see this restructured — the accumulated grammar replaced by something closer to base's new URL-based authority parsing, which never had to guess at these boundaries — that is a reasonable call and I would rather make it now than add a sixth widening later.

71 tests green. Three files in packages/cli/src/serve fail on my machine — fast-path, daemon-logger, capabilities-docs-contract — with EPERM: operation not permitted, symlink, which is Windows without symlink privilege and not related to this change.

Comment on lines +503 to +506
const closed = value.lastIndexOf(closer, bound - 1);
if (closed >= afterMarker) {
const upTo = value.slice(afterMarker, closed);
if (PORT_AT_END.test(upTo) && !upTo.includes('@')) return closed;

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.

[Suggestion] Host rewrite when a balanced-quoted credentialed URL is followed later on the same line by a second quoted span containing @ (e.g. a quoted email). lastIndexOf(closer, bound - 1) lands on the LAST quote on the line (the email's closing quote), PORT_AT_END fails on the text between, so the closer shortcut gives up and the span runs to end-of-line; the greedy username class [^\s/?#'"\x60<>]*: then backtracks to a later : and sanitizeProviderBaseUrl strips at a prose @.

— Concrete cost: Error at "https://admin:s3cr3t@internal:8443" — email "ops@company.com" for helpError at "https://company.com" for help — the host is rewritten from internal:8443 to company.com and the contact prose is deleted (the credential is still stripped, so this is corruption, not a leak). Regression: base preserved https://internal:8443 with the prose intact. The uncredentialed variant ("https://internal:8443" with no credentials) produces the same wrong output on base as well, so that one is pre-existing; the credentialed case is where this diff regresses. It also sits in tension with the CREDENTIAL_PREFIX_PATTERN comment's claim that a URL "wrapped in a balanced pair … is handled" — it is, unless a second quoted span on the line steals lastIndexOf.

Fix direction: search backward through closer occurrences and return the rightmost one where PORT_AT_END matches and the span carries a recognizable credential prefix. The naive backward search guarded by !upTo.includes('@') does not flip this case (the credentialed URL's own span contains @) and breaks the password itself looks like a port test, so the closer needs to be validated against the credential prefix rather than the mere absence of @.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed on both halves, exactly as you split them — fixed in f5c513e for the credentialed case, and I am leaving the uncredentialed one broken on purpose, with the reason recorded in the code.

in     Error at "https://admin:s3cr3t@internal:8443" - email "ops@company.com" for help
before Error at "https://company.com" for help
after  Error at "https://internal:8443" - email "ops@company.com" for help

Your warning about the naive backward search was the whole difficulty, and it cost me three candidates before I read it as the constraint it is. !upTo.includes('@') cannot gate the walk, because the credentialed span carries its own @; but PORT_AT_END alone cannot either, because a closer inside a password reaches the walk too and user:8443 is character-for-character a host:port. What separates them is that user:8443 has no @ at all, so the walk requires a complete userinfo@host:port:

"admin:s3cr3t@internal:8443"             -> accepted
"user:8443"                              -> rejected (no userinfo)
"user:8443\"abc secret@host.example/v1"  -> rejected
"api.example:8443"                       -> rejected (no userinfo; handled by the last-closer rule)

The walk also starts at last - 1, not last, so the last closer keeps the existing rule verbatim and nothing that works today changes route.

The uncredentialed variant I did not fix, and I do not think it should be fixed this way. You are right that it is pre-existing; what I want to add is that its input is indistinguishable from a pinned one:

"https://internal:8443" … "ops@company.com"   want: keep internal:8443
"https://user:8443"abc secret@host.example/v1"  want: strip the password

internal:8443 and user:8443 are the same shape, and what follows the closer is prose in one and the rest of a credential in the other — so widening the walk to accept a bare host:port fixes the first message and leaves the password in the second. That is a leak traded for a corruption, so I left it and wrote the reasoning into the comment instead of quietly declining it.

Three mutations:

mutation failures
walk accepts any port-at-end 1 — the pinned password itself looks like a port, and it leaks
walk deleted 1 — the new test only
last closer no longer requires the absence of @ 0

The third surviving is worth stating rather than hiding. It changes the span on three inputs I constructed but the output on none of fourteen probed end-to-end, so after the walk exists it looks redundant — I kept it because "no test covers it" and "it is safe to delete" are different claims and I only measured the first.

Revision walk, credentialed / uncredentialed:

revision credentialed uncredentialed
base 6a432ad internal:8443 kept company.com (broken here too)
ce14a6d (mine) company.com company.com
8ab9eef, 9ae36ac, 68fe634 company.com company.com
f5c513e internal:8443 kept company.com

Which matches your note precisely, and makes this the fourth finding in this batch that traces to ce14a6d. It also answers your point about the CREDENTIAL_PREFIX_PATTERN comment: the claim that a balanced pair "is handled" was true only for a line with one quoted span, and I have amended the URL_QUOTE_PAIRS doc, which asserted a closer "must be the last one within the span" — that is now what a last closer must satisfy, not what any closer must.

72 tests green, prettier/eslint/tsc clean. The EPERM: operation not permitted, symlink failures in fast-path, daemon-logger and capabilities-docs-contract are Windows without symlink privilege, unrelated to this change.

The standing offer on #discussion_r3689070573 applies here too, and this finding strengthens it: four regressions from one commit, each fixed by a further narrowing of the same accumulated grammar. If you would rather see this branch restructured around base's new URL authority parsing than widened again, say so and I will do that instead of adding a fifth patch.

`HOST_AFTER_USERINFO` requires two characters of a bare label, so `@h` matched
no alternative and `CREDENTIAL_PREFIX_PATTERN` declined a userinfo that was
really there. `findUrlEnd` then fell back to cutting at the first space, which
is inside the password, and the slice handed to `sanitizeProviderBaseUrl` had no
`@` at all -- so nothing was stripped and the credential came back as prose:

  in   Failed https://user:my pass@h retry later
  out  Failed https://user:my pass@h retry later     <- full credential
  base Failed https://h retry later

A regression against the branch point, and the same for an empty username
(`https://:my pass@h`). One-character hosts are real: container names and
/etc/hosts aliases are often a single letter.

`CREDENTIAL_FALLBACK_PATTERN` is consulted only after the primary pattern has
declined, and differs from it in exactly one place -- the `@` may be followed by
a one-character bare host. The port lookahead is kept verbatim, because that is
what tells a userinfo from `:8443 - contact admin@example.com`, where there is
no credential and cutting at the email's `@` would rewrite the host.

Widening the host rule is safe in that state in a way it is not in the primary
pattern. There, a one-character "host" that is really a password fragment
rewrites a host that was fine -- which is why the two-character floor exists,
and why `p@s s@host.example` still resolves through the primary pattern. Here,
the alternative is not "leave the host alone" but "emit the password".

Four tests: the two leaks, the `p@s s` boundary that the floor exists for, and
a no-credential message ending in a one-character word before an email, which
fails if the port lookahead is ever dropped from the fallback. With the fallback
disabled, exactly the two leak tests fail and the two boundary tests still pass.

Not fixed here, because a port and a password are genuinely indistinguishable in
them: `user:123. secret@host` (digits then a PORT_END char) and the quoted-span
closer case. Both need the colon in `host:port` told apart from the colon in
`user:password`, which is the ambiguity `CREDENTIAL_PREFIX_PATTERN` already
documents; a fix guessing either way trades one leak for another.
@LHMQ878

LHMQ878 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

All three findings on 8ab9eefb reproduce. One is fixed in 9ae36ac; the other two I am deliberately not fixing here, and the reason is the same reason the review's suggested diffs do not work.

I reproduced each against the head before touching anything, and confirmed the "regression" characterisation by running the same three inputs against the branch point:

input base 8ab9eefb
https://user:my pass@h retry later https://h retry later unchanged — credential leaks
https://user:123. secret@host.example/v1 https://host.example/v1 unchanged — credential leaks
"https://admin:s3cr3t@internal:8443" — email "ops@company.com" for help host and prose intact "https://company.com" for help

So both Criticals are real leaks and regressions against base. Confirmed.

Fixed: the bare one-character host

CREDENTIAL_FALLBACK_PATTERN is consulted only after CREDENTIAL_PREFIX_PATTERN declines, and differs from it in exactly one place — the @ may be followed by a one-character bare host. The port lookahead is kept verbatim, because that is what tells a userinfo from :8443 — contact admin@example.com, where there is no credential and cutting at the email's @ rewrites the host.

Widening the host rule is safe in that state in a way it is not in the primary pattern. There, a one-char "host" that is really a password fragment rewrites a host that was fine — which is why the two-character floor exists, and p@s s@host.example still resolves through the primary pattern. Here, the alternative is not "leave the host alone" but "emit the password", because the first-space cut is already known to land inside a credential.

Four tests: the two leaks (user:my pass@h, :my pass@h), the p@s s boundary the floor exists for, and a no-credential message ending in a one-character word before an email — that last one fails if the port lookahead is ever dropped from the fallback. Control experiment: with the fallback call removed, exactly the two leak tests fail and both boundary tests still pass.

68 tests in the file pass; acpModelUtils.test.ts 31 pass; eslint and prettier clean.

Not fixed, and why the suggested fixes reopen leaks

I tried both suggested directions and measured what they do. Reporting the failures because they bound the fix space.

user:123. secret@host (the digits-then-PORT_END leak). The review's direction — "confirm there is no later @(?=HOST_AFTER_USERINFO) before rejecting on PORT_END" — I implemented as a lookahead on PORT_DIGITS. It fixes the case. But the discriminator it relies on does not exist: probing the text between the colon and the @ gives

LEAK   "123. secret"              digits + PORT_END + prose
NOCRED "8443 — contact admin"     digits + PORT_END + prose
NOCRED "8443, contact admin"      digits + PORT_END + prose
NOCRED "8443 support"             digits + PORT_END + prose

Four inputs, one structure, two opposite correct answers. Any rule keyed on this shape moves the three no-credential cases, and those three are pinned tests — api.example.com:8443 — contact admin@…, localhost:8443 — …, api.example:8443, contact admin@…. I had a version passing the leak test and failing those three.

The quoted-span closer. The suggestion is to search closers backward and validate against the credential prefix instead of !upTo.includes('@'). Backward search does find the right closer:

closed=70  upTo="admin:s3cr3t@internal:8443\" - email \"ops@company.com"   PORT_AT_END=false
closed=54  upTo="admin:s3cr3t@internal:8443\" - email "                    PORT_AT_END=false
closed=44  upTo="admin:s3cr3t@internal:8443"                               PORT_AT_END=true  <-- correct

But it reopens exactly the leak the !upTo.includes('@') guard was added for. On "https://user:8443"abc secret@host.example/v1":

span cut contains @
current lastIndexOf user:8443"abc secret@host.example/v1" yes → strips
backward search user:8443 no → nothing stripped, password survives

Adding "or the span carries a credential prefix" fixes that one and breaks the pinned "https://api.example:8443" — contact admin@example.com, which has no credential prefix and an @ later on the line. Four attempts, each trading one of these for another.

The common blocker: both need the colon in host:port told apart from the colon in user:password, in a span where a password may contain spaces, digits, quotes and @. That is the ambiguity CREDENTIAL_PREFIX_PATTERN's doc comment already names, and I do not think it is resolvable inside this grammar — it wants the message producer to mark the URL, not the consumer to guess it.

I would rather ship the one case that has an unambiguous discriminator than land a fourth iteration that moves the leak somewhere the tests do not look. If you would prefer these two fixed in this PR anyway, say so and I will take the version that keeps the leaks pinned as explicit tests rather than silently.

Note: the working tree here also has an in-progress PORT_DIGITS extraction that is not part of 9ae36ac — that commit is only the fallback pattern and its four tests.

LHMQ878 added 3 commits July 31, 2026 16:02
Two cases for the `PORT_DIGITS` rule, one on each side of it, because the rule
resolves an ambiguity rather than computing an answer and a test on only one
side would be satisfied by the naive fix.

`https://user:123. secret@host.example/v1` is a password whose first word is
digits closing on a `PORT_END` character, which is character-for-character how a
port ending a sentence looks. Read as a port, the credential prefix was not
recognised, `findUrlEnd` cut at the first space -- inside the password -- and
the slice reaching `sanitizeProviderBaseUrl` had no `@` left to strip, so
`user:123. secret@` was re-emitted verbatim in the `/status` payload.
`CREDENTIAL_FALLBACK_PATTERN` does not reach it either: its host is a single
character by construction and this host is `host.example`.

Measured through `createWorkspaceProvidersStatusProvider`:

    in     Failed https://user:123. secret@host.example/v1
    before Failed https://user:123. secret@host.example/v1   <- credential kept
    after  Failed https://host.example/v1

The second case is the port that really is a port. Each kills a different
mutation:

- restoring the bare `${PORT_END}` arm fails the leak test only;
- deleting the `${PORT_END}` arm outright -- the naive fix for the leak -- fails
  the new port-guard test *and* the pre-existing `:8443, contact` one, both of
  which rewrite the host to `example.com`.

So the guard is not redundant with the leak test; it pins which direction the
ambiguity is resolved in, which is the part a later change could quietly flip.
…ry matches

The one-character-host defect has a second half that 9ae36ac does not close,
because there the primary pattern does not decline -- it matches at the *wrong*
`@`. A one-char host is invisible to `HOST_AFTER_USERINFO`, so in

    Cannot reach https://user:pass@h — contact admin@example.com

the lazy tail runs past `h` and matches at the email's `@`. The credential is
still stripped, so this is corruption rather than a leak, but the host is
rewritten from `h` to `example.com` and the contact prose is deleted:

    before Cannot reach https://example.com
    after  Cannot reach https://h — contact admin@example.com

`CREDENTIAL_FALLBACK_PATTERN` finds the right `@` already; it was only consulted
when the primary pattern failed. It is now consulted either way, and when both
match, which to believe is decided by the whitespace between them:

- two spaces or more is prose, so the primary reached out of the URL to find a
  host and the earlier `@` wins;
- one space is a one-space password, which this file already treats as ambiguous
  and resolves in favour of the better-evidenced later host -- the pinned
  `p@s s@host.example` case -- so the primary keeps it.

That is the same "a second space means prose" test `PORT_DIGITS` makes, applied
to the other ambiguity, rather than a new heuristic.

Three mutations, each killed by a different set:

| mutation | failures |
|---|---|
| consult the fallback only when the primary declines | 1 |
| always prefer the fallback (drop the two-space guard) | 2 |
| treat one space as enough | 2 |

The last two matter: both would fix the reported case, and both break the pinned
`p@s s@host.example` tradeoff, so the guard is what keeps this from being a trade
of one defect for another.
`findUrlSegmentEnd` took `lastIndexOf(closer, bound - 1)` — the last closer
on the line, which is the wrong one as soon as a second quoted span follows.
For `Error at "https://admin:s3cr3t@internal:8443" - email "ops@company.com"`
it lands on the email's quote, the port test fails on the text between, and
the closer carries no information; the span then runs to end-of-line, a later
`:` becomes the username and the prose `@` its terminator, rewriting the host
to `company.com` and deleting the contact text. The credential is still
stripped, so this is corruption rather than a leak.

So when the last closer does not qualify, walk left through the remaining
ones. The test there has to be stricter than a port at the end and no `@`:
the credentialed span holds an `@` of its own, and a password can end in
`:digits` — `user:8443` is character-for-character a `host:port`. Requiring
the userinfo *and* the host separates them, since `user:8443` has no `@` at
all, and keeps the pinned password-looks-like-a-port case stripping.

The uncredentialed `"https://internal:8443"` followed by a quoted email is
still wrong, as it is on base. There is no signal that separates its
`internal:8443` from that password: same shape, and what follows the closer
is prose in one case and the rest of a credential in the other. Accepting a
bare `host:port` here would fix the message and leave the password in the
other, which is the worse trade — recorded in the comment rather than taken.

Measured across the branch, credentialed / uncredentialed:

  base 6a432ad  internal:8443 kept   /  company.com   (latter broken here too)
  ce14a6d       company.com          /  company.com   (regression)
  8ab9eef, 9ae36ac, 68fe634  unchanged
  this commit   internal:8443 kept   /  company.com

Three mutations: accepting any port-at-end in the walk leaks the pinned
password (1 failure), deleting the walk fails only the new test (1), and
dropping the first closer's `@` test fails nothing — it changes the span on
three inputs but not the output on any of fourteen probed, so it is kept as
pre-existing behaviour rather than removed on no evidence.
Comment on lines +261 to +262
const HOST_CHAR = String.raw`[\p{L}\p{N}\p{M}]`;
const LABEL_CHAR = String.raw`[\p{L}\p{N}\p{M}-]`;

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.

[Critical] Credential leak: the host grammar admits no host whose first character is outside [\p{L}\p{N}\p{M}] (notably _, and also - and .). A credentialed URL with such a host is matched by neither CREDENTIAL_PREFIX_PATTERN nor CREDENTIAL_FALLBACK_PATTERN; when the password also contains a space, findUrlEnd falls through to the first-space cut inside the password, and sanitizeProviderBaseUrl receives an @-less slice it returns unchanged — so the full credential is re-appended verbatim into the /status payload.

— Failure scenario: Failed https://user:my pass@_host/v1Failed https://user:my pass@_host/v1 (password my pass visible). Reproduced against the real code through createWorkspaceProvidersStatusProvider; the same leak hits @_svc.local, @-host, @.host, and a slash-containing spaced password (https://user:a b/c@host.example/v1). The control https://user:my pass@host.example/v1 strips correctly. The -/./slash variants are regressions versus base (base stripped them); _ is a never-handled gap that this file's own guarantee — "Not recognising a host is what leaks a credential" — and the bare-label support added for intranet/k8s/container names should now cover.

Suggested change
const HOST_CHAR = String.raw`[\p{L}\p{N}\p{M}]`;
const LABEL_CHAR = String.raw`[\p{L}\p{N}\p{M}-]`;
const HOST_CHAR = String.raw`[\p{L}\p{N}\p{M}_]`;
const LABEL_CHAR = String.raw`[\p{L}\p{N}\p{M}_-]`;

Admitting _ lets @_host/@_svc.local match so the primary pattern consumes the userinfo. The -/.-led and slash-in-password variants are the same class and deserve a deliberate decision too; at minimum pin the underscore case with a regression test. Note CREDENTIALED_AUTHORITY_AT_END (line 626) spells its middle class inline and will not pick up this widening — see the separate suggestion there.

— qwen3.8-max-preview via Qwen Code /review

* legal in a password: `:123%abc secret@host` was read as a port and its
* password left in the message.
*
* Quotes and brackets are deliberately absent for the same reason, even though

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.

[Suggestion] The comment says "Quotes and brackets are deliberately absent" but PORT_END = [,.;:!?)\]}] on line 322 includes three closing brackets — ), ], }. Comment and code contradict each other on a security-sensitive regex grammar. — Concrete cost: those closing brackets are load-bearing (via PORT_DIGITS they let :8443) / :8443] / :8443} read as a port, which is what stops a following prose @ such as :8443) contact admin@example.com from being taken as a userinfo terminator). A maintainer who trusts "brackets are deliberately absent" and removes them reintroduces exactly the host-rewrite/leak the surrounding code works to prevent.

Suggested change
* Quotes and brackets are deliberately absent for the same reason, even though
* Quotes and opening brackets are deliberately absent for the same reason, even though

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +173 to +175
* reports `evil.com`. Both suites pass under that change; the two
* `holds an @` cases in `serve/workspace-providers-status.test.ts` are what
* catch it.

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.

[Suggestion] This comment admits the gap but does not close it: the collocated sanitizeProviderBaseUrl cases in utils/acpModelUtils.test.ts have no case where ? or # bounds the authority with a credential @ before it and an @ after it (the https://u:p@host.example:8443?k=v@evil.com shape this comment names). — Concrete cost: verified empirically — temporarily narrowing findAuthorityEnd (dropping the ?/# bounds) leaves acpModelUtils.test.ts green at 31/31, and only the two holds an @ cases in serve/workspace-providers-status.test.ts (a different directory) fail. A developer changing findAuthorityEnd who runs only the collocated suite sees green while the host-rewrite regression slips through.

Suggested change
* reports `evil.com`. Both suites pass under that change; the two
* `holds an @` cases in `serve/workspace-providers-status.test.ts` are what
* catch it.
* reports `evil.com`. Both suites pass under that change; the two
* `holds an @` cases in `serve/workspace-providers-status.test.ts` are what
* catch it. Adding the `?k=v@` / `#f@` shapes to `acpModelUtils.test.ts` would
* let the collocated suite catch it too.

— qwen3.8-max-preview via Qwen Code /review

*/
const CREDENTIALED_AUTHORITY_AT_END = new RegExp(
String.raw`^[^\s/?#'"\x60<>]*:[^/?#]*?@` +
String.raw`${HOST_CHAR}(?:[\p{L}\p{N}\p{M}.-]*${HOST_CHAR})?:\d+$`,

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.

[Suggestion] CREDENTIALED_AUTHORITY_AT_END is the only host pattern that spells the Unicode class inline ([\p{L}\p{N}\p{M}.-]*) instead of composing it from LABEL_CHAR; every other host pattern (UNDELIMITED_HOST, DELIMITED_HOST, FALLBACK_HOST) builds on the shared HOST_CHAR/LABEL_CHAR constants. — Concrete cost: the natural fix for the credential leak above (add _ to HOST_CHAR/LABEL_CHAR) propagates to the primary/fallback patterns and HOST_AFTER_USERINFO automatically but silently leaves this pattern rejecting _ mid-host, so a quoted "https://user:pass@my_host:8443" followed by a second quoted span still mis-bounds the segment after that fix. No independent present-day bug (today _ is rejected uniformly), so this is a coupling hazard rather than a blocker.

Suggested change
String.raw`${HOST_CHAR}(?:[\p{L}\p{N}\p{M}.-]*${HOST_CHAR})?:\d+$`,
String.raw`${HOST_CHAR}(?:${HOST_MIDDLE_CHAR}*${HOST_CHAR})?:\d+$`,

(where HOST_MIDDLE_CHAR is LABEL_CHAR plus the dot the cross-label match needs, e.g. const HOST_MIDDLE_CHAR = String.raw`(?:${LABEL_CHAR}|\.)`; — keeps the intentional dot admission while letting any future widening of LABEL_CHAR propagate.)

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f5c513e, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

A host whose first character is one a valid host cannot start with was
matched by neither credential pattern, so `findUrlEnd` fell through to
cutting at the first space -- inside the password -- and the `@`-less
slice came back from `sanitizeProviderBaseUrl` verbatim with the rest of
the credential re-appended as prose. `_host` leaked before this branch;
`-host` and `.host` are shapes the replaced code stripped, because it
tested one character class instead of matching a host grammar. This is
the one place the grammar must not be a validator: rejecting a host does
not make the URL invalid, it makes the authority unrecognised, and being
liberal here can only remove more text from the payload.

`CREDENTIALED_AUTHORITY_AT_END` now composes its host middle from the
shared classes rather than spelling them inline, which is why it kept
rejecting `@_host:8443` after `HOST_CHAR` gained `_`.

A slash in the password reached the same leak through the one delimiter
both patterns treat as absolute: their `[^/?#]*?@` tail cannot cross it,
and it must not in general, since a path may legally contain an `@`.
`SLASHED_PASSWORD_PATTERN` crosses it under two gates and is consulted
only after both others decline -- a space must precede the `@`, which is
the precondition for the leak and the one thing a path cannot hold, and
the port lookahead excludes `\d+[/?#]` so that `:8443/v1`, newly
reachable, is not read as a userinfo.

`PORT_END`'s comment claimed brackets were absent while `)`, `]` and `}`
were in the set. They are safe, but not for the reason given: the
`no userinfo follows` lookahead is what carries them. Quotes are the one
addition that is not interchangeable, and two spellings where the
lookahead does not fire are now pinned -- both pass with quotes added if
asserted on `JSON.stringify`, which escapes the quote in the expected
password.

Also pins `?` and `#` as the authority terminator in
`sanitizeProviderBaseUrl`, with and without a credential to strip.

Measured: 7 new tests in workspace-providers-status.test.ts are red on
the previous revision of this branch (5 failed / 74 passed) and green
with the fix (81 passed); the two quote tests are red with `"` and `'`
added to `PORT_END`. acpModelUtils.test.ts: 36 passed. The wider
`serve`/`utils` selection fails the same 80 tests before and after --
failure sets diffed, not counts. The one test that differed,
`server.test.ts > ... refreshes cached serve features`, fails
intermittently on the unmodified tree as well, once in two runs.
@LHMQ878

LHMQ878 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in b3db8ed93, and the [Critical] was right on every count including the regression split — I confirmed that half separately by running the same six inputs against git show origin/main:packages/cli/src/serve/workspace-providers-status.ts, because "pre-existing" and "I introduced this" deserve different treatment and I didn't want to guess which was which.

input before after versus base
https://user:my pass@_host/v1 https://user:my pass@_host/v1 https://_host/v1 leaked on base too
https://user:my pass@_svc.local/v1 leaked https://_svc.local/v1 leaked on base too
https://user:my pass@-host/v1 leaked https://-host/v1 regression I introduced
https://user:my pass@.host/v1 leaked https://.host/v1 regression I introduced
https://user:a b/c@host.example/v1 leaked https://host.example/v1 regression I introduced

Three of the five were strictly worse than the code I replaced. Base got them right for a reason worth recording: it did not match a host grammar at all, it took indexOf('@') and tested a single character class, /[A-Za-z0-9.[\]-]/. That admits - and . without meaning to, and rejects _ — so replacing it with a real grammar traded two accidental successes for one principle, and the principle was wrong in the one place it mattered.

A host grammar in a sanitizer must not be a validator. That is the actual lesson, and I had it backwards. HOST_CHAR now admits _, - and . as a first character, which no valid host may be. Rejecting @_host never made the URL invalid — it made the authority unrecognised, and the consequence of not recognising an authority here is the whole leak: no credential prefix matches, findUrlEnd falls through to cutting at the first space inside the password, and sanitizeProviderBaseUrl gets an @-less slice it returns verbatim while the rest of the credential is re-appended as prose. Being liberal cannot fail the other way, because what a match does is strip the userinfo ahead of it, so admitting too much removes text from the payload rather than adding it. _ is also just legal in practice — container and k8s service names use it, which is what the bare-label branch exists for.

One divergence was invisible until I composed it away: CREDENTIALED_AUTHORITY_AT_END spelled its host middle inline as [\p{L}\p{N}\p{M}.-], so HOST_CHAR gained _ and that pattern went on rejecting @_host:8443. It now builds the middle from the shared classes. A duplicated character class that drifts is a cosmetic problem almost everywhere; here the drift is the leak.

The slash case is a different mechanism, and the one I found least comfortable to fix. Both patterns spell the userinfo tail [^/?#]*?@, which cannot cross a / — and in general it must not, because a path may legally hold an @: crossing it in https://host.example/path@thing takes the @ from the path and rewrites the host from the text beyond. A slash genuinely is stronger evidence than a space. But a slash is also legal in a password, so the same class declines a real prefix, and that is what leaked b/c@host.example/v1.

So SLASHED_PASSWORD_PATTERN crosses it, last of three and only once both others decline, under two gates that each rule out one half of the ambiguity:

  • A space must precede the @. Not a heuristic about passwords — it is the precondition for the leak. With no space, the first-space cut cannot land inside the credential, so there is nothing to fix and no reason to guess. It is also exactly what a path cannot contain, so path@thing is excluded rather than traded away.
  • The port lookahead gains \d+[/?#]. PORT_DIGITS never treated a slash as ending a port because nothing could cross one, which made :8443/v1 unreachable — and it is the commonest spelling of a base URL. Reachable and ungated, https://api.example:8443/v1 contact admin@example.com reads 8443/v1 contact admin@ as a userinfo and reports the host as example.com. Both costs are pinned by test.

On the PORT_END [Suggestion]: the contradiction was real, and the comment was wrong in a way worth more than a wording fix. ), ] and } are in the set while the text said brackets were deliberately absent. They are safe — but not for the reason the comment implied. What carries them is the (?![^\s]*\s[^@\s]*@) lookahead: :123)abc secret@host still finds its prefix because a later @ rejects the port reading, so a bracket only ends a port where there is no credential to keep.

Which then made the symmetric question answerable instead of hand-waved, and the answer surprised me. I added " and ' to the set to see what broke: all 79 tests passed. They are still not safe, because a quote can appear where that lookahead does not fire:

:123"abc secret word@host    second space before the @
:123"ab c secret@host        space inside the first word

Both fail the [^@\s]*\s[^@\s]*@ shape, so with quotes in the set the digits read as a port, no prefix is found, and the password is emitted. Two tests now pin them, and getting them to fail took one more step: they assert on the message rather than on JSON.stringify(result) like every other test here, because stringify escapes the " in the expected password and a not.toContain on the dump passes no matter what the sanitizer did. They're red with quotes added and green without.

Also added the missing ?/#-bounded authority rows to acpModelUtils.test.ts, in both directions — with a credential to strip, and without one, so a widening of findAuthorityEnd cannot quietly begin rewriting a host that was already clean.

Measurements. 7 new tests are red on the previous revision of this branch and green with the fix; the two quote tests are red with quotes added to PORT_END:

5 failed / 74 passed     previous source, new tests
81 passed                with the fix
36 passed                acpModelUtils.test.ts

The wider serve + utils selection fails the same 80 tests before and after — I diffed the failure sets rather than the counts. One test differed between the two runs, server.test.ts > createServeApp POST /workspace/reload passes validated client identity and refreshes cached serve features, and I checked it rather than dismissing it: it passes in isolation, and it fails intermittently on the unmodified tree as well, once in two runs of the same selection. tsc --noEmit, eslint and prettier --check are clean on all three files.

Comment on lines +503 to +505
const SLASHED_PASSWORD_PATTERN = new RegExp(
String.raw`^[^\s/?#'"\x60<>]*:(?!${PORT_DIGITS}|\d+[/?#])` +
String.raw`(?=[^?#@]*\s)[^?#]*?@(?=${HOST_AFTER_USERINFO})`,

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.

[Critical] SLASHED_PASSWORD_PATTERN (added by this PR) still leaks three classes of spaced/slashed credential that the removed code stripped. Each is probe- and A/B-confirmed against the merge base:

(a) Spaced password before a pathless one-character host — the host lookahead (?=${HOST_AFTER_USERINFO}) rejects a bare one-char host and the fallback cannot cross /, so nothing matches and findUrlEnd cuts inside the password. Failed loading provider https://user:a b/c@h retry later → unchanged (full credential); base → https://h retry later.
(b) # or ? inside a spaced password — every tail ([^/?#]*?@ / [^?#]*?@) cannot cross #/?, and unlike / nothing crosses them. Cannot reach https://user:pa#ss word@host.example/v1 retry → unchanged; base → https://host.example/v1 retry (the ? spelling leaks identically).
(c) First word is digits-then-/ — the \d+[/?#] port exclusion declines it. Failed loading provider https://user:123/abc secret@host.example/v1 → unchanged; base → https://host.example/v1. This exclusion is load-bearing (it protects :8443/v1 contact admin@), so it cannot simply be deleted.

— Failure scenario: each input reaches the /status payload with the full credential visible — the leak direction this file exists to prevent. None is among the documented tradeoffs.

Suggested fix: (a) give SLASHED_PASSWORD_PATTERN the fallback's one-char-host escape hatch (FALLBACK_HOST) under the same two gates; (b) give it (or a sibling) a userinfo tail that crosses #/? under the same gates; (c) gate \d+[/?#] the way PORT_DIGITS gates its bracket case — read it as a port only when no spaced credential follows it. Pin each input with a regression test.

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment on lines +620 to +622
if (fallback[0].length >= credentials[0].length) return false;
const between = body.slice(fallback[0].length, credentials[0].length);
return (between.match(/\s/g) ?? []).length >= 2;

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.

[Critical] prefersFallback's "≥2 spaces means prose" rule rewrites the host for a pathless single-character host followed by exactly one prose word and an email. Cannot reach https://user:pass@h admin@example.comCannot reach https://example.com (host rewritten hexample.com, contact prose deleted); base produced https://h admin@example.com. A/B-confirmed regression.

This is not the documented one-space tradeoff: the pinned p@s s@host.example case resolves in favour of a later host that has structural evidence (a path via DELIMITED_HOST), whereas here the later "host" example.com is a prose email with no path/port/query. This one-word shape is not test-pinned — the closest test ('keeps a one-character host when prose after it contains an @') uses — contact between @h and admin@, i.e. ≥2 spaces, a different code path.

— Failure scenario: the oncall sees a wrong server name (example.com) and the contact line is deleted.

Suggested fix: also prefer the fallback when its host candidate is a one-character bare label immediately followed by whitespace (the shape the rest of this PR deliberately supports), rather than gating purely on a ≥2-space count; at minimum pin this spelling so the one-space resolution is a deliberate choice.

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

const CREDENTIAL_PREFIX_PATTERN = new RegExp(
// `\x60` is the backtick, spelled as an escape because a literal one cannot
// appear in the template and `\`` is not a valid escape under the `u` flag.
String.raw`^[^\s/?#'"\x60<>]*:(?!${PORT_DIGITS})[^/?#]*?@(?=${HOST_AFTER_USERINFO})`,

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.

[Critical] The credential patterns' username class [^\s/?#'"\x60<>]* admits : and @, so the greedy quantifier consumes past the credential's own @ and re-anchors on a later colon; a trailing prose email's @ then becomes the strip boundary — rewriting the host and deleting prose. Two probe- and A/B-confirmed triggers:

(a) Bracketed-IPv6 internal colons: Error at "https://admin:s3cr3t@[::1]:8443" - email "ops@company.com" for helpError at "https://company.com" for help; base preserved [::1]:8443.
(b) A quoted credentialed URL with a port followed by an unquoted prose email — the closer's first check bails on upTo.includes('@') and the walk-left finds no second closer, so the whole span reaches findUrlEnd and the greedy username swallows user:token@host.example via the port colon: "https://user:token@host.example:8443" — contact admin@example.com"https://example.com; base preserved https://host.example:8443. The quoted-email sibling is test-pinned (line ~1351); this is the unquoted-email hole in that same guard.

— Failure scenario: the reported host becomes the email's domain and the contact prose is deleted — "the failure this file works hardest to avoid."

Suggested fix: exclude @ (and :) from the username class in all three credential patterns and CREDENTIALED_AUTHORITY_AT_END[^\s/?#'"\x60<>:@]*. Verified: this flips both outputs to correct and all 81 tests still pass. (For (b) you may additionally admit CREDENTIALED_AUTHORITY_AT_END to the closer's first check so the span is truncated before any pattern sees the prose.)

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment on lines +603 to +605
const prefix = credentials ?? SLASHED_PASSWORD_PATTERN.exec(body);
const from = prefix ? markerLength + prefix[0].length : markerLength;
const space = segment.slice(from).search(/\s/);

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.

[Critical] A password whose first character is / is recognised by no credential pattern; findUrlEnd falls to the first-space cut and hands the whole URL to sanitizeProviderBaseUrl, where new URL parses without throwing (hostname app, pathname /secret@…, empty username/password), so the if (parsed.username || parsed.password) branch is skipped and the URL is returned unchanged — full leak.

failed: https://app:/secret@svc.local:8443/api retry → unchanged (credential app:/secret present); base stripped to https://svc.local:8443/api retry. A/B-confirmed regression; the same leak hits https://user:/token@host.example/v1. Non-leading-slash passwords still strip (there new URL throws and the catch-path fallback fires). No test covers a slash-leading password.

— Failure scenario: the full credential reaches the /status payload.

Suggested fix: in sanitizeProviderBaseUrl (acpModelUtils.ts), do not unconditionally return baseUrl when new URL succeeds with empty userinfo — also consult the already-present findUnescapedUserInfoFallbackAt(baseUrl, authorityStart, authorityEnd) in that branch and stripAt when it returns an index (guarding its /^\d+$/ port test so user:123/…@host is not mistaken for host:port). Verified to flip the output without breaking existing cases.

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

Comment on lines +252 to +254
// A pathless authority bounded by `?` or `#` rather than `/`. The rows above
// pin `?`/`#` inside the *userinfo*; these pin them as the authority
// terminator, which is the direction `findAuthorityEnd` exists for. Drop

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.

[Critical] findAuthorityEnd bounds the authority on /, ?, # only and misses \, which WHATWG treats as / for special schemes. The new warning-path delegation to sanitizeProviderBaseUrl makes the resulting host rewrite newly reachable from free-text messages.

Failed loading provider https://user:secret@api.example\@evil.com/ nowFailed loading provider https://evil.com/ now (host rewritten api.exampleevil.com); base preserved the host (https://api.example\@evil.com/ now). A/B-confirmed regression introduced by the delegation: the deleted sanitizeProviderWarningSegment cut at the first @ and never consulted sanitizeProviderBaseUrl for this input. (The direct model.baseUrl path was already exposed pre-PR; the warning path is the new reachability.) These new test rows pin ?/# as authority terminators but not \, so neither suite catches it.

— Failure scenario: the reported host becomes evil.com — the exact failure the JSDoc this PR adds to sanitizeProviderBaseUrl calls load-bearing.

Suggested fix: in findAuthorityEnd, also min in baseUrl.indexOf('\\', authorityStart); add a pin row such as ['https://user:secret@api.example\\@evil.com/', 'https://api.example\\@evil.com/'] alongside the ?/# rows. Verified: flips the output and keeps all 117 tests green.

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

expect(result.errors?.[0]?.error).toBe(
`Cannot reach ${open}https://host.example/v1${close} — retry`,
);
expect(JSON.stringify(result)).not.toContain(`pa${close}ss`);

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.

[Suggestion] The not.toContain credential-leak guard is vacuous for the " parametrization: JSON.stringify escapes " to \", so a regression leaking pa"ss renders as pa\"ss and not.toContain('pa"ss') (searching for the unescaped substring) still passes — probe-confirmed. The test is saved only by its exact .toBe on the full error string; if that were ever relaxed, the " leak would ship uncaught. Affects this line and the hardcoded not.toContain('pa"ss') at line 1383.

— Concrete cost: the assertion pins nothing for the " case. The other five parametrizations (', backtick, <, (, [) are not escaped by JSON.stringify, so their guards work correctly.

Suggested fix: assert on the message directly — expect(result.errors?.[0]?.error).not.toContain(pa${close}ss) (and likewise on result.errors?.[0]?.error at line 1383) — or drop the redundant line since the .toBe already covers it.

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

* thinks the authority ends is load-bearing for a caller in another directory.
* Narrowing `findAuthorityEnd` still strips the credentials, but it rewrites the
* host from whatever follows: `https://u:p@host.example:8443?k=v@evil.com`
* reports `evil.com`. Both suites pass under that change; the two

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.

[Suggestion] This comment claims "Both suites pass under that change" (narrowing findAuthorityEnd to drop the ?/# bounds), but the acpModelUtils.test.ts rows added in this same diff fail (3 tests) under that change — mutation-confirmed. A maintainer following the guidance would observe failures the comment implies cannot occur, and would misunderstand which tests guard the cross-file invariant.

— Concrete cost: misleading mutation-testing guidance on a security-sensitive contract; the comment names only the two holds an @ cases in serve/workspace-providers-status.test.ts as the catch set, when the collocated acpModelUtils.test.ts rows catch it too.

Suggested fix: reword to note that the rows added here also catch it, e.g. "Before the rows above were added, both suites passed under that change; those rows and the two holds an @ cases in serve/workspace-providers-status.test.ts are what catch it."

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

@LHMQ878

LHMQ878 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

I reproduced all five [Critical] findings and confirmed the A/B claim on each. They are all real, and they are all regressions against the merge base. I'm not going to attempt a seventh fix round — I think the right call is to close this PR, and I'd rather say that myself than have it argued out of me.

Verification. I drove all ten inputs through the public path (createWorkspaceProvidersStatusProviderprovider(workspace, true), the same harness the existing tests use), at HEAD b3db8ed93 and again with only workspace-providers-status.ts and acpModelUtils.ts reverted to the merge base 6a432ad:

# input base HEAD b3db8ed93
R1-1a …user:a b/c@h retry later https://h retry later unchanged — leaks a b/c
R1-1b …user:pa#ss word@host.example/v1 https://host.example/v1 unchanged — leaks pa#ss
R1-1b′ …user:pa?ss word@host.example/v1 https://host.example/v1 unchanged — leaks pa?ss
R1-1c …user:123/abc secret@host.example/v1 https://host.example/v1 unchanged — leaks 123/abc, secret
R1-2 …user:pass@h admin@example.com https://h admin@example.com https://example.comhost rewritten, prose deleted
R1-3a "…admin:s3cr3t@[::1]:8443" - email "ops@company.com" https://[::1]:8443 preserved https://company.comhost rewritten
R1-3b "…user:token@host.example:8443" — contact admin@… https://host.example:8443 preserved "https://example.comhost rewritten, truncated
R1-4 …app:/secret@svc.local:8443/api retry https://svc.local:8443/api retry unchanged — leaks /secret
R1-4′ …user:/token@host.example/v1 retry https://host.example/v1 retry unchanged — leaks /token
R1-5 …user:secret@api.example\@evil.com/ now host preserved https://evil.com/ nowhost rewritten to the attacker's

Ten for ten: clean at base, broken at HEAD. Six are outright credential leaks into the /status payload, which is the one thing this file exists to prevent. R1-5 rewrites the reported host to evil.com.

The suite does not catch any of them. 117 tests pass at HEAD across both files. So the tests I added over six rounds encode the cases I thought of and are blind to every one of these — which is the strongest argument that the tests aren't the problem.

Net effect is negative, and I checked that rather than assuming. The two bugs this PR was opened for are real, and base does get them wrong:

base HEAD
https://api.example:8443/v1 — contact admin@example.com https://example.com (mangled) correct
https://user:p@ssw0rd-tail@broken.example/v1 leaks ssw0rd-tail correct

So this is 2 fixed against 6 new leaks and 4 host rewrites. Trading two known failures for ten unknown ones is a bad trade in a sanitizer, and it's the trade currently on the table.

Why I think it can't be patched into shape. The shape of the last six rounds is the argument: 14 commits and +1478/−31 to replace 31 lines, and round 6 still finds five Criticals of the same kind as rounds 1–5. Each fix widens a regex or adds a gate, and each widening creates a new interaction with the others — HOST_CHAR, LABEL_CHAR, DELIMITED_HOST, FALLBACK_HOST, three credential patterns, CREDENTIALED_AUTHORITY_AT_END, prefersFallback's space-counting heuristic, plus findAuthorityEnd and findUnescapedUserInfoFallbackAt across the file boundary. Every finding above is an interaction between two or more of those. That's not a list of bugs to fix; it's the wrong decomposition. Free-text credential redaction by regex over an un-delimited span has no correct fixed point, and my indexOf-replacement premise — that a real host grammar would be strictly better than base's accidental one-character class — is what's wrong. Base is imprecise in ways that happen to fail safe; my version is precise in ways that fail open.

What I'd suggest instead, if the two original bugs are worth fixing:

  1. Fix them in sanitizeProviderBaseUrl alone, where the input is a single URL and the authority is genuinely delimited — no free-text span-splitting, no host grammar. That addresses MOTIV-1 and MOTIV-2 above without touching the warning path.
  2. Keep the free-text path deliberately blunt and fail-safe: if a span between two :// markers contains an @ at all, drop the whole span rather than trying to locate the userinfo inside it. Over-redaction in a warning message is a cosmetic cost; under-redaction is a credential leak.

Closing this. Thanks to the reviewer for the A/B discipline — attaching the base comparison to every finding is what made this diagnosable instead of arguable, and it's why I could confirm rather than debate all five. Happy to open a narrow PR for (1) if that's wanted; I'll leave (2) to whoever owns this file, since it's a policy decision about over- vs under-redaction rather than a bug fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider warning sanitizer truncates messages containing a port, and leaks a password containing @

2 participants