Skip to content

fix(logging): redact credentials embedded in a URL's userinfo before logging - #640

Merged
kshivang merged 2 commits into
risa-labs-inc:devfrom
nikhil-jangir-iitk:fix/log-uri-userinfo
Sep 15, 2026
Merged

kshivang merged 2 commits into
risa-labs-inc:devfrom
nikhil-jangir-iitk:fix/log-uri-userinfo

Conversation

@nikhil-jangir-iitk

@nikhil-jangir-iitk nikhil-jangir-iitk commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

LogSanitizer.maskUriParams is the masker used before a URL is logged, at ~22 call sites (git clone, browser navigation, deep links). It masked only sensitive query and fragment parameters and returned the rest of the URL verbatim - including the userinfo component, scheme://user:password@host, where a credential is routinely carried:

  • a private HTTPS git remote is https://x-access-token:<token>@github.com/..., and DesktopGitService.cloneRepository logs the clone URL at INFO ("Starting git clone") and later branches on repositoryUrl.contains("@") for its auth-failure message - so the URL it logs is expected to carry a token;
  • a URL with HTTP basic-auth userinfo navigated to in the browser reaches the same masker through nav.url / url.

So https://x-access-token:ghs_...@github.com/o/r.git was written to the log, and to the plugin-readable log capture, in cleartext.

Fix

redactUserInfo replaces the userinfo with [REDACTED], keeping the scheme, host, port and path: https://x-access-token:ghs_...@github.com/o/r.git -> https://[REDACTED]@github.com/o/r.git.

Only an @ inside the authority is treated as a userinfo delimiter. The authority ends at the first /, ? or # after ://, so:

  • an @ in a path (/@handle) or in a query value (an email, ?to=a@b.com) is left alone;
  • a string with no :// (a mailto: address) is returned unchanged;
  • the whole userinfo is redacted, not just the part after :, so a bare-token userinfo (https://ghp_...@host) is covered too.

The query index is now read off the redacted string rather than the original, because the redaction can change the string's length; a userinfo credential and a sensitive query parameter in the same URL are both removed.

Worth a reviewer's attention

  • The redaction runs first, then the existing query/fragment passes. redactUserInfo returns the original string untouched whenever there is no :// or no @ in the authority, so every URL that has no userinfo is byte-for-byte unchanged - the existing behaviour is preserved for everything the masker already handled.
  • maskUriParams stays purely text-based (no URI parsing), matching the rest of the function: it must not throw on a malformed input and must keep the path/query for debugging.

Verified

  • Red first: UriUserInfoTest (10 tests) against the unchanged maskUriParams fails 6 of 10 (the userinfo-redaction cases); the 4 negative cases (email in query, @ in path, mailto:, plain URL) already pass, proving the fix does not over-redact.

  • Full :plugin-platform:plugin-logging:desktopTest passes (32 tests, 0 failures, including the module's publish-path guard tests), and the composeApp LogSanitizerTest, which exercises the re-export delegate, passes (80, 0). ktlintCheck and detekt are clean.

  • Mutation-checked, each mutation caught by a named test:

    Mutation Tests that caught it
    userinfo redaction removed 6 (every redaction case)
    authority boundary ignored (whole URL scanned for @) 2 (@ in path, @ in query)
    query index read off the original URL, not the redacted one 1 (userinfo + query in one URL)
    only the value after : redacted, username kept 5

Not covered (found while doing this, separate changes)

  • The free-text path (sanitizeLogMessage / sanitizeStackTrace) still leaves a userinfo password in a bare proxy message such as Failed to connect to proxy http://alice:pass@10.0.0.5:3128: filePathPattern consumes //alice before urlPattern can match the whole URL, leaving http:[PATH]:pass@10.0.0.5. That is a different function with a pass-ordering interaction and is left for a separate change.
  • maskUriParams' sensitive-parameter allow-list (10 names) is unchanged here; broadening it (e.g. client_secret, presigned-URL signatures, SSO ticket) is a separate question about which names count as sensitive.

Contributor follow-up (2026-09-14)

@ninja-lifts supplied commit 695d8a2a addressing the previous review: every plain nested URL is examined; free-text clone progress goes through redactUrlUserInfo; last-@, no-path and malformed authority boundaries are pinned. New tests include inherited GIT_TRACE and server-echo canaries. These repairs and all implementation/tests are contributor work, not reviewer code changes. The original description above records the initial submission; clone output and plain nested URLs are now covered by this follow-up. Protocol-relative and percent-encoded nested URLs, literal ampersands in nested userinfo, and browser backslash normalization remain documented limitations.

Rebase onto dev (2026-09-15)

  • Rebased both commits onto dev d22d2bfeb. The first applied cleanly. The second conflicted in DesktopGitService.cloneRepository with fix(git): make clone timeout cancellable #660, which moved the clone loop into runCloneProcess with a cancellable timeout.

  • Resolution: dev's fix(git): make clone timeout cancellable #660 loop is kept as it is, and its progress callback's debug line goes through cloneProgressLogMessage, the same one-line change this PR made to the old loop:

    logger.debug(LogCategory.GENERAL, cloneProgressLogMessage(progressLine))

    GitCloneProcess.kt (new in fix(git): make clone timeout cancellable #660) has no logger calls, so that line is still the only place clone output reaches a log. The diff against dev is the same six files, +430/-5, as before the rebase.

  • Verified on the rebased head: :plugin-platform:plugin-logging:desktopTest 48, 0 failures; :composeApp:desktopTest for ai.rever.boss.utils.logging.* and ai.rever.boss.git.* 233, 0 failures, including GitCloneProgressLogTest, whose source assertion pins that exact call; ktlintCheck and detekt clean for both modules.

  • Collision check: a 3-way merge of the six files against every open PR that changes them (37 PR/file pairs) adds 0 conflicts, except against fix(logging): keep query and fragment secrets out of browser URL log lines #691's current remote branch, which still carries a reviewer merge of an older dev clone loop; fix(logging): keep query and fragment secrets out of browser URL log lines #691's rebased branch does not touch DesktopGitService.kt, and merging it with this branch gives 0 conflicts.

  • The low-severity review notes are unchanged, as agreed in the review.

Morning review boundary (2026-09-15)

The overlapping marker shape https://host://u:p@in/ is not scanned as a second URL because :// starts one character before the outer authority end. The u:p@in text is a path in the outer URL, not its navigation credentials. This unusual nested/free-text shape remains an explicit limitation; this is not a universal sanitizer for arbitrary credential-shaped path text. The current clone output reader in GitCloneProcess.kt has no logging calls, and both live output and the trailing drain use the redacting callback. A future logging sink there would need its own regression guard.

Current-head review: #640 (comment) . All implementation and contributor follow-ups remain @ninja-lifts's work; this batch made no source changes.

@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head for correctness, credential redaction coverage, regressions, and integration with dev.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 45s —— View job


Review: PR #640 - redact userinfo before logging

  • Gather context (diff vs origin/dev, full LogSanitizer, all 22 call sites)
  • Correctness of redactUserInfo
  • Credential redaction coverage
  • Regressions (existing tests, composeApp delegate, publish-path guards)
  • Integration with dev

Verdict

The change is correct and I found no regression. The premise checks out too: I confirmed BossLogger applies no sanitizer of its own to the data map, so whatever maskUriParams returns is what lands in the log file and in the plugin-readable capture - the userinfo really was written in cleartext.

The one thing I'd want resolved before calling the git-clone case covered is finding #1 below: the same function fixed here logs the token anyway, ~100 lines later, from git's own stderr.

Correctness - redactUserInfo (LogSanitizer.kt:153-171)

I traced every index by hand; the logic holds, including the parts that are easy to get wrong:

  • lastIndexOf('@', authorityEnd - 1) is an inclusive backward search in Kotlin, so an @ sitting at the last authority character is found. That is the no-path case https://user:pass@host, where an exclusive bound would have missed it. Correct as written.
  • No negative-index hazard: authorityStart = schemeEnd + 3 >= 3, so authorityEnd - 1 >= 2 always, and substring(0, authorityStart) can never exceed the length (the :// was found at schemeEnd).
  • The at < authorityStart guard is load-bearing, not defensive noise: the backward search runs past the authority into the scheme when the authority is empty (a@b:///x -> at = 1, authorityStart = 6), and without the guard that would splice at the wrong offset.
  • lastIndexOf rather than indexOf is the right choice and I'd keep it explicitly - RFC 3986 makes the last @ in the authority the delimiter, so https://user:p@ss@host/ redacts the whole userinfo rather than leaving ss behind. That is not stated in the KDoc and is worth one clause, since a future "simplification" to indexOf would look harmless and silently leak a password fragment.
  • The "must not throw" property is preserved: there is no input reaching a substring out of range, so the catch that degrades the whole URL to [uri-mask-error] is not newly reachable.
  • Recomputing queryStart off result is required and correct.

The char-by-char authority scan reads a bit un-idiomatic next to indexOfAny, but it is allocation-free and this file's own header comment ("every log line and every crash report is put through them, so none of this may be rebuilt per call") is the reason to leave it alone. No change wanted.

Coverage gaps

1. [High] The clone token still reaches the log, from git's own output. DesktopGitService.kt:2295 sets redirectErrorStream(true), and DesktopGitService.kt:2337 logs each line raw:

logger.debug(LogCategory.GENERAL, "Clone progress: $progressLine")

git prints the remote with userinfo intact in fatal: unable to access 'https://x-access-token:ghs_…@github.com/o/r.git/': … (this is the well-known CI leak that GitHub Actions masks for you). Since BossLogger does not sanitize message text, that line is written verbatim at DEBUG. So for the exact scenario this PR's description is built on - a private HTTPS clone - the token is removed from the "Starting git clone" line at :2237 and then written in full a moment later on failure. The PR's "Not covered" section names the free-text path in the abstract; this concrete instance is the same call site the fix was written for, and a reader will reasonably assume it is handled. Note that sanitizeLogMessage is not a drop-in fix here, for precisely the pass-ordering reason the PR already documents. Fix this →

2. [Medium] A nested URL in a query value keeps its credential. Only the first authority is examined, so:

https://sso.example/login?next=https://u:p@internal/   ->  unchanged

next / redirect_uri / continue are not in sensitiveUriParamNames, so neither pass touches it. Browser-navigation logging (BrowserHandleImpl.kt:2960, :2974, :2994) is exactly where a second URL rides inside the first. Scanning every :// occurrence instead of indexOf is a small change and would close it. Fix this →

3. [Low] The KDoc states a coverage hole as if it were a safety property. "a URL with no :// … is returned unchanged" is true, but it is also why a protocol-relative //user:pass@host/path is skipped entirely. Not reachable from the named call sites (JxBrowser and the deep-link handlers hand over absolute URLs), so I would document it rather than handle it - the sentence currently reads as an exhaustive account of what is deliberately left alone, and #2 and #3 both belong in that list.

4. [Low, cosmetic] An empty userinfo is reported as if redacted. https://@host/p -> https://[REDACTED]@host/p, making a credential-free URL look like it carried one. Harmless, but at > authorityStart would avoid it at no cost.

5. [Low, edge] \ is not treated as an authority terminator. Per WHATWG, browsers parse https://evil.com\@good.com/x with host evil.com; this redacts it to https://[REDACTED]@good.com/x, hiding the host actually navigated to. That is a loss of the diagnostic that matters most in the one case that shape occurs (a phishing URL), so it is worth a line in the KDoc even though I would not add parsing for it.

Regressions - none found

  • The no-userinfo path is byte-identical. redactUserInfo returns the argument object unchanged whenever there is no :// or no authority @, so every URL the masker already handled is untouched.
  • No existing test uses an @ in a URI - I grepped LogSanitizerTest, QueryParameterSanitizerTest and UriFragmentOrderTest. Nothing to break.
  • The publish-path guard is unaffected. LoggingStableFieldTest only asserts the $stable field is present on the listed classes; a new private function changes neither the public API nor the POM, so the boss-plugin-api shadowing contract AGENTS.md describes is intact.
  • One implementation, not two. composeApp/src/commonMain/kotlin/ai/rever/boss/utils/logging/LogSanitizer.kt:23 is a pure delegate, so there is no second copy to keep in sync, and its output is display/log-only at every call site (GlobalSearchDialog.kt:310 deliberately dispatches the real URL to the handler and only the masked one to the log). Good.
  • One follow-up question rather than a finding: the boss-plugin-api jar ships its own copy of ai.rever.boss.plugin.logging. Inside BOSS's plugin classloaders this module shadows it parent-first, so plugins get the fix - but a consumer of the published api jar (the standalone boss-microkernel-runtime repo) would not. Worth a moment's thought on whether that copy needs the same change.

Integration with dev

Clean. origin/dev (f91633d) is an ancestor of HEAD; the branch is exactly one commit ahead, with no divergence and no dev commits touching plugin-platform/plugin-logging. Nothing to rebase.

Tests

UriUserInfoTest is well-built - the four negative cases are what make the six positive ones trustworthy, and the "userinfo + sensitive query parameter in one URL" case pins the index recomputation that would otherwise be an invisible coupling. Two cases I would add: the no-path form https://user:pass@host (it is the one that depends on lastIndexOf's inclusive bound, and a - 1 typo there would pass every current test), and the double-@ form https://user:p@ss@host/ (pins lastIndexOf against a future switch to indexOf).

What I could not verify: Gradle is not runnable in this environment (command approval denied), so I reviewed by reading and hand-tracing rather than executing. I did not independently reproduce the 32/0 and 80/0 test counts, the ktlint/detekt results, or the mutation table - CI covers those.
• branch fix/log-uri-userinfo

@kshivang

Copy link
Copy Markdown
Contributor

@ninja-lifts Thank you for identifying the concrete userinfo leak and supplying focused positive/negative tests. The authority redaction and query-index recomputation are correct. I independently compiled the actual sanitizer plus host delegate: all 10 new tests and 80 existing host tests pass. Against current dev, exactly the six userinfo cases fail, reproducing your red/green evidence.

I am moving this PR to draft for a bounded follow-up, with your original commit and authorship unchanged. The latest Claude review identified an end-to-end gap I verified at DesktopGitService.kt:2337: Clone progress: $progressLine logs merged Git stdout/stderr verbatim. A fatal diagnostic containing the remote can therefore log the same userinfo credential at DEBUG after the starting-URL log has been fixed. This is a pre-existing exposure, not a regression introduced by your helper, but it leaves the motivating clone flow incompletely covered. The existing sanitizeLogMessage is not sufficient because of the pass-ordering defect you already documented. Completing this safely needs a tested free-text URL redaction path rather than feeding arbitrary process output to a single-URI helper.

Actionable remaining work:

  • Redact userinfo in Git progress/failure output before the logging sink; preserve useful failure text, host and port. Add a synthetic fatal Git stderr canary test and a call-site/sink assertion, including multiple URLs. Keep process/progress behavior unchanged. Issue Clone Project can hang past its timeout when Git output is silent #641 concerns clone timeout behavior in this same method and is distinct read-only overlap context.
  • Cover nested absolute URL userinfo such as https://sso.example/login?next=https://u:p@internal/; the current first-authority scan leaves it intact. Add nested-URL coverage without regressing outer query/fragment masking or non-secret email/path values.
  • Pin the currently correct no-path and repeated-@ cases with tests. Document protocol-relative inputs and WHATWG backslash normalization as boundaries; they are not regressions at the named absolute-URL call sites. Keeping empty-userinfo redaction is harmless and conservative; I do not consider that cosmetic suggestion a blocker. Do not weaken last-@ masking.

Validation status is separate from author defects: I approved the existing fork CI run https://github.com/risa-labs-inc/BossConsole/actions/runs/34798899078. Quality, shell, version, AI-contract and database jobs passed; platform builds/compatibility were still running at this checkpoint. Local module desktopTest, ktlintCheck and detekt are queued behind the shared build lock, not failing. Focused 90-test evidence does not replace module Compose/ABI guards or full CI. After the fixes, run those module checks plus the host LogSanitizer tests and require current-head CI to pass; refresh dev and confirm clean integration before returning to ready.

Current head: 919359721cd50483865e22e70e1c5f1d1edf6db9. Current main/dev logging baselines are identical; dev merge-tree is clean. #471/#541 are already-integrated distinct fixes; draft #567 and file-logging #544 are separate work, not duplicates. No contributor code was replaced or pushed by this review. Claude was requested once and its completed review was inspected; no repeat request is needed until substantive follow-up changes warrant one.

@kshivang
kshivang marked this pull request as draft September 14, 2026 04:06
@kshivang
kshivang force-pushed the dev branch 2 times, most recently from 7dfb90e to a3f4b7f Compare September 14, 2026 08:43
@nikhil-jangir-iitk

Copy link
Copy Markdown
Contributor Author

@kshivang Thanks for the bounded list. All three items are addressed in a new commit, 695d8a2a, on top of the original one, which is unchanged. The branch is rebased onto dev at a3f4b7f0 with no conflicts, and merges cleanly with current dev (3e09b575, one unrelated commit later). Current-head CI will need approval again.

1. Git clone output

Before changing anything I checked what git actually prints. On git 2.43, against a local HTTP server, a clone URL with a token produced no credential in any fatal: line for 404, 401, 403, 500, redirect, unresolvable host or refused connection. git has anonymized these since 2.22 (commit c1284b21; the 2.22.0 release notes say remote-http "did not anonymize URLs reported in its error messages at places"). So the exact line in the review should only appear on git older than 2.22.

The leak is still real through three other sources, all merged into the same stream:

  • GIT_TRACE=1 inherited from the environment (cloneRepository copies System.getenv() into the child). On git 2.43 this printed the full tokened URL 4 times (trace: built-in: git clone --progress http://x-access-token:<token>@...).
  • Text the server sends back, printed verbatim after fatal: remote error:.
  • git older than 2.22, as above.

One limit on reach: these lines are logged at DEBUG, and BossLogger.log drops DEBUG at the default INFO level. So this needs BOSS_LOG_LEVEL=DEBUG or dev mode, which is also when someone debugging a failed clone would turn on GIT_TRACE.

Fix: the loop now logs cloneProgressLogMessage(progressLine), which calls a new LogSanitizer.redactUrlUserInfo. It removes only userinfo, for every URL in the line, so the failure text, host and port stay readable. In free text an authority also ends at whitespace, so 'https://u:p@github.com': contact admin@corp.example keeps the email. Process and progress behaviour is unchanged.

Tests: UrlUserInfoInTextTest (8, including a pre-2.22 fatal: line, the measured GIT_TRACE line, a server echo, multiple URLs in one line, ordinary progress lines unchanged, and malformed input) and GitCloneProgressLogTest (3: the synthetic fatal-stderr canary with two URLs, an ordinary line, and a source assertion that the loop logs through the redacting message and no logging call interpolates the raw line).

2. Nested URLs

Every :// is now examined, so https://sso.example/login?next=https://u:p@internal/ becomes ...next=https://[REDACTED]@internal/. A nested URL's authority also ends at &, so ?next=https://internal.example&contact=a@b.com keeps the email, and outer query/fragment masking still runs afterwards. Covered for query, fragment, no-path-then-&, and nested plus an outer token=.

3. Pins and boundaries

Pinned https://user:pass@host and https://user:p@ss@host/; last-@ masking is unchanged. Empty userinfo is still redacted. Documented in the KDoc as not handled, each checked by running it: input without :// (protocol-relative), a percent-encoded nested URL, a nested URL whose userinfo holds a literal &, and \ (logged as https://[REDACTED]@good.example/x, hiding evil.example).

One correction to the review: the no-path case does not depend on lastIndexOf's inclusive bound, because a host follows the @. Moving the search start one character earlier still passed it. The bound matters only when @ is the last authority character, so I added https://user:pass@/path for that; it catches the mutation.

Verification (final head, Windows, JDK 17)

  • Red first: against pass-through stubs, 4 of 17 UriUserInfoTest, 6 of 8 UrlUserInfoInTextTest and 2 of 3 GitCloneProgressLogTest fail. (The 18th UriUserInfoTest, the @-as-last-character case, was added after and pins behaviour that was already correct.)

  • :plugin-platform:plugin-logging:desktopTest 48, 0 failures (includes LoggingStableFieldTest). composeApp LogSanitizerTest 80/0, BossLoggerTest 20/0, GitCloneProgressLogTest 3/0.

  • ktlintCheck and detekt clean in both modules (source-set checks forced to rerun, not cached).

  • 12 mutations, all caught:

    Mutation Caught by
    no redaction 19 tests
    only the first :// examined 5
    whitespace does not end a free-text authority 1 (email after a no-path URL)
    & does not end a nested authority 1 (email after a nested URL)
    first @ instead of last 2
    backward search starts one character early 2 (@ as last authority character)
    authority-start guard dropped 1 (malformed input)
    copy position not advanced 19
    /, ?, # do not end an authority 8
    query index read off the original URI 1
    clone loop logs the raw line 1 (source assertion)
    message helper does not redact 1 (canary)

I did not run the full composeApp suite locally, only the tests above, so full CI on this head is still the gate.

Not changed

sanitizeLogMessage's pass-ordering issue from the PR description, and the copy of ai.rever.boss.plugin.logging inside the published boss-plugin-api jar, are both untouched here.

@nikhil-jangir-iitk
nikhil-jangir-iitk marked this pull request as ready for review September 14, 2026 09:38
@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review contributor update 695d8a2 against current dev, especially the clone progress sink, multiple/nested URL scan, and the new regression tests. The previous handoff was addressed by @ninja-lifts; please assess the new code rather than carrying forward the old draft conclusion. Fork CI run 34828969760 has been approved. The published API copy and broad free-text sanitizer ordering are explicitly outside this change; the host facade delegates to the updated parent-first host module.

@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 7m 54s —— View job


Review: contributor update 695d8a2a

  • Gather context (diff vs base, full LogSanitizer, clone sink, all call sites)
  • Correctness of the multi/nested-URL scan
  • Clone progress sink
  • New regression tests
  • Regressions and integration

Verdict

Assessed fresh, not carried forward. All three handoff items are done and the implementation is correct. I hand-traced every index in redactUserInfo and every one of the 28 new expectations; they all hold, including the ones that are easy to get wrong. I found no blocking defect. Four low-severity notes below, none of which I would hold the PR for.

One thing from my previous review I should correct, because @ninja-lifts is right and I was not: the no-path case https://user:pass@host does not depend on lastIndexOf's inclusive bound - the host follows the @, so a - 1 typo still finds it. The bound matters only when @ is the last authority character, and the case they added for it (https://user:pass@/path, plus three shapes in the malformed-input test) is the right pin.

The scan itself (LogSanitizer.kt:180-226)

The rewrite from single-authority to whole-string is the part that could have introduced a bug, and it does not:

  • The loop terminates. schemeEnd' = indexOf("://", authorityEnd) and authorityEnd >= schemeEnd + 3, so the search origin strictly increases.
  • copiedUpTo strictly increases and never overruns. It is only assigned at, and at < authorityEnd <= schemeEnd', so the next authorityStart = schemeEnd' + 3 > copiedUpTo. Every append(text, copiedUpTo, authorityStart) is therefore in range and in order - this is the one invariant a "copy position not advanced" mutation would break, and it is the mutation their table says 19 tests catch.
  • It cannot throw. authorityStart <= length (the :// was found), findAuthorityEnd returns within [start, length], lastIndexOf(c, idx) is clamped, and the final append is in range. That matters because redactUrlUserInfo is called from the clone loop outside maskUriParams' try/catch, so totality is load-bearing rather than incidental. It is total - see note 1 below for the convention point.
  • at >= authorityStart is still load-bearing and is what makes the backward search safe when the authority is empty; @:// exercises it.
  • ampersandEnds = nested && !freeText is the right gate. The first authority in a URI ends at ? before any & can matter, so applying the rule to it would only misfire on non-URL input; restricting it to nested authorities is correct, and ?next=https://internal.example&contact=a@b.com is exactly the case that proves it.
  • The nested flag is set unconditionally, not only on a redaction - correct, since it means "we are past the outer authority", not "we redacted".

I traced x://@@ :// -> x://[REDACTED]@ :// by hand as the worst-shaped input in the suite; the two-iteration copy bookkeeping is right.

Clone progress sink (DesktopGitService.kt:2224, :2347)

The plumbing is correct and, as far as this file goes, complete:

  • The clone loop was the only sink in DesktopGitService that put raw process output into a logger. I grepped every logger.* call against errorMsg / result.output / result.error and there are none - every other failure path logs a canned message plus an exit code.
  • The onProgress callbacks in the same loop never carry the raw line either: they emit fixed strings or Regex("(\\d+)%")'s match, so nothing raw reaches the UI progress surface.
  • The child's stdout is read by the BufferedReader, not inherited, so GlobalLogCapture sees only the redacted, formatted line.

I also verified the reach claim independently rather than taking it on trust: BossLogger.log (BossLogger.kt:420-424) checks entry.level.priority < effectiveLevel.priority and returns before recentLogs.addLast, the SLF4J call and the file queue. So a DEBUG line at the default INFO level reaches neither the file nor the plugin-readable capture, and the exposure really is bounded to BOSS_LOG_LEVEL=DEBUG / dev mode - which, as they say, is the same session in which someone would have GIT_TRACE on.

The GIT_TRACE justification is sound, and worth one footnote: environment().putAll(System.getenv()) at :2303 is a no-op - ProcessBuilder.environment() is already seeded from the parent - so the trace variable is inherited whether or not that line is there, and removing it would not close the vector. Neutralising GIT_TRACE* for the child would be genuine defence in depth, but it changes process behaviour, which this handoff explicitly asked to keep unchanged. Redacting at the sink is the right layer for this PR.

Tests

UriUserInfoTest (18) and UrlUserInfoInTextTest (8) are good. The negative cases carry the weight - contact=a@b.com after a nested authority, @handle in a path, git@github.com:o/r.git (scp-style, no ://), and the six ordinary progress lines - and the CANARY markers in the free-text and canary tests mean a partial redaction fails loudly rather than passing on a substring match.

GitCloneProgressLogTest: the canary test with two URLs in one line is the right shape, and the source assertion is a reasonable way to pin the join a unit test cannot reach. Source-reading tests are established here (SupabaseWiringTest, OsOpenArgumentsTest, EngineBundlePlistStripTest), so this matches convention rather than inventing one.

Notes (none blocking)

1. [Low] redactUrlUserInfo is the only public function in LogSanitizer with no try/catch. Every sibling (maskEmail, maskUriParams, sanitizeExceptionMessage, …) degrades to a placeholder rather than throwing, on the file's stated "runs inside the logging pipeline" rule. I proved this one total, so there is no bug today - but it is now called directly from a hot loop outside maskUriParams' guard, and the next edit to the index arithmetic loses that proof silently. A wrapper returning "[uri-mask-error]" would cost nothing. Fix this →

2. [Low] Free-text over-redaction when a URL and an @ share one whitespace-delimited token. A free-text authority ends only at / ? # or whitespace, so https://evil.example,alice@corp.example (comma, no space) redacts to https://[REDACTED]@corp.example - the host actually contacted is lost, which is the same shape as the documented \ limitation and arguably belongs in the same KDoc list. The KDoc's "an @ later in the sentence (an email address) is not read as a delimiter" is true only once whitespace intervenes. The same applies to a nested URL inside a free-text query, where & deliberately does not terminate: https://host/?next=https://internal&x=a@b.com collapses to https://[REDACTED]@b.com. Both fail safe (over-redact, never under-redact), so this is a documentation gap rather than a defect.

3. [Low] GitCloneProgressLogTest.source() re-implements ai.rever.boss.testsupport.repoRoot. That helper already exists and is used by WindowIconTest / WindowIconConventionTest. Several other tests hand-roll the same generateSequence, so this is consistent with the majority - but the shared one is right there.

4. [Low] The negative source assertion is narrower than its message. Regex("""logger\.\w+\([^\n]*\$\{?progressLine""") catches re-interpolation, which is the likely regression, but a logger.debug(LogCategory.GENERAL, "Clone progress: " + progressLine) concatenation passes it, and the positive assertion pins the exact call text including LogCategory.GENERAL, so a future ktlint wrap breaks the test rather than the behaviour. Acceptable for what it is; just do not read it as a general guard.

Regressions

None found.

  • No pre-existing test in either module uses an @ inside a URI - I grepped LogSanitizerTest, QueryParameterSanitizerTest, UriFragmentOrderTest and HostnameSanitizerTest. Nothing to break, and the no-://-or-no-@ fast path returns the argument object itself, so every URL the masker already handled is byte-identical.
  • The queryStart/fragmentStart recomputation off result is still correct after the rewrite, and [REDACTED] introduces no ? & #, so the second and third passes cannot be perturbed by the first.
  • No API dump to update: there are no .api files in the tree, and LoggingStableFieldTest asserts $stable on named classes, which a new function does not touch. Adding a public member to the host copy of ai.rever.boss.plugin.logging is safe under parent-first shadowing - as you note, the published api-jar copy is out of scope, and nothing compiled against that jar can call the new function anyway.
  • The composeApp facade is a one-line delegate with matching semantics (non-null parameter, no [empty] sentinel, unlike maskUriParams) - correct for a free-text helper.

Adjacent, explicitly not this PR: raw git stderr still reaches the UI through _lastError.value = errorMsg / GitError(errorMsg) on the push/fetch paths (DesktopGitService.kt:180, :278, :2211, …). That is a display surface, not a log sink, and git >= 2.22 anonymises its own messages - but it is the same class of exposure and the same GIT_TRACE reasoning applies, so it is worth an issue rather than silence.

Integration with dev

The two changed source files were last touched by 665445c, well behind this branch's base a3f4b7f - no overlap and nothing to rebase on the files this PR owns.

What I could not verify here: Gradle, python/node and git fetch are all outside this job's approved commands, so I reviewed by reading and hand-tracing rather than executing. I did not reproduce the 48/0, 80/0, 20/0 or 3/0 counts, the ktlint/detekt results, the mutation table, or the current dev tip - CI run 34828969760 was still queued when I checked, so it remains the gate.
• branch fix/log-uri-userinfo

@kshivang

Copy link
Copy Markdown
Contributor

@ninja-lifts Thank you for addressing the earlier handoff with measured Git trace/server-output evidence, the clone sink repair, nested-URL handling and targeted regression/mutation tests. Those follow-up changes are your work; I made no code changes to this PR.

Final reviewed head: 695d8a2aab516de675c6d90cb545974418ae5eb7, target dev. The fresh Claude review confirms all three previous handoff items are done and finds no blocking defect. Local locked Gradle now independently passes 48 logging tests + 103 targeted host tests, with logging and composeApp ktlint/detekt green; the direct production sanitizer/facade run also passes 106 tests.

The low-severity review notes are not merge blockers: the scan's bounds/progress proof makes the unguarded helper total today; comma-adjacent emails and free-text nested-query separators can over-redact (diagnostic fidelity, not credential disclosure); using the shared test-root helper is optional cleanup; the source assertion pins this exact call, not every possible future logging expression. Keep those limits explicit. Protocol-relative/encoded nested URLs, literal ampersands inside nested userinfo and browser backslash normalization retain the documented scope limits. #659 is a distinct draft timeout change to the same clone loop and must preserve this redaction on later integration; it was not modified here.

The sole final handoff blocker is hosted macOS CI still queued, not the old findings and not an author test failure. Linux, Windows, quality, compatibility, database, shell, version and contracts checks have passed at this snapshot: https://github.com/risa-labs-inc/BossConsole/actions/runs/34828969760 . The fork run was approved during this review. Next: let macOS and the build summary finish, address any actual failure if one appears, restore ready, refresh dev and mergeability, then merge under the shared dev lock using this reviewed SHA. No new implementation or repeated Claude request is required by the present review. I am converting the open PR to draft solely under the requested bounded-review policy because that remaining gate cannot be bypassed.

@nikhil-jangir-iitk

Copy link
Copy Markdown
Contributor Author

Rebased onto dev d22d2bfeb. The second commit conflicted with #660's clone loop; I kept #660's runCloneProcess loop and routed its progress debug line through cloneProgressLogMessage, the same change as before (GitCloneProcess.kt has no logger calls). Same six files, +430/-5. On the rebased head: 48 logging + 233 host tests pass, ktlint/detekt clean, 0 added conflicts.

@nikhil-jangir-iitk
nikhil-jangir-iitk marked this pull request as ready for review September 15, 2026 10:32
…logging

maskUriParams masked only sensitive query and fragment parameters, so a
credential carried in a URL's authority (scheme://user:password@host) was
returned verbatim and written to the log. A private HTTPS git remote is
https://x-access-token:<token>@github.com/..., and DesktopGitService logs the
clone URL at INFO and elsewhere branches on repositoryUrl.contains("@") for its
auth-failure message, so the URL it logs is expected to carry a token. Browser
navigation and deep-link sites pass URLs through the same masker.

redactUserInfo now replaces the userinfo with [REDACTED], keeping the scheme,
host, port and path. Only an @ inside the authority is treated as a delimiter -
the authority ends at the first / ? or # after :// - so an @ in a path or a
query value (an email) is left alone, and a URL with no :// is unchanged. The
query index is now read off the redacted string, since the redaction can change
its length.
Review of the first commit found two more places a URL credential
reaches the log.

cloneRepository merges git's stderr into stdout and logged each line
raw at DEBUG. git 2.22 and later strip the credential from their own
fatal messages (measured on git 2.43 for 404, 401, 403, 500, redirect
and connection failures), but three sources still print it: git older
than 2.22, whose release notes say remote-http "did not anonymize URLs
reported in its error messages at places"; GIT_TRACE=1 inherited from
the environment, which printed the full clone URL four times on git
2.43; and text a server sends back, which git prints verbatim after
"remote error:". These lines reach the log only when DEBUG is enabled.
The line now goes through cloneProgressLogMessage, which calls the new
LogSanitizer.redactUrlUserInfo. It removes only the userinfo, so the
failure text, host and port stay readable; sanitizeLogMessage would
replace the whole URL and its host.

maskUriParams examined only the first authority, so
https://sso.example/login?next=https://u:p@internal/ kept its
credential. Every :// is now examined. A nested URL's authority also
ends at &, so a later &contact=a@b.com is not read as its userinfo.

The no-path and repeated-@ cases were already correct and are now
pinned, along with an @ as the last authority character. Input without
://, percent-encoded nested URLs and backslash normalization are
documented as not handled.
@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head of #640 against dev, including contributor follow-ups and prior finding dispositions. This is the current morning batch review; do not repeat repaired findings. Focus on remaining correctness, credential exposure, regressions and focused test coverage. Earlier reviewer repairs remain separately attributed.

nikhil-jangir-iitk added a commit to nikhil-jangir-iitk/sec_boss that referenced this pull request Sep 18, 2026
…ority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.
kshivang pushed a commit to nikhil-jangir-iitk/sec_boss that referenced this pull request Sep 18, 2026
…ority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.
kshivang pushed a commit to nikhil-jangir-iitk/sec_boss that referenced this pull request Sep 18, 2026
…ority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.
kshivang pushed a commit to nikhil-jangir-iitk/sec_boss that referenced this pull request Sep 18, 2026
…ority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.
kshivang added a commit that referenced this pull request Sep 18, 2026
…lines (#691)

* fix(logging): keep query and fragment secrets out of browser URL log lines

"Browser created via BrowserService" logged a tab's full URL at INFO on
every tab creation, hibernation wake and crash recovery, and
"URL command queued" logged every boss://url link the same way. The data
map is appended to the line as-is, the line reaches the Console capture
that every plugin reads through logDataProvider, and the console plugin
serves that capture to MCP clients through console_tail and
console_search. An OAuth code, a sign-in token or a presigned signature
in the URL went with it.

Log LogSanitizer.describeUri (scheme, host and path) at the four sites
in BrowserServiceImpl, DeepLinkHandler and CLICommandHandler.
maskUriParams redacts by exact parameter name and hid 4 of 14
credential-bearing URL shapes measured; describeUri hid 12, missing only
a token carried in the path.

BrowserUrlLogConventionTest scans those three files for url-keyed log
data without LogSanitizer; DescribeUriSecretShapesTest pins the
describeUri output the sites now rely on.

* fix(logging): log deep links by shape and describe scheme-less references

From review of the browser URL logging change:

DeepLinkHandler logged every incoming link through maskUriParams, at INFO
or WARN, in six places: the three OS open-URI handlers, "Received deep link
from command line", "Processing deep link" and the unrouted-host warning.
maskUriParams redacts by parameter name, and a link's query is often not a
listed name: boss://url?url= carries a whole URL with its own query,
boss://terminal?command= a command, and an https link BOSS receives as the
default browser any credential under any name. Run through the real
processDeepLink and processCommandLineArgs, seven synthetic secrets in
those shapes all reached LogDataProvider.logs; after this change none does.
The six sites and the "Cookie rejected" line now use describeUri.

describeUri interpolated the scheme, so a reference with no scheme read as
null://localhost. That is exactly what CLICommandHandler logs for input
normalizeAndValidateUrl rejected, and what a boss://url link can carry. It
now renders localhost, //host/path, and "(with query params)" without a
leading space.

BrowserUrlLogConventionTest now accepts only describeUri for a url-keyed
value in the guarded files, so switching a site to maskUriParams is caught.
Its call parser skips comments and character literals, and its KDoc lists
what it cannot see.

* fix(logging): name the host when java.net.URI will not parse the authority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing #640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.

* fix(logging): preserve escaped path bytes in URI descriptions

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
nitininhouse added a commit to nitininhouse/BossConsole that referenced this pull request Sep 18, 2026
kshivang added a commit that referenced this pull request Sep 22, 2026
…lines (#691)

* fix(logging): keep query and fragment secrets out of browser URL log lines

"Browser created via BrowserService" logged a tab's full URL at INFO on
every tab creation, hibernation wake and crash recovery, and
"URL command queued" logged every boss://url link the same way. The data
map is appended to the line as-is, the line reaches the Console capture
that every plugin reads through logDataProvider, and the console plugin
serves that capture to MCP clients through console_tail and
console_search. An OAuth code, a sign-in token or a presigned signature
in the URL went with it.

Log LogSanitizer.describeUri (scheme, host and path) at the four sites
in BrowserServiceImpl, DeepLinkHandler and CLICommandHandler.
maskUriParams redacts by exact parameter name and hid 4 of 14
credential-bearing URL shapes measured; describeUri hid 12, missing only
a token carried in the path.

BrowserUrlLogConventionTest scans those three files for url-keyed log
data without LogSanitizer; DescribeUriSecretShapesTest pins the
describeUri output the sites now rely on.

* fix(logging): log deep links by shape and describe scheme-less references

From review of the browser URL logging change:

DeepLinkHandler logged every incoming link through maskUriParams, at INFO
or WARN, in six places: the three OS open-URI handlers, "Received deep link
from command line", "Processing deep link" and the unrouted-host warning.
maskUriParams redacts by parameter name, and a link's query is often not a
listed name: boss://url?url= carries a whole URL with its own query,
boss://terminal?command= a command, and an https link BOSS receives as the
default browser any credential under any name. Run through the real
processDeepLink and processCommandLineArgs, seven synthetic secrets in
those shapes all reached LogDataProvider.logs; after this change none does.
The six sites and the "Cookie rejected" line now use describeUri.

describeUri interpolated the scheme, so a reference with no scheme read as
null://localhost. That is exactly what CLICommandHandler logs for input
normalizeAndValidateUrl rejected, and what a boss://url link can carry. It
now renders localhost, //host/path, and "(with query params)" without a
leading space.

BrowserUrlLogConventionTest now accepts only describeUri for a url-keyed
value in the guarded files, so switching a site to maskUriParams is caught.
Its call parser skips comments and character literals, and its KDoc lists
what it cannot see.

* fix(logging): name the host when java.net.URI will not parse the authority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing #640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.

* fix(logging): preserve escaped path bytes in URI descriptions

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
kshivang added a commit that referenced this pull request Sep 22, 2026
…orites (#935)

* fix(search): highlight the selected row in the sectioned global search list

The sectioned (All-category) view of the double-shift search never lit up
the selected row. SearchResultsList advanced a loop-mutated var per
section and the items(...) composable lambda read it by reference - the
lambda runs only when the LazyColumn composes the slot, after the walk
has finished, so the var held the list's total count and no row's
computed global index could ever equal selectedIndex. The flat
single-category view passes its index straight in and always highlighted,
which is why the bug survived: only the default view, the one double-shift
opens, was affected.

Compute each section's start up front in sectionStartsFor (a pure function
mirroring the drawing loop's walk, in the style of listItemIndexFor) and
capture that value by value into the row lambda. Selection math becomes
sectionStart + localIndex == selectedIndex, which is also what Enter
already indexes, so the highlighted row is now the row Enter opens.

Five tests pin the walk: first-occurrence starts, bijection of section
ranges over the result indices, absent categories, and the empty list.

* refactor(search): address review feedback on section starts

Address the Claude diff review on the global-search highlight fix:

- sectionStartsFor now takes the drawing loop's own grouping instead of
  re-grouping the list, so there is one source of truth and getValue is
  safe by construction
- pin the getFilteredResults -> sectionStartsFor coupling with a test
  that feeds the real producer's output through the walk
- pin the walk's input contract (enum-order walk over counts) with an
  explicit interleaved-input test
- add a consistency test: section-derived row positions agree with
  listItemIndexFor for every row
- drop the overstated KDoc claim; state the two tests that pin the
  coupling

* style(search): satisfy detekt MaxLineLength on the grouped helper

detekt caps code lines at 120 (ktlint allows 140), and ktlint wants the
helper as a one-line expression body - the annotated signature exceeded
120. Simplify instead of fighting both linters:

- the flat branch never reads groupedResults (it draws results directly),
  so its map was dead weight - emptyMap() and a non-null-key map type
- sectionStartsFor takes Map<SearchCategory, List<SearchResult>> again
- the test helper is a plain expression body with no type annotation

* Merge instead of assigning on the recent-browser-pages load

loadAsync assigned data.pages straight into _recentPages, and
bootstrapFromBrowserHistory did the same with its converted entries. init
launches that load and recordPageVisit can land while the read is in
flight - which is exactly why recordPageVisit uses update{} - so the page
the user had just visited was dropped from the list and from the save
scheduled for it. The bootstrap path is worse: it writes immediately, so
the assignment persisted over the racing visit.

This is the defect #795 fixed for the sibling RecentFilesManager, which
was modelled on this class for its lock discipline, debounce swap and
atomic write. Those three were already right here; this fourth one was
not.

mergeRecordedPages differs from RecentFilesManager's mergeRecorded because
visitCount accumulates: the racing entry is created fresh with a count of
1 because the list was empty, so taking the newer entry wholesale would
reset a long-lived page from its real count to 1. Counts are summed, the
same way UrlHistoryManager merges duplicates; the newer lastVisited
supplies the title, and the favicon falls back to the stored entry when
the racing visit has none. Summing is not idempotent, which is safe while
loadAsync runs once from init and is documented as a constraint on adding
any reload.

7 unit tests for the merge contract. SuggestionDismissalTest and
SearchSourceRegistrarTest, which drive this object, still pass 7/7 each.

* fix(browser): preserve startup dismissals during load

* Derive the displayed recent-files list outside mutationLock

setFiles wrote _allFiles and then derived _recentFiles, and #795 put the
whole thing inside mutationLock. The derive calls File.exists() once per
entry - up to 20 - and on an unmounted or disconnected share, the exact
case _allFiles exists for, each can block for seconds. Every
recordFileOpen, removeFile and clearAll then queued behind one slow mount.

The exists() cost is pre-existing; being inside a critical section is not.
Before #795 setFiles was called with no lock held, so a slow share made
one mutation slow rather than serialising all of them.

setFiles is split: mutationLock now covers only the recorded write, and
refreshVisible() does the derive under its own visibilityLock. The two
locks are never nested. The recorded list is read inside visibilityLock,
so of two concurrent derives the one that finishes last is the one that
read the freshest list.

The displayed list now lags the recorded one for the length of the derive.
That is not new - nothing re-derives between mutations either, so a file
deleted outside BOSS already stays visible until the next mutation - the
window is just wider.

No new tests: the change is about lock scope, which needs a blocking
File.exists() to observe. RecentFileVisibilityTest, RecentFilesMergeTest
and RecentFilesLoadTest pass 15/15 unchanged.

* test(recent-files): order reset with visibility refresh

* fix(auth): fence user_data.json saves against a logout that interleaves the lock

Follow-up on the merged #795 (issues #762/#764), closing the one review
item it did not include: a save that entered before logout but acquires
fileLock only after clearUserData ran still recreated user_data.json
with the logged-out user's identity - the mutex cannot order a caller
that already passed it.

saveUserData now captures a clear-generation counter before acquiring
the lock and re-checks it inside (delegating to an internal
doSaveUserData seam that takes the entry generation, so the regression
test drives the capture-clear-acquire interleaving deterministically);
clearUserData bumps the counter under the lock. A save entered before
logout observes the bump and skips; a save entered after logout has a
fresh generation and proceeds normally (pinned by the test).

The fence test rides the merged suite's hermetic resetForTesting
fixture. 4/4 + detekt + ktlint green in one run.

* test(auth): exercise logout fence through public save

* fix(persist): pin atomicWriteText permissions to owner-only and migrate stats and project writes (#860)

* fix(security): fail closed on permission hardening

* fix(render): let plugin quarantine settle before narrowing (#750)

* fix(render): let plugin quarantine settle before narrowing

* fix(render): bound settling refunds across an incident

* test(render): derive escalation probe length from policy

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(persist): flush debounced recent-files and user-data saves on exit (#795 follow-up)

* fix(review): acknowledge recent-files manager breadth

* style(persist): format flush test chain

* test(search): pin the global search service contract

* style(search): wrap service test fixtures

* style(search): format match fixture helper

* style(search): use block body for fixture helper

* style(search): extract match fixture constructor

* fix(settings): serialize mutations and persist atomically with owner permissions (#871)

* fix(security): fail closed on settings permissions

* fix(security): redact sensitive JWT tokens in Supabase realtime log lines (#855) (#870)

* fix(security): redact sensitive JWT tokens in Supabase realtime log lines (#855)

* test(logging): isolate Supabase logger state

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* test(mcp): pin the risk evaluator contract for tool approval

* test(mcp): make risk contract claims explicit

* fix(logging): keep query and fragment secrets out of browser URL log lines (#691)

* fix(logging): keep query and fragment secrets out of browser URL log lines

"Browser created via BrowserService" logged a tab's full URL at INFO on
every tab creation, hibernation wake and crash recovery, and
"URL command queued" logged every boss://url link the same way. The data
map is appended to the line as-is, the line reaches the Console capture
that every plugin reads through logDataProvider, and the console plugin
serves that capture to MCP clients through console_tail and
console_search. An OAuth code, a sign-in token or a presigned signature
in the URL went with it.

Log LogSanitizer.describeUri (scheme, host and path) at the four sites
in BrowserServiceImpl, DeepLinkHandler and CLICommandHandler.
maskUriParams redacts by exact parameter name and hid 4 of 14
credential-bearing URL shapes measured; describeUri hid 12, missing only
a token carried in the path.

BrowserUrlLogConventionTest scans those three files for url-keyed log
data without LogSanitizer; DescribeUriSecretShapesTest pins the
describeUri output the sites now rely on.

* fix(logging): log deep links by shape and describe scheme-less references

From review of the browser URL logging change:

DeepLinkHandler logged every incoming link through maskUriParams, at INFO
or WARN, in six places: the three OS open-URI handlers, "Received deep link
from command line", "Processing deep link" and the unrouted-host warning.
maskUriParams redacts by parameter name, and a link's query is often not a
listed name: boss://url?url= carries a whole URL with its own query,
boss://terminal?command= a command, and an https link BOSS receives as the
default browser any credential under any name. Run through the real
processDeepLink and processCommandLineArgs, seven synthetic secrets in
those shapes all reached LogDataProvider.logs; after this change none does.
The six sites and the "Cookie rejected" line now use describeUri.

describeUri interpolated the scheme, so a reference with no scheme read as
null://localhost. That is exactly what CLICommandHandler logs for input
normalizeAndValidateUrl rejected, and what a boss://url link can carry. It
now renders localhost, //host/path, and "(with query params)" without a
leading space.

BrowserUrlLogConventionTest now accepts only describeUri for a url-keyed
value in the guarded files, so switching a site to maskUriParams is caught.
Its call parser skips comments and character literals, and its KDoc lists
what it cannot see.

* fix(logging): name the host when java.net.URI will not parse the authority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing #640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.

* fix(logging): preserve escaped path bytes in URI descriptions

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* feat: added MCP operation ledger and CLI

* fix(mcp): create the operation ledger with owner-only permissions

* fix(mcp): harden ledger verification and persistence

* fix(mcp): mask the credential in an Authorization header, not just its label

* fix(mcp): absorb repeated auth schemes and reach the quoted value, so a compound or quoted credential cannot survive

* fix(mcp): redact arbitrary authorization schemes

* fix(mcp): scope session trust to the provider the operator approved (#815)

* fix(mcp): scope session trust to the provider the operator approved

Session trust was keyed by tool name only, so trusting one provider's
tool for the session silently granted the same auto-run to every other
provider shipping a same-named tool - an unvetted plugin could inherit
an approval the operator never gave it. The engine already treats
(toolName, providerId) as a tool's identity everywhere else; session
trust was the one surface that forgot.

- trustForSession now takes the provider, with no default, so a
  caller cannot grant name-only trust by accident
- policyFor matches a session grant only when the provider in hand is
  the one that earned it; with no provider it never applies
- confirmInvocation had the providerId all along and now passes it
  through; a grant without a provider runs the one approved call but
  does not stick
- revokeSessionTrust keeps a name-wide mode (null provider) because
  persisted rules are name-keyed and their resets cannot know a
  grant's provider; that direction over-removes trust, which fails
  closed
- section writes drop only the (providerId, toolName) pairs they
  changed, so same-named tools trusted for other providers keep their
  own grants

New McpSessionTrustScopingTest pins the leak: a same-named tool from
an untrusted provider keeps prompting.

* test(mcp): pin session-trust edge cases for #815

* docs(agents): pin the provider-scoped session-trust contract (#815)

* docs(agents): scope section-save trust invalidation to the changed pairs (#815)

* style(mcp): remove leading blank line in trust edge cases

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* perf(ipc): Optimize MCP tool schema parsing with string-based cache (#867)

* perf(ipc): Cache MCP tool schema parsing to reduce latency

* test(ipc): Add regression coverage for MCP schema caching

* fix(ipc): bound and verify tool schema cache

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugins): log dropped protected dev jars and duplicate jar picks (#781)

* fix(plugins): add diagnostic logging for deduplicated and dropped dev jars

* style(test): order imports alphabetically for ktlint compliance

* refactor(plugin): remove unreachable protected dev penalty branch

* fix(plugins): preserve dedupe scan and test isolation

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* test(boss-native-files): skip symlink tests when symlink creation is unavailable (#839)

* test(boss-native-files): skip symlink tests when symlink creation is unavailable

* test(native-files): keep symlink probe failures visible

* style(native-files): order test imports

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(browser): parse the focused field details as JSON, not with a regex

The page builds this payload with `JSON.stringify` and `getFormFieldInfoFromJS`
read it back with `"key":"([^"]*)"`, once per key. That is not a JSON parser in
two ways, and both of them reach a credential:

- `[^"]*` stops at the first quote, and `JSON.stringify` writes a quote inside a
  value as `\"`. So the password `a"b` arrived as `a\` - truncated at the quote,
  and carrying a backslash nobody typed.
- Nothing decoded an escape, so a textarea's newline arrived as the two
  characters `\n`, and the same for `\\`, `\t` and `\uXXXX`.

The parse moves to `formFieldInfoFrom` in its own file so it can be exercised
without a live browser frame, the way `cloneProgressLogMessage` is in
`DesktopGitService`. `BrowserHandleImpl` keeps the script and the frame call.

`Json.Default` is right here and this is not the case AGENTS.md's `supabaseJson`
rule is about: that rule exists because `decodeFromString<T>` treats an
undeclared column as a hard error. There is no model here - `parseToJsonElement`
builds a tree and each key is read off it, so the script's `className`, which
nothing reads, is simply not looked at.

One deliberate behaviour change: a payload that is not a JSON object now yields
null rather than a `FormFieldInfo` whose every field is empty. The old shape
claimed to describe a text field it had never read; the caller treats null as
"no autofill detail for this menu", which is what it is.

The classification `when` is split into `declaredFieldType` and
`namedFieldType`. That split is not cosmetic and not free: the block had been
inside a function baselined for `CyclomaticComplexMethod` and `LongMethod`, so
moving it out lost the suppression. Decomposing by source of evidence - what the
element declares outranks what it is named - keeps the branch order exactly as
it was and lets both baseline entries be deleted instead of re-baselined. Three
tests pin that precedence, which nothing covered before.

* test(plugin): pin the artifact cleanup deletion-safety contract (#843)

* test(plugin): pin the artifact cleanup deletion-safety contract

* test(plugin): make cleanup symlink coverage portable

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(plugin): verify system-plugin GitHub downloads before install

* fix(plugin): use the canonical pinned system-plugin repo

* test(cli): pin the CLI security validator contract

* test(cli): make validator contract assertions honest

* test(cli): clarify downstream URL and path contracts

* test(run): pin update-payload integrity and crash-write safety for run configs (#824)

* test(run): pin concurrent-update and crash-write safety for run configs (#754 follow-up)

* style: ktlint fixes for the run-config concurrency tests

* test(run): skip unsupported permission failure injection

* test(run): make atomic-write observer deterministic

* test(run): keep atomic observer within quality limits

* test(run): stabilize persisted timestamps

* fix(run): preserve timestamps and detect empty writes

* test(run): require the reader to sample before writes

* test(run): make reader readiness fail fast

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(secrets): stop clients importing ciphertext into rows they can decrypt

`authenticated` held table-level INSERT and UPDATE on public.secrets and
public.secret_metadata, and the row policies let an owner update their own
rows. A signed-in user could therefore PATCH any ciphertext into their own
password_encrypted or recovery_codes_encrypted and read it back decrypted
through get_user_secrets, since every value is under the one global key:
a leaked dump or backup plus any account decrypts every password in it.
It is the attack 20260909000000_encrypt_totp.sql already closes for
twofa_secret, and it also hands an owned row out as a padding oracle
against the unauthenticated CBC ciphertext (#618 defect 3).

Replace each client table-level INSERT/UPDATE with a column-level grant
of the same privilege on every column except the ciphertext ones, reading
the column list from the catalog and never granting a privilege a role
did not already hold. Every legitimate writer is a SECURITY DEFINER RPC
and is unaffected; no client in this repository writes either column.

The new pgTAP suite reproduces the import end to end (it fails on the
unpatched schema) and pins the paths that must keep working: create and
update through the RPCs, direct edits of ordinary columns, and plaintext
TOTP input through its trigger. Full pgTAP suite: 38 files, 900 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(secrets): fail closed on inherited ciphertext grants

* fix(updater): verify the chromium engine fallback download against the catalog sha256 (#798 follow-up)

* docs(browser): describe fallback checksum provenance

* Keep RecentFilesLoadTest teardown off the user's recent-files.json (#817)

* Keep RecentFilesLoadTest teardown off the user's recent-files.json

resetForTesting reloads after repointing settingsFile, so restoring the
real path in teardown reads the developer's own recent-files.json. If that
file is denormalised (duplicate paths, over-cap, hand-edited order) the
merge differs from the decoded contents, persist is true, and the debounced
save lands on the real file well within a full desktopTest run. CI never
sees it: a runner has no ~/.boss, so the load early-returns.

- resetForTesting takes reload: Boolean = true; teardown passes false. The
  restore only needs the pointer back in place, and nothing else in the
  suite drives this object.
- saveJob is cancelled before the settingsFile swap rather than after.
  saveImmediately reads settingsFile without the lock, so a job already
  past its delay could write the just-cleared list to the new path - which
  on the restore call is the user's own file.

Raised in the automated review on #795 after it merged, alongside the
measured ~/.boss writes from other singletons (separate issue).

* test: await recent-files reset jobs before path swap

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(security): make /auth/challenge enumeration-safe and rate-limited (#769)

* fix(security): make /auth/challenge enumeration-safe and rate-limited

POST /passkey/auth/challenge runs with verify_jwt=false and returns
three distinguishable outcomes for an email probe: 400 'User not found',
400 'No passkeys found for user', or a 200 whose allowCredentials leaks
each stored credential_id to the unauthenticated caller. Migration
20260910000000 already called the same lookup an 'unauthenticated-grade
enumeration oracle for the whole user base' when it revoked
find_user_by_email's client grants, but this route performs the lookup
over HTTP and returns distinguishable errors, so the oracle survived
through the surface that migration did not cover.

- pre-auth failures (unknown email, no passkeys, lookup error) now all
  return the same inert challenge: a fresh random value that is never
  stored - so /auth/complete can never verify against it - with an
  empty allowCredentials, so the client's credential request fails
  locally exactly as it would for a genuine no-credentials user.
  Status, body shape, and content are identical for every email a
  prober tries; the real allow list is returned only when a passkey
  row actually exists.
- the route is rate-limited with the organisation function's
  in-memory fixed-window shape (copied, since edge functions share no
  import path): 20 challenges per client per hour, 429 with the new
  OpenAPI 429 response documented.

Validation: full passkey deno test suite 162 passed / 0 failed;
type-check clean. The two enumeration tests now pin the inert response;
five new rate-limit tests cover the window, retry-after, reset, and
per-client budgets.

* fix(security): close the inverted store-failure oracle, bound the limiter, and pin the route contract

Review follow-ups on this PR (by @kshivang):

- storeChallenge failure previously returned success:false - a state
  reachable only for an enrolled account (we already passed the passkey
  lookup), which inverts the oracle exactly when the store hiccups.
  It now returns the same inert challenge as every other pre-auth
  failure.
- The route's per-client budget moves from 20/hour to 60/hour: the
  desktop sign-in flow spends up to three challenge calls per
  successful sign-in, so 20 was under three sign-in attempts.
- The limiter's MAX_KEYS memory bound (already ported from the
  organisation implementation) now has its flood test: a 10,050-key
  spray is evicted and fresh keys still get fresh windows.
- Route-level tests pin what only the route can prove: the 61st probe
  from one client is a 429 with ZERO Supabase lookups spent on it
  (limit-before-lookup, proven via the mock's query history), and the
  serialized responses for unknown-email and no-passkeys are
  key-for-key identical with empty allow lists and per-request random
  challenges.
- resetRateLimiter exposed so route tests isolate limiter state.

The residual enrollment oracle is real and stays: enrolled accounts
still receive their real credential IDs in allowCredentials, because
legacy (non-discoverable) credentials cannot complete the ceremony
without them. Closing it needs a client-side discoverable-credentials
migration; this PR narrows the claim to the pre-auth failure states
it seals and documents that boundary in the issue.

Full passkey deno suite: 165 passed / 0 failed; type-check clean.

* fix(security): enforce limiter cap and narrow enumeration claims

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(orchestrator): confine snapshot paths and persist atomically with owner permissions (#873)

* fix(orchestrator): reject snapshot symlink escapes

* fix(plugin): bound the manifest entry reads before signature verification

* style(plugin): fix bounded manifest test formatting

* test(plugin): pin the manifest bound and keep the caps in sync

Final review asks on #858:

- split readBoundedManifest(stream) out of readManifestContent so the
  byte bound is directly testable without a JAR, and pin it with a fake
  InputStream that fails the moment a reader asks for more than cap + 1
  bytes; an unbounded readText-then-check-length implementation now
  fails deterministically instead of passing every fixture test
- add real zip-bomb fixtures to both bounded-read suites: a plugin.json
  entry inflating to 1 GiB from a tiny compressed payload, streamed into
  the JAR, asserted rejected within the byte bounds
- pin PluginManifestReader.MAX_MANIFEST_BYTES against
  DevPluginArtifacts.MAX_MANIFEST_BYTES with an explicit assertEquals in
  a composeApp desktopTest (the RetiredPluginsTest convention), and
  document the deliberate null-vs-throw failure modes in the KDoc
- narrow the PluginManifestReader KDoc: the bound covers this reader's
  own manifest read only, not the JDK's JarVerifier, which reads
  META-INF signature entries unbounded on the first getInputStream

* fix(plugin): satisfy detekt on the bounded manifest reads

Wrap the oversize fail message in the bounded-read test and suppress TooManyFunctions on PluginManifestReader, whose eleven read/validate steps belong to one fail-closed order.

* fix(deep-link): hold an external workspace link's terminal commands for confirmation

boss://terminal?command= has consulted DeepLinkOrigin since #60, but
boss://workspace?path= reaches the same shell - applying a Space types each
terminal tab's initialCommand - and dispatch never passed it an origin. An
external workspace link therefore started every command in the file with no
prompt.

Carry the origin on CLICommand.LoadWorkspace (default EXTERNAL), through the
cold-start readiness queue, onto WorkspaceLoadEvent.requiresConfirmation. The
window, which is the only place the file is parsed, decides with
spaceLoadDisposition: an external load of a Space that carries terminal
commands is shown in SpaceLoadApprovalDialog first, and dropped when a command
is malformed or the commands cannot all be shown. The operator's own
`boss workspace` and Spaces without terminal commands load as before.

* fix(deep-link): harden workspace confirmation feedback

* fix(cli): reject hidden command formatting characters

* fix(cli): reject supplementary format controls

* fix(deep-link): complete hidden character filtering

* fix(security): close the search_path on the passkey SECURITY DEFINER set (#773)

* fix(security): close the search_path on the passkey SECURITY DEFINER set

Since 20260802000000 the repo convention for SECURITY DEFINER functions
is SET search_path TO '' plus fully-qualified table references - a
hardening that migration itself calls a security fix and the Supabase
linter enforces. Three passkey lifecycle functions predate it and were
never upgraded, so each still resolves its tables through the
caller-influenced search_path:

- clean_expired_passkey_challenges()
- create_mobile_registration_session(text, text, text)
- get_session_status(text)

Not a live exploit today - turning a mutable search_path into
privilege escalation needs CREATE on a searched schema, which the
PostGREST roles lack - but a future CREATE grant or restored PUBLIC
default would silently convert them into escalation primitives, and
the bodies would not error before that day. Same drift class
20260910000000 closed ('the repository was WEAKER than production').

clean_expired_passkey_challenges also still carried EXECUTE grants for
anon and authenticated (20251023000014; the 20260908030000 sweep
stripped only anon/PUBLIC). Nothing invokes it - the probabilistic
trigger inlines its own DELETE - so the grants are dead weight on a
SECURITY DEFINER function any self-registered user could call.

The migration carries each original body verbatim with only two
mechanical changes: the SET search_path TO '' clause and schema
qualification of table references (auth.users, public.passkey_challenges,
pg_catalog.now()). Signatures, return types, logic and owners unchanged;
pgTAP pins the closed form via pg_proc.proconfig and the revoked grants.

Validation: fresh local DB applied the full chain through this
migration cleanly; supabase test db - new suite 12/0, all other suites
unchanged (the only failure is the pre-existing
secret_totp_encryption_test fixture, absent on a fresh clone).

* test(security): correct the plan count to the 12 assertions that run

plan(13) was written before the two grant-keeping assertions were merged
into one, so pgTAP aborted the file with 'Bad plan: planned 13, ran 12'
despite every assertion passing. plan(12) matches the file; suite now
reports ok.

* fix(security): close the nested-trigger search_path regression in the passkey hardening

Review follow-up (PR review by @kshivang): the hardened
create_mobile_registration_session inserts under search_path = '', and
passkey_challenges' AFTER INSERT trigger then runs
trigger_cleanup_expired_challenges(), which had no local search_path and
referenced passkey_challenges unqualified - so the 10% cleanup branch
inherited the empty path and aborted registration with
relation-not-found.

The migration now redefines the trigger chain to be self-contained, in
the same shape as open #572 (20260911093022) so whichever lands second
is a textual no-op replace of identical semantics:

- clean_expired_passkey_challenges(): bounded cleanup (256 rows, FOR
  UPDATE SKIP LOCKED), SECURITY INVOKER, SET search_path TO ''. Defined
  here so the delegate resolves whether or not #572 has landed; when it
  has, this replaces it with the identical body.
- trigger_cleanup_expired_challenges(): SECURITY INVOKER delegate with
  its own empty search_path, public.-qualified, replacing the old
  unbounded unqualified probabilistic body.

pgTAP updates: membership testing ('search_path=""' = any(proconfig))
instead of positional proconfig[1]; pins on the trigger's own empty
search_path and INVOKER status; live-path proofs that the cleanup RPC
and get_session_status still execute under the closed path; 16/16 ok,
full suite 785 tests green.

* fix(security): preserve passkey cleanup semantics under closed path

* test(security): use the pgTAP pattern assertion

* test(security): pin the passkey trigger repair

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>

* fix(home): place Home in the Favorites grid (#933)

* fix(browser): wire printing and make top-bar zoom badge opt-in

* test(spotlight): classify the handled browser print command

* fix(mcp): keep ledger telemetry ordered with durable writes

* fix(review): handle Windows persistence contention and flush recent pages

* fix(test): await recent-page cleanup without locking its file

---------

Co-authored-by: Shivanshu <shivanshu@risalabs.ai>
Co-authored-by: Aryan Bajaj <aryan.bajaj.met22@itbhu.ac.in>
Co-authored-by: choksi2212 <manaschoksiwork@gmail.com>
Co-authored-by: Rohith Pariki <rohithpariki@gmail.com>
Co-authored-by: Aryan Raj <aryanraj2789@gmail.com>
Co-authored-by: Mihir-Rabari <mihirrabari2604@gmail.com>
Co-authored-by: RohithPariki <159949499+RohithPariki@users.noreply.github.com>
Co-authored-by: Nikhil jangir <nikhil23@iitk.ac.in>
Co-authored-by: harshwardhan singh bais <titanx276@gmail.com>
Co-authored-by: nitin gupta <nitin_g@me.iitr.ac.in>
Co-authored-by: Kanak Khandelwal <152528927+Kanakk23@users.noreply.github.com>
Co-authored-by: Rounak Kumar Agarwal <agarwalrounak2005@gmail.com>
Co-authored-by: Atharv Gaur <atharvanna71@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: aryanbajaj868 <117334799+aryanbajaj868@users.noreply.github.com>
Co-authored-by: Manas Choksi <162600900+choksi2212@users.noreply.github.com>
Co-authored-by: Vidhaankhare16 <vidhankhare16@gmail.com>
choksi2212 pushed a commit to choksi2212/BossConsole that referenced this pull request Sep 22, 2026
…lines (risa-labs-inc#691)

* fix(logging): keep query and fragment secrets out of browser URL log lines

"Browser created via BrowserService" logged a tab's full URL at INFO on
every tab creation, hibernation wake and crash recovery, and
"URL command queued" logged every boss://url link the same way. The data
map is appended to the line as-is, the line reaches the Console capture
that every plugin reads through logDataProvider, and the console plugin
serves that capture to MCP clients through console_tail and
console_search. An OAuth code, a sign-in token or a presigned signature
in the URL went with it.

Log LogSanitizer.describeUri (scheme, host and path) at the four sites
in BrowserServiceImpl, DeepLinkHandler and CLICommandHandler.
maskUriParams redacts by exact parameter name and hid 4 of 14
credential-bearing URL shapes measured; describeUri hid 12, missing only
a token carried in the path.

BrowserUrlLogConventionTest scans those three files for url-keyed log
data without LogSanitizer; DescribeUriSecretShapesTest pins the
describeUri output the sites now rely on.

* fix(logging): log deep links by shape and describe scheme-less references

From review of the browser URL logging change:

DeepLinkHandler logged every incoming link through maskUriParams, at INFO
or WARN, in six places: the three OS open-URI handlers, "Received deep link
from command line", "Processing deep link" and the unrouted-host warning.
maskUriParams redacts by parameter name, and a link's query is often not a
listed name: boss://url?url= carries a whole URL with its own query,
boss://terminal?command= a command, and an https link BOSS receives as the
default browser any credential under any name. Run through the real
processDeepLink and processCommandLineArgs, seven synthetic secrets in
those shapes all reached LogDataProvider.logs; after this change none does.
The six sites and the "Cookie rejected" line now use describeUri.

describeUri interpolated the scheme, so a reference with no scheme read as
null://localhost. That is exactly what CLICommandHandler logs for input
normalizeAndValidateUrl rejected, and what a boss://url link can carry. It
now renders localhost, //host/path, and "(with query params)" without a
leading space.

BrowserUrlLogConventionTest now accepts only describeUri for a url-keyed
value in the guarded files, so switching a site to maskUriParams is caught.
Its call parser skips comments and character literals, and its KDoc lists
what it cannot see.

* fix(logging): name the host when java.net.URI will not parse the authority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.

* fix(logging): preserve escaped path bytes in URI descriptions

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
choksi2212 pushed a commit to choksi2212/BossConsole that referenced this pull request Sep 22, 2026
…lines (risa-labs-inc#691)

* fix(logging): keep query and fragment secrets out of browser URL log lines

"Browser created via BrowserService" logged a tab's full URL at INFO on
every tab creation, hibernation wake and crash recovery, and
"URL command queued" logged every boss://url link the same way. The data
map is appended to the line as-is, the line reaches the Console capture
that every plugin reads through logDataProvider, and the console plugin
serves that capture to MCP clients through console_tail and
console_search. An OAuth code, a sign-in token or a presigned signature
in the URL went with it.

Log LogSanitizer.describeUri (scheme, host and path) at the four sites
in BrowserServiceImpl, DeepLinkHandler and CLICommandHandler.
maskUriParams redacts by exact parameter name and hid 4 of 14
credential-bearing URL shapes measured; describeUri hid 12, missing only
a token carried in the path.

BrowserUrlLogConventionTest scans those three files for url-keyed log
data without LogSanitizer; DescribeUriSecretShapesTest pins the
describeUri output the sites now rely on.

* fix(logging): log deep links by shape and describe scheme-less references

From review of the browser URL logging change:

DeepLinkHandler logged every incoming link through maskUriParams, at INFO
or WARN, in six places: the three OS open-URI handlers, "Received deep link
from command line", "Processing deep link" and the unrouted-host warning.
maskUriParams redacts by parameter name, and a link's query is often not a
listed name: boss://url?url= carries a whole URL with its own query,
boss://terminal?command= a command, and an https link BOSS receives as the
default browser any credential under any name. Run through the real
processDeepLink and processCommandLineArgs, seven synthetic secrets in
those shapes all reached LogDataProvider.logs; after this change none does.
The six sites and the "Cookie rejected" line now use describeUri.

describeUri interpolated the scheme, so a reference with no scheme read as
null://localhost. That is exactly what CLICommandHandler logs for input
normalizeAndValidateUrl rejected, and what a boss://url link can carry. It
now renders localhost, //host/path, and "(with query params)" without a
leading space.

BrowserUrlLogConventionTest now accepts only describeUri for a url-keyed
value in the guarded files, so switching a site to maskUriParams is caught.
Its call parser skips comments and character literals, and its KDoc lists
what it cannot see.

* fix(logging): name the host when java.net.URI will not parse the authority

describeUri built its description from URI.getHost(), which is null whenever the
authority is not a legal RFC 2396 hostname or IP literal. An underscore is
ordinary in intranet and container names, and a non-ASCII label is ordinary
anywhere, so those URLs were logged with the authority missing entirely:

  https://my_host.example.com/a?code=x  ->  https:///a (with query params)
  http://web_server/status              ->  http:///status
  https://muenchen.example.com/a        ->  https:///a

The "Cookie rejected" line in BrowserServiceImpl describes a cookie URL this way,
so it named no host at all for exactly the hosts whose names are least guessable.

describedBase now falls back to the raw authority when getHost() is null. The
parser is no help there: getRawUserInfo() is null and getPort() is -1 in exactly
the same cases, while getRawAuthority() still carries the userinfo verbatim, so
hostFromRawAuthority splits it by hand and removes both. Everything up to the
LAST @ is userinfo, because a private HTTPS clone URL is
https://x-access-token:<token>@host/... and splitting on the first @ would keep
the tail of a password containing one. A trailing :<digits> is a port; requiring
digits keeps a colon that is not a port rather than cutting the name at it.

The fallback runs only when getHost() is null, so every URL that parses today is
described exactly as before, the dropped port included.

A reference that is only delimiters ("?", "#", "?#") has an empty base, an empty
query and an empty fragment, so describeUri returned "" and the line read "uri="
with no value, indistinguishable from a line that logged nothing. It now reads
[empty reference], distinct from the [empty] that reports an absent input.

Tests: 56 in plugin-logging and 113 focused composeApp logging tests, all green;
the three new assertions fail first on the unchanged source. The userinfo guard
is pinned beside the existing risa-labs-inc#640 assertion, since naming the authority is what
makes that secret reachable. 7 mutations on the final code, 0 survivors.

* fix(logging): preserve escaped path bytes in URI descriptions

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
kshivang pushed a commit to nitininhouse/BossConsole that referenced this pull request Sep 22, 2026
kshivang added a commit to KhushiTrivediii/BossConsole that referenced this pull request Sep 22, 2026
…d sanitizer

Agent review fix for risa-labs-inc#916 (not contributor credit):
- The PR's own redactUrlUserInfo regex stopped at the FIRST '@' in the URL
  authority, so 'https://user:p@ss@host/' logged the credential tail
  'ss@host'. Delegate to LogSanitizer.redactUrlUserInfo (risa-labs-inc#640), which redacts
  at the LAST '@' per WHATWG URL parsing, and add regression tests for the
  at-sign password and for non-URL at-signs.
kshivang added a commit to KhushiTrivediii/BossConsole that referenced this pull request Sep 22, 2026
…PEM blocks in the argument sanitizer

Review repair for risa-labs-inc#886 (agent work, not credited to the contributor):

- URI userinfo: call LogSanitizer.redactUrlUserInfo (merged in risa-labs-inc#640) instead of a
  local regex. The local regex stopped at the first @ in the authority, so a
  password containing an @ left its tail (ss@host) in the approval dialog and the
  operation ledger. Issue risa-labs-inc#886's suggested fix calls for exactly this reuse.
- PEM private keys: mask the whole BEGIN..END block (a header-only mask left the
  key material on the line) and still mask truncated fragments to end of line.
- curl -u/--user: username part may not contain /, :, or @, and the flag must not
  be mid-token, so a bare URL argument (-u http://host) is left untouched while a
  real user:pass credential is masked whole (passwords may contain colons).
- Tests: pin the last-@ delimiter case, path-@ case, colon-in-password case, and
  full-block PEM masking.
nikhil-jangir-iitk added a commit to nikhil-jangir-iitk/sec_boss that referenced this pull request Sep 22, 2026
The free-text sanitizers left a password in the line when the URL's host was
an IP literal. Measured on dev 5dda86e, through the real sanitizer:

  in : Failed to connect to proxy http://alice:pass@10.0.0.5:3128
  out: Failed to connect to proxy http:[PATH]:pass@10.0.0.5:3128

filePathPattern's (?:/[^\s:]+)+ reaches //alice before urlPattern can match the
whole URL, so the URL is never recognised as one and the credential survives.

risa-labs-inc#640 closed this for maskUriParams and recorded the free-text path as a separate
change. This is that change.

The bound is narrower than risa-labs-inc#640 stated, and I measured it rather than assuming
it: the leak needs an IP-literal host. Against a DNS name, emailPattern matches
s3cr3t@proxy.corp.example and masks the password by accident. The DNS cases are
in the test as guards, not as evidence, and the test says so.

Where this lands is the reason it matters. These functions are where text BOSS
did not write arrives, and two call sites delegate that trust to them in their
own comments: McpToolRegistryImpl.executeUncapped puts a plugin's exception
message through sanitizeExceptionMessage before returning it to the agent, and
CrashReport puts every captured log entry through sanitizeLogMessage on the way
into a report built to be submitted. Neither can predict what it is handed. I
found no string BOSS itself builds that triggers this; git clone output is
already covered by risa-labs-inc#640's cloneProgressLogMessage.

The fix is one line. redactUrlUserInfo already exists and already has eight
tests from risa-labs-inc#640; it was simply never wired into this chain. It runs after the
query-parameter pass, which must see the original text, and before the location
passes, which need the authority still intact. Only the credential is removed;
what survives is masked as a location exactly as before.

Verified by running it. Red: four of the seven new tests fail against unmodified
dev, and the three that pass are the guards, so they are not passing vacuously.
Green: 63 tests in plugin-logging and 267 in composeApp's sanitizer and crash
suites, with no existing test modified. Mutation: removing the one line fails
the same four by name and leaves the guards green. detekt and ktlint pass.

Not covered: boss-plugin-api ships its own 306-line copy of this class with the
same pass ordering and no redactUrlUserInfo at all. The host copy shadows it
parent-first inside plugin classloaders, so the host path is closed, but that
repo needs its own change. A protocol-relative //user:pass@host is still
unchanged, as redactUrlUserInfo's own KDoc records.
kshivang added a commit to KhushiTrivediii/BossConsole that referenced this pull request Sep 22, 2026
…PEM blocks in the argument sanitizer

Review repair for risa-labs-inc#886 (agent work, not credited to the contributor):

- URI userinfo: call LogSanitizer.redactUrlUserInfo (merged in risa-labs-inc#640) instead of a
  local regex. The local regex stopped at the first @ in the authority, so a
  password containing an @ left its tail (ss@host) in the approval dialog and the
  operation ledger. Issue risa-labs-inc#886's suggested fix calls for exactly this reuse.
- PEM private keys: mask the whole BEGIN..END block (a header-only mask left the
  key material on the line) and still mask truncated fragments to end of line.
- curl -u/--user: username part may not contain /, :, or @, and the flag must not
  be mid-token, so a bare URL argument (-u http://host) is left untouched while a
  real user:pass credential is masked whole (passwords may contain colons).
- Tests: pin the last-@ delimiter case, path-@ case, colon-in-password case, and
  full-block PEM masking.
atharvgaur1845 pushed a commit to atharvgaur1845/BossConsole that referenced this pull request Sep 22, 2026
…dialog or the ledger

McpArgumentSanitizer masked a value when its key was sensitive, when it
was an assignment, when it was an Authorization header or when its shape
was a known token. A password inside a URI authority is none of those, so
`psql postgres://admin:hunter2@host/app` reached the approval dialog and
McpOperationLedger, which appends it to disk, verbatim (risa-labs-inc#886). risa-labs-inc#640 had
already added LogSanitizer.redactUrlUserInfo for the logging path; the
MCP path never called it.

sanitizeMessage now runs that helper first, so the userinfo of every URI
becomes [REDACTED] while the scheme, host, port and path stay readable,
which is what an operator approves a command by. Three more shapes the
issue measured leaking get a rule each: curl basic auth (`-u user:pass`,
requiring the colon so `git push -u origin` and a URI after `-u` are left
alone), an AWS access key id in its documented form, and a PEM private
key body.

McpArgumentSanitizerUriCredentialTest covers the issue's measured lines,
the look-alikes that must survive (an email in --author, /@handle, a bare
`-u admin`, a public key block) and that the existing rules still apply.

Closes risa-labs-inc#886

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
choksi2212 added a commit to choksi2212/BossConsole that referenced this pull request Sep 24, 2026
…L userinfo in logs, and complete the reload cycle

Fixes risa-labs-inc#911. The browser service accepted any non-blank URL on a
plugin-exposed IPC surface: a javascript: URL was executed in the page
context with no approval gate, and the raw URL - including
user:password@host credentials - was logged verbatim at INFO. A refused
scheme now short-circuits before any state is touched; allowed schemes
are http/https/file/ftp/about. URL logging redacts userinfo per the
risa-labs-inc#640 shape. reload() also stranded the window in loading=true forever
(STARTED emitted, COMPLETED never): the bookkeeping-only reload now
completes the cycle and clears the flag. 4 regression tests, none
collecting unbounded flows.

(cherry picked from commit 1adee41)
fluck-boss added a commit that referenced this pull request Sep 24, 2026
… close the cells it fails (#1019)

* fix(mcp): mask a credential in a URI authority before it reaches the dialog or the ledger

McpArgumentSanitizer masked a value when its key was sensitive, when it
was an assignment, when it was an Authorization header or when its shape
was a known token. A password inside a URI authority is none of those, so
`psql postgres://admin:hunter2@host/app` reached the approval dialog and
McpOperationLedger, which appends it to disk, verbatim (#886). #640 had
already added LogSanitizer.redactUrlUserInfo for the logging path; the
MCP path never called it.

sanitizeMessage now runs that helper first, so the userinfo of every URI
becomes [REDACTED] while the scheme, host, port and path stay readable,
which is what an operator approves a command by. Three more shapes the
issue measured leaking get a rule each: curl basic auth (`-u user:pass`,
requiring the colon so `git push -u origin` and a URI after `-u` are left
alone), an AWS access key id in its documented form, and a PEM private
key body.

McpArgumentSanitizerUriCredentialTest covers the issue's measured lines,
the look-alikes that must survive (an email in --author, /@handle, a bare
`-u admin`, a public key block) and that the existing rules still apply.

Closes #886

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mcp): state the argument sanitizer's contract as a property, and close the cells it fails

Four sanitizer leak reports in a week (#836, #837, #886, and the shapes
below) were each one hand-found cell of the same table: how a command
labels a credential, crossed with how the text is wrapped on its way
through a tool argument. McpArgumentSanitizerFuzzTest walks the table
with a seeded generator: 26 shapes (assignments and their `_`/`-` built
names, YAML and JSON keys, Authorization and cookie headers, long and
short flags, URI userinfo, npm's token line, vendor tokens, a JWT, an AWS
key id, a PEM block) times 10 contexts (quoting, `export`, chained and
piped lines, a multiline script, a JSON string with its escapes, prose,
unicode neighbours), plus a nested-argument pass, asserting the random
value never survives into what the dialog and the ledger receive; and 20
benign commands times the same contexts asserting byte-identical output,
because the operator approves what they can read.

On the sanitizer as of #979 the property failed in 303 cells across 11
shapes. Each is one rule now: sensitive names extended by `_`/`-` words
(`AWS_SECRET_ACCESS_KEY=`, `SECRET_KEY_BASE=`), a quoted or JSON-escaped
key before the `:`/`=` (`{"password":"x"}` in a curl body, also inside a
JSON string), long secret flags with a space (`--password x`, `--token x`),
the short `-p` only after the programs whose `-p` is a password (sshpass,
mysql tools, mongo, docker login, az login) and `-a` after redis-cli, npm's
`_authToken x`, a `Cookie:` header, and a PEM body carrying JSON-escaped
newlines. `max_tokens=4096` and `--tokenizer=` stay readable: only whole
words joined by `_`/`-` extend a name, since `tokens` is not `token`.

Whole ai.rever.boss.mcp package: 269 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp): redact encrypted PEM bodies, un-vacuous the aws fuzz cell

The pemPrivateKey body class excludes '-', so a traditionally encrypted PEM
- Proc-Type:/DEK-Info: headers between the BEGIN line and the base64 - stops
the match four characters in and the entire encrypted key body survives into
the approval dialog and the ledger. That is openssl rsa -aes256 and
ssh-keygen -m PEM with a passphrase. Let the rule absorb RFC 1421 header
lines, and add the encrypted-PEM shape as the 27th fuzz shape (the harness
could not see the leak: its pem block shape generates a headerless block).

Also make the aws access key id cell assert what it means: the shape renders
AKIA + an uppercased, truncated secret, so contains(secret) was vacuously
true. Probe for the rendered key body instead.

* fix(mcp): close the encrypted-PEM cell when the command travels in a JSON string

The RFC 1421 header arm anchored on real newlines only: when the whole
command is wrapped as a JSON string value the newlines arrive as '\' + 'n',
the arm cannot start, and the match stops four characters into Proc-Type -
the encrypted key body survives into the approval dialog and the ledger
(BOSS_SANITIZER_FUZZ_SEED=20260919, cell: encrypted pem block / json string
value). Let header lines end at a real or an escaped newline and stop line
content at either.

---------

Co-authored-by: Atharv Gaur <atharvanna71@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: fluck-boss <fluck-boss@users.noreply.github.com>
fluck-boss pushed a commit that referenced this pull request Sep 24, 2026
The free-text sanitizers left a password in the line when the URL's host was
an IP literal. Measured on dev 5dda86e, through the real sanitizer:

  in : Failed to connect to proxy http://alice:pass@10.0.0.5:3128
  out: Failed to connect to proxy http:[PATH]:pass@10.0.0.5:3128

filePathPattern's (?:/[^\s:]+)+ reaches //alice before urlPattern can match the
whole URL, so the URL is never recognised as one and the credential survives.

#640 closed this for maskUriParams and recorded the free-text path as a separate
change. This is that change.

The bound is narrower than #640 stated, and I measured it rather than assuming
it: the leak needs an IP-literal host. Against a DNS name, emailPattern matches
s3cr3t@proxy.corp.example and masks the password by accident. The DNS cases are
in the test as guards, not as evidence, and the test says so.

Where this lands is the reason it matters. These functions are where text BOSS
did not write arrives, and two call sites delegate that trust to them in their
own comments: McpToolRegistryImpl.executeUncapped puts a plugin's exception
message through sanitizeExceptionMessage before returning it to the agent, and
CrashReport puts every captured log entry through sanitizeLogMessage on the way
into a report built to be submitted. Neither can predict what it is handed. I
found no string BOSS itself builds that triggers this; git clone output is
already covered by #640's cloneProgressLogMessage.

The fix is one line. redactUrlUserInfo already exists and already has eight
tests from #640; it was simply never wired into this chain. It runs after the
query-parameter pass, which must see the original text, and before the location
passes, which need the authority still intact. Only the credential is removed;
what survives is masked as a location exactly as before.

Verified by running it. Red: four of the seven new tests fail against unmodified
dev, and the three that pass are the guards, so they are not passing vacuously.
Green: 63 tests in plugin-logging and 267 in composeApp's sanitizer and crash
suites, with no existing test modified. Mutation: removing the one line fails
the same four by name and leaves the guards green. detekt and ktlint pass.

Not covered: boss-plugin-api ships its own 306-line copy of this class with the
same pass ordering and no redactUrlUserInfo at all. The host copy shadows it
parent-first inside plugin classloaders, so the host path is closed, but that
repo needs its own change. A protocol-relative //user:pass@host is still
unchanged, as redactUrlUserInfo's own KDoc records.
kshivang pushed a commit that referenced this pull request Sep 24, 2026
The free-text sanitizers left a password in the line when the URL's host was
an IP literal. Measured on dev 5dda86e, through the real sanitizer:

  in : Failed to connect to proxy http://alice:pass@10.0.0.5:3128
  out: Failed to connect to proxy http:[PATH]:pass@10.0.0.5:3128

filePathPattern's (?:/[^\s:]+)+ reaches //alice before urlPattern can match the
whole URL, so the URL is never recognised as one and the credential survives.

#640 closed this for maskUriParams and recorded the free-text path as a separate
change. This is that change.

The bound is narrower than #640 stated, and I measured it rather than assuming
it: the leak needs an IP-literal host. Against a DNS name, emailPattern matches
s3cr3t@proxy.corp.example and masks the password by accident. The DNS cases are
in the test as guards, not as evidence, and the test says so.

Where this lands is the reason it matters. These functions are where text BOSS
did not write arrives, and two call sites delegate that trust to them in their
own comments: McpToolRegistryImpl.executeUncapped puts a plugin's exception
message through sanitizeExceptionMessage before returning it to the agent, and
CrashReport puts every captured log entry through sanitizeLogMessage on the way
into a report built to be submitted. Neither can predict what it is handed. I
found no string BOSS itself builds that triggers this; git clone output is
already covered by #640's cloneProgressLogMessage.

The fix is one line. redactUrlUserInfo already exists and already has eight
tests from #640; it was simply never wired into this chain. It runs after the
query-parameter pass, which must see the original text, and before the location
passes, which need the authority still intact. Only the credential is removed;
what survives is masked as a location exactly as before.

Verified by running it. Red: four of the seven new tests fail against unmodified
dev, and the three that pass are the guards, so they are not passing vacuously.
Green: 63 tests in plugin-logging and 267 in composeApp's sanitizer and crash
suites, with no existing test modified. Mutation: removing the one line fails
the same four by name and leaves the guards green. detekt and ktlint pass.

Not covered: boss-plugin-api ships its own 306-line copy of this class with the
same pass ordering and no redactUrlUserInfo at all. The host copy shadows it
parent-first inside plugin classloaders, so the host path is closed, but that
repo needs its own change. A protocol-relative //user:pass@host is still
unchanged, as redactUrlUserInfo's own KDoc records.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants