Skip to content

fix(passkey): mask email and user id in edge-function logs - #1490

Merged
kshivang merged 244 commits into
devfrom
codex/pr-1436-dev
Sep 25, 2026
Merged

kshivang merged 244 commits into
devfrom
codex/pr-1436-dev

Conversation

@kshivang

@kshivang kshivang commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Consolidated dev handoff for #1436

Recreated from Raghav Bansal's two commits on the latest dev, preserving the original commit authorship. The only conflict was in auth.ts; the dev branch's enumeration-safe inert response is retained while the email masking is applied.

Scope: mask passkey edge-function email and user-id values in persistent logs, with auth/mobile regression coverage. Related host-side session/challenge sanitizer work remains in #1440/#567 and is intentionally not duplicated here.

Validation:

Original PR: #1436

shivanshu-risa and others added 30 commits September 21, 2026 21:42
…h 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.
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
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
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.
…es 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.
… a compound or quoted credential cannot survive
Rushikeshiitb and others added 18 commits September 24, 2026 15:21
…1043) (#1044)

The absolute-date branch of formatRelativeTime now passes
Locale.getDefault(Locale.Category.FORMAT) to SimpleDateFormat explicitly, matching
the rest of the codebase's date formatters. This is a clarity/consistency change,
not a behaviour fix: the bare SimpleDateFormat("MMM d") constructor already
resolves to the FORMAT-category locale, so the rendered output is unchanged.

Add ProjectCardLocaleTest guarding that the branch honours the FORMAT-category
locale (not the general default and not a hardcoded one), so a future regression
to Locale.getDefault() or a fixed locale is caught.
…ope (#1029) (#1030)

SplitViewOperationsImpl launched every plugin-facing operation on a bare
CoroutineScope(Dispatchers.Main): no SupervisorJob, so one child coroutine failing
cancelled the scope's Job and silently killed every later launch, and the scope was never
cancelled, so a closed window leaked it and any in-flight Main-dispatched work.

- Use CoroutineScope(SupervisorJob() + Dispatchers.Main) so one operation's failure does
  not take down the others.
- Implement the existing DisposableProvider seam and cancel the scope in dispose().
  DefaultPlugin.dispose() now releases the plugin-facing instance (via a named lazy
  delegate, guarded by isInitialized() like the other providers), and the composable that
  owns the BossAppState instance disposes it in a DisposableEffect.

The scope is an injectable constructor parameter (default preserves production) so dispose()
can be tested without a Main dispatcher. The detekt baseline entry for this class is updated
to match its added supertype; it is the same suppression, not a new one.
…ugh atomicWriteText (#1011) (#1022)

Migrate the listed settings/state managers' JSON persistence from a direct
writeText to atomicWriteText (temp file then atomic rename), so an interrupted or
concurrent write cannot leave a truncated or torn file. Not every writer in the
app is migrated yet - only the managers listed in this change.

Rebased onto current dev, which merged #1032's saveMutex serialization for the
four settings managers (FocusMode, Performance, DesktopRunner, DesktopTerminalLink).
This keeps that mutex and does the atomic write inside the lock, in both saveSettings
and the load-time default-file write, for all four. The mutex only orders writes;
atomicWriteText is what makes each write crash-safe, so the load-time read never sees
a torn file even though it stays outside the lock - the mutex comment now says so.
That is the remaining half of #1031 and closes #1659.

Add a test asserting the write goes through the atomic helper: after an update the
settings file is pinned to owner-only 0600 permissions, which atomicWriteText sets
and a plain writeText does not, so the test fails if a call site regresses to a
non-atomic write (the pre-existing no-leftover-temp check passes either way).
…l meets it (#1668) (#1671)

The /list route caps pageSize (#1508), but search_plugins is anon-callable through
PostgREST and passed p_page and p_page_size straight to search_plugins_internal, which
ends in `LIMIT p_page_size OFFSET (p_page - 1) * p_page_size`. So
`POST /rest/v1/rpc/search_plugins {"p_page_size": null}` was LIMIT ALL: every visible
plugin, each with its own version, rating, download and tag subqueries. A negative size
or page raised a LIMIT/OFFSET error, and a huge page overflowed INT.

Both wrappers now clamp before calling the internal function: page size to 1..100 (NULL
-> 20), page to 1..1,000,000 (NULL -> 1). The same shape as get_popular_tags in
20260922160000. Values inside the ranges are unchanged, and the route's own ceiling is
100, so no request the route accepts changes. Clamping in the wrappers avoids restating
search_plugins_internal's hundred-line body, and they are its only callers:
search_plugins_internal is service_role only.

search_plugins is re-granted to anon, because the enforce_explicit_anon_grants trigger
revokes it on CREATE OR REPLACE. search_plugins_for_viewer's service_role-only ACL is
restated.

The pgTAP test uses 150 public plugins. It pins null, zero, negative, one-past and
oversized page sizes, null, zero, negative and INT-max pages, total_count unchanged,
both band edges, the ACL, and the viewer wrapper.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Introduces {{secret:<id>}} references in MCP tool arguments, resolved in an ordered pre-pass ahead of the policy and approval path: denied or feature-off calls never touch the vault, resolution is all-or-nothing, a secret-bearing call always asks (tool or provider ALLOW, session trust and YOLO never satisfy it), the ledger records the original arguments and names the released references, and an optional result scrubber redacts echoed values. The pre-pass's argument parses sit under dev's depth guard, and the invariant suite pins non-disclosure, atomicity, ordering, approval binding, transparency, consistency and bounded work.
… spellings (#1656)

* fix(mcp): stop masking a container uid:gid, and cover the cookie flag spellings

Two findings from the review of #1019 that were left open when it merged.

`docker run -u 1000:1000 nginx` came back as `docker run -u [REDACTED]`. The
basic-auth rule keys on the `user:password` colon after `-u`/`--user`, and a
uid:gid pair has the same shape, so one of the commonest docker and podman
invocations there is had the very thing approval exists to show - which uid a
container is about to run as - taken out of it. No credential is two integers, so
a purely numeric value is now excluded; `-u admin:1234` still redacts, because
only both sides being integers makes it a uid pair. The benign corpus gains the
`-u` twin of the `docker run -p 8080:80` entry that should have caught this, plus
the `--user=` and podman spellings.

`Cookie: session=x` was closed by the header spelling alone. The flag spellings
went through untouched: after `-b` or `--cookie` the cookie's name is whatever the
site chose, so no list of names helps, and the flag has to be the signal. The whole
value is taken, as the long-flag rule already does for `--password`. `-b` also
names a jar file, which is redacted too: telling a path from a cookie needs a
filesystem the sanitizer does not have, and over-masking one curl argument is the
safe direction.

Both are pinned as fuzz shapes rather than examples. Against the sanitizer as it
merged, the two cookie shapes leak 60 cells and the benign corpus fails on the
docker line.

Also from that review, without behaviour changes: the failure message counts the
distinct cells it prints rather than the pre-`distinct()` total, which with
`repeat(3)` reported nine for three; and the generator's KDoc states the blind spot
its alphabet creates, namely that a generated secret can never contain a character
that ends a value, so no cell can discover that `password=Xk9&mQ2` leaves a tail in
the clear.

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

* fix(mcp): gate the short cookie flag on curl, and pin the -b false positives

Review follow-ups. The first version matched a bare `-b` after any program, which
reintroduced exactly the over-masking this change removes for `-u`: `git checkout -b
feature/x`, `ssh -b addr`, `tar -b 20` and `gradle -b file` lost their argument, and
flags that take no value (`cp -b`, `grep -b`, `wget -b`) lost the next positional. The
short flag is now gated on curl the way `-p` is gated on its programs; `--cookie` and
`--cookie-jar` stay unconditional because the name carries the meaning. The benign
corpus gains `git checkout -b`, `cp -b` and `ssh -b`.

The failure count now dedupes by shape and context: each failure string embeds that
repetition's random secret, so plain distinct() collapsed nothing. uidGidValue moves
above basicAuthFlag's KDoc, which it had detached from its declaration.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci: run the authenticated-ipc.yml Windows leg only for PRs into main

* ci: run the build.yml Windows leg only for PRs into main

Updated the OS matrix configuration for build-test job to clarify conditions for macOS and Windows runners.

* ci: run the native-file-boundaries.yml Windows leg only for PRs into main

* ci: run the filesystem-limits.yml Windows leg only for PRs into main

* docs: note the Windows legs skip PRs that don't target main
… warning (#991)

Adds buildSrc/settings.gradle.kts with an explicit rootProject.name, so the type-safe project accessors preview stops warning on every Gradle invocation and the build identity is explicit rather than derived.
…eads it (#1658)

* fix(cli): refuse a quote inside a boss.bat argument before anything reads it

Every argument read in boss.bat wraps the value in quotes - if "%~1"=="" -
and cmd substitutes %~1 before it parses the line, so a " in the value
closes that quote early. x"=="x" calc & rem " keeps its & quoted for the
caller's own parse, but turns the first check into
if "x"=="x" calc & rem ""=="" and runs calc.

Capture the raw %* on an echoed REM line (the one place cmd writes it out
without parsing it), read it back through a FOR variable, and refuse any
quote that does not open or close a whole argument. Whole-argument quotes
are all a path or URL needs, and with none inside, no %~N can carry a
quote into the quoted reads, so the rest of the script is unchanged.

Fixes #1617

* fix(cli): scope the boss.bat quote check to what is read, and claim the capture file

Review on #1658:

- status, doctor, mcp and completion pass their arguments to BOSS.exe
  as a bare %*, which cmd re-reads exactly as the caller's line was
  read, and never through %~N. Check only their first argument, so
  `boss mcp invoke tool --args {"q":"x"}` (docs/CLI.md) works again.
  plugin stays strict: it reads %~2 and %~3 before it forwards.
- %RANDOM% is seeded from the clock, so two calls started together
  could share the capture file and each check the other's arguments.
  Claim a directory with md, which fails when it exists, and refuse
  the call if none can be claimed.
- Clear the capture variables before anything is forwarded.
- Run the live cmd.exe probes on the Windows leg of build-test, so the
  mutation checks run in CI and not only on a fork.

Tests: forwarded JSON arguments, first-token and plugin strictness,
escaped > >> | & < on the command line, and eight parallel calls that
must each check their own arguments.

* test(cli): pin that a forwarded argument stays data, and that a taken capture directory is refused

- A payload in argument 2..N of a forwarded command reaches the stub and
  runs nothing, so a future %~2 read in a forwarded command fails the test.
- A copy that always draws the same capture directory name finds it taken
  and refuses, instead of reading the other call's arguments.
- The leftover check only judges capture directories made during the test.
- The for /f read is guarded again, so a missing capture file shows only the
  script's own refusal.
- The refusal points at boss.ps1 for a terminal command that needs a quote,
  and docs/CLI.md says what cmd accepts.
- The Windows CI step runs right after checkout, in one pwsh.
…ly (#741)

A window's Save now binds its live layout to that window's own active Space identity instead of the process-global one, rebinds the window only after the write has landed, and reports a failed write instead of premature success. A per-effect latch coalesces overlapping menu saves into one re-run on the latest layout, the named dialog ignores an overlapping submit, and late callbacks are fenced against window teardown.
* docs(plugin-store): clarify audit RPC failure behavior

Clarify comments regarding JSON input rejection in audit payload.

* fix(plugin-store): log resolved API key audit RPC errors

* test(plugin-store): cover resolved audit RPC failures

* fix(plugin-store): preserve safe audit failure kind
…d both providers (#1552)

* fix(plugins): resolve the Downloads folder one way for the browser and both providers

The browser's save location, the in-process FileSystemDataProvider and the
out-of-process FileSystemDataProviderProxy each resolved the Downloads folder
differently, so a plugin was told a different folder depending on which
process it was loaded into. When ~/Downloads was absent, in-process plugins
got the home folder and out-of-process plugins got a path that did not exist,
with mixed separators on Windows.

Add DownloadsDirectory to plugin-path-utils and route all three through it.
The shared XDG parser also handles ${HOME}, XDG_CONFIG_HOME and last-wins.

DownloadsDirectoryConsistencyTest fails on main and passes here;
DownloadsDirectoryTest covers the per-platform rules on any OS.

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

* fix(plugins): keep the Downloads consistency test out of the Windows ARM64 build, and read XDG edge values as xdg-user-dirs means them

Review follow-up for #1552.

- DownloadsDirectoryConsistencyTest imports FileSystemDataProviderProxy from
  plugin-api-ipc, which Windows ARM64 does not build. It is now excluded with
  SkipListDriftTest when that module is absent. WindowsArm64SourceIsolationTest
  lists it in unconditionalExclusions, so the build-file drift check agrees, and
  gains ai.rever.boss.plugin.ipc. in unavailablePackages, so the next such import
  fails on every platform instead of at release.
- XDG_DOWNLOAD_DIR: an empty last value (how xdg-user-dirs records a disabled
  directory) no longer revives an earlier assignment; a relative value is read
  against home, not the working directory; a value naming home itself falls back
  to the conventional folder.

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

* fix(plugins): treat $HOME/. and $HOME/sub/.. as home in XDG_DOWNLOAD_DIR

Re-review follow-up for #1552. absolutePath does not remove dot segments, so
those forms passed the "not home itself" check and handed back home. The value
and home are now compared after Path.normalize(), which needs no filesystem
access, so existence stays injected in the tests.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#894)

* test(auth): add UserDataStorage merge, clear, and race contract tests

* test(auth): harden the torn-read detector against vacuous passes

A detector thread that died on an unexpected error (or never decoded a
record) would have left the torn-write test green while proving nothing.
Record unexpected detector failures and successful decodes, assert the
thread actually stopped, and add Thread.onSpinWait so the busy-spin stops
fighting the writers for the cores the overlap depends on. Also correct
the lost-update round comment (path coverage is the scheduler's choice,
not the test's claim) and document on clearUserData that the pre-login
pending_wizard_completed marker is deliberately preserved through logout.

* test(auth): satisfy detekt on the hardened torn-write test

Extract the detector iteration and the writer race into private helpers
(LongMethod), drop the second jump statement from the detector loop, and
suppress TooGenericExceptionCaught on the deliberate sentinel catch - the
same convention as BrowserFindTest.

* test(auth): close the remaining vacuity and flake holes in the torn-write race

- replace the broad detector catch with an uncaught-exception handler
  (detekt's TooGenericExceptionCaught stays clean and the net widens)
- restore the transient IOException tolerance dropped in the refactor
- latch the writers to the detector's first disk read: overlap becomes a
  guarantee, killing the successfulDecodes scheduling flake
- assert the detector saw a decoded form beyond the seed, so the test
  cannot pass when no racing write ever landed (e.g. every atomic move
  failing on a platform)
- assert torn-reads before the inconclusiveness guards so a real finding
  is never buried; correct the onSpinWait rationale (PAUSE hint, not yield)
- update the PR body: no behavior changes, only a clearUserData KDoc

* test(auth): fix unscoped async and detekt SwallowedException in the race test

- wrap the 24 racing writers in coroutineScope (a bare async in a suspend
  fun selects the deprecated unscoped overload, which fails compilation)
- name the transient IOException catch '_' so detekt's SwallowedException
  stops flagging the deliberately ignored sharing-violation window

* test(auth): duty-cycle the detector, guard-order it, and always stop it

- parkNanos duty cycle instead of a hot spin: a spinning detector pins a
  core AND keeps user_data.json open a large fraction of the race, which on
  Windows denies the writers' Files.move(., REPLACE_EXISTING, ATOMIC_MOVE)
  delete access so every write no-ops - turning the non-vacuity assertion
  into a misleading loud failure. Sampling on a ~0.1 ms duty cycle still
  takes hundreds of samples across the race, which is plenty for a negative
  no-torn-read invariant, and lets the moves land.
- detector-health guard now precedes the decodedForms non-vacuity guard
  (a detector that died right after its first read would otherwise read as
  'no racing write ever landed')
- stop + join the daemon detector in a finally so a latch-timeout or writer
  throw can't leave it spinning (re-reading storageFile) for the whole
  shared test-worker JVM
- scope the 'cannot flake on scheduling' header claim to the mutex-based
  race tests (2 and 3); test 4 is observation-dependent/best-effort

* test(auth): mark the merge race inconclusive when Windows lands no write (per review)

---------

Co-authored-by: Mihir-Rabari <mihir.rabari@users.noreply.github.com>
Co-authored-by: Shivang <shivang.iitk@gmail.com>
…ngs backend (#918)

* fix(auth): persist Supabase session tokens through an encrypted Settings backend

Root cause: install(Auth) left the supabase-kt defaults in place, and its default Settings() resolves to java.util.prefs.Preferences on the JVM, so the session JWTs - including a live refresh token, the highest-value secret in the app - were written as plaintext under ~/.java/.userPrefs (XML), the macOS plist, or the Windows registry: a library-owned store outside the ~/.boss directory this app otherwise keeps owner-only.

Fix: EncryptedSessionSettings, a Settings backend that AES-256-GCM encrypts every value with a fresh 96-bit IV per write into ~/.boss/supabase/session-store.enc, with the random 32-byte key in session-store.key written through atomicWriteText so it lands 0600 on POSIX from its first byte, mirroring the #860 hardening. It is fail-closed: crypto unavailability or a corrupt key aborts construction, never falling back to plaintext. install(Auth) now wires SettingsSessionManager and SettingsCodeVerifierCache onto this backend, and a one-shot migration copies the legacy Preferences plaintext first and then destroys it, so existing sessions survive the upgrade and the live plaintext refresh token is revoked.

Test: EncryptedSessionSettingsTest adds 10 cases, all green: injection of the encrypted backend into the Auth session manager and code verifier cache (assertSame through reflection into the private settings field), string and typed-value roundtrips across a restart, persisted bytes contain no plaintext, the key file is created owner-only on POSIX, remove/clear purge disk, tampered entries decrypt to nothing, legacy Preferences content migrates in with the plaintext destroyed afterwards, and the encrypted copy wins over a stale legacy copy.

Fixes #846

* fix(auth): migrate URL-qualified legacy session keys with dual compatibility

* fix(auth): keep the legacy migration within the detekt return-count rule

Agent review fix for #918 (not contributor credit): migrateLegacyEntry had
five returns, tripping detekt's ReturnCount limit and failing the code-quality
gate. Fold the empty-populated and null-value guards into one check and the
two warn-and-abort arms into a small helper; the fail-closed behavior (keep
the legacy plaintext until the encrypted copy is written) is unchanged.

* fix(auth): fail removals closed, regenerate unusable keys, pin library defaults

- remove/clear destroy the store when the revoking rewrite fails, so a
  sign-out that reported success can never leave a revoked refresh
  token on disk to be auto-loaded at the next start
- an undecodable or wrong-length key file regenerates instead of
  throwing: a corrupt key means sign-in again, not a Supabase client
  that refuses to build
- the key file is created exclusively (Files.createFile) so two
  processes starting against the same directory adopt one key instead
  of each orphaning the other's store
- a drift test pins supabase-kt 3.8.0's default URL-qualified key
  names and its Preferences.userRoot node against the library itself,
  so a dependency bump that changes either fails the build instead of
  silently stranding plaintext tokens in the legacy store

---------

Co-authored-by: Shivang <shivang.iitk@gmail.com>
Co-authored-by: Mihir-Rabari <mihir-rabari@users.noreply.github.com>
…ce directory (#880)

* fix(mcp): keep workspace ids from selecting files outside the workspace directory

open_workspace and close_workspace (#794) took a caller-supplied workspaceId
ending in ".json" as the file name itself, and the desktop workspace file
manager joined any name onto its directory without confinement. So
open_workspace {"workspaceId":"../x.json"} read and applied a file beside
the directory, and close_workspace with a "workspace-disposable-/../x.json"
id deleted one on Windows, where ".." resolves lexically even when the
component before it does not exist. ~/.boss/mcp-tool-policy.json, the
persisted MCP policy file, is one directory up from the default workspace
directory's parent and is exactly such a file.

WorkspaceFileManagerCommon.isBareFileName states the rule (one component,
no separator of either platform, not "." or "..", no NUL) and
getWorkspaceFilePath enforces it, so every read, write and delete through
the manager is confined; each caller already catches and logs, so a refused
name surfaces as not found or not saved. The provider derives a file name
from an id the same way the file was written (fileNameForId, after
stripping an echoed ".json"), for both tools.

Tests: the end-to-end open case (failed before), a close case that pins
the derived name and the surviving file, and file-manager cases for
delete, load, loadDocument, writeDocumentBlocking and saveWorkspace with
a relative path (delete, load and the writes failed before).

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

* fix(mcp): make the created file name agree with the read one, and say what the bare-name rule means

Review follow-ups on the file-name confinement.

The create path and the read path disagreed for an id ending in `.json`.
`workspaceFileNameFor` strips the suffix before deriving, so `foo.json` reads
`foo.json`; `createIfAbsent` kept the id verbatim, so `saveWorkspace` derived
`foo.json.json` from it. A Space created that way could never be reopened by the
id that made it, and a second `createIfAbsent` silently replaced its layout. The
id is now stripped once where it is minted, so both halves derive the same name.
Pre-existing, but the property this change claims is that the name read is the
name written, and this was the case where it was not.

`isBareFileName` now judges the component Win32 resolves rather than the one it
was handed. Win32 trims trailing spaces and dots, so `".. "` addressed the parent
directory while passing a `!= ".."` test; trimming collapses every
all-dots-and-spaces name to empty, so one emptiness test covers `.`, `..`, `...`
and `".. "` together. `:` is refused (a drive, `C:x.json`, or an alternate data
stream, `notes.json:evil`) instead of relying on the JDK throwing
InvalidPathException at the join, and the control-character test is `isISOControl`
rather than NUL only, matching `isSafeWorkspaceId`. Neither case was an escape to
a file, so this is hardening, not a hole being closed.

The KDoc now says which layer this is: `isSafeWorkspaceId` is the id-level rule at
the tool boundary, `isBareFileName` the name-level rule at the file manager, and
the two differ on purpose (a name may contain `..` inside it, an id may not), so
neither implies the other. `getWorkspaceFilePath`'s `expect` declaration documents
the throw, which is the contract callers outside this class meet.

Tests: an id ending in `.json` creates one file, named as the read path derives
it, and reopens by either spelling; the bare-name rule accepts four real names and
refuses thirteen shapes including `...`, `".. "`, `C:x.json` and `x.json:stream`;
an empty name addresses the workspace directory and is refused on all four
entry points. The two provider tests that dev's `isSafeWorkspaceId` now refuses
first say so, and assert that message, so they read as regression guards for that
gate rather than as cover for this change; the file-manager test is the one that
fails without `isBareFileName`. Reverting the two fixes fails exactly the two new
tests (`must refuse: '...'`, `round-trip.json.json`). 255 tests green; ktlint and
detekt clean.

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

---------

Co-authored-by: Atharv Gaur <atharvanna71@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…es (#957)

* fix(mcp): share the startup-command refusal across open_workspace modes

open_workspace's id mode refuses to open a saved Space whose layout carries
terminal initialCommand entries - those commands were not visible in the
invocation's approval arguments - but the path mode re-entered the same
saved-Spaces machinery with no gate: opening a project path applied any saved
Space registered for it (matchExistingSpace rule 3) and typed its stored
terminal startup commands into a shell, unapproved. The refusal could be
walked around by passing a path instead of an id.

Both modes now route through one shared refusal (initialCommandsRefusal): the
same message, the same shipped-template exemption, and the same placement
before anything is applied - before the path mode's reuse fast path for the
same reason the id mode's gate sits before its already-active return. The
operator's doors are unchanged: the workspace UI loads a command-carrying
Space behind its confirmation prompt, and open_terminal still types one
command an invocation the risk gate has actually seen.

Adversarial coverage: a saved Space whose terminal writes a marker file is
refused by path entry with the id mode's refusal word for word and is never
applied to the window, while a saved Space without startup commands still
re-enters with its terminal intact.

Fixes #920

Windows follow-up (first CI run caught it): the two new path-mode tests failed on
windows-latest because matchExistingSpace compared project paths by raw string. The
request arrives canonicalized (C:\dir) while a saved Space's stored spelling can
legitimately differ (C:/dir - another save surface, or another OS in a synced
workspace file), so rule 3 missed the Space: the refusal silently missed with it,
and the benign re-entry minted a duplicate instead of reusing. The lookup now uses
path identity (matchesProjectPath over TabPaths.pathsMatch, the same definition
open tabs already trust), with a spelling-insensitivity unit test; on macOS/Linux
the spellings already agreed, so nothing changes there.

* test(mcp): register path-mode Space fixtures after the manager's first load

The workspaceManager singleton loads its picker list asynchronously once
per process, and that first load REPLACES the whole list. A test that
registered a fixture right after first touching the singleton could
have its registration swapped out: matchExistingSpace then saw nothing
for the project path, path mode minted a fresh Space, and the test saw
"opened" where a refusal or reuse was expected. Linux runners usually
finished the load first; the Windows CI run (35360957645) did not, which
is the flake behind that red check.

Both path-mode tests that register a Space now await the singleton's
first non-empty workspaces value before registering, so the
registration lands after the load and can no longer be replaced under
it. No assertion is weakened, and the gate logic is untouched.

The registered ids are also dropped from the process-wide singleton in
tearDown so later test classes in the same JVM inherit no fixture.
WorkspaceManager has no unregister door - deleteWorkspaceById removes a
row only when its own file manager deleted the file, which is not where
a test fixture lives - so the helper reaches the backing flow directly,
the same way other desktop tests reset otherwise-final private state.

* test(mcp): wait for the merged first load, not a non-empty list

The first-load wait `first { it.isNotEmpty() }` can be satisfied by a
pre-load registration: the create_workspace tests in this class register
into the workspaceManager singleton with no wait and JUnit method order
is not fixed, so if one runs first the wait returns before the async disk
load lands, and that load then replaces the list - dropping the fixture
exactly as in the Windows flake. Wait instead until every shipped layout
id is present, which only the merged post-load list satisfies.

Review follow-up from the 957c15e pass.
)

Adds TerminalService.CloseInput, backed by TerminalSession.closeStdin(), which delivers
EOF on a session's stdin without terminating it. A stdin-consuming one-shot command
(sort, grep, cat with no args, or a pipeline ending in one) otherwise blocks on read()
forever, and its run_command exit chunk is never produced.

Rebased onto dev's reworked input path (#939, #1527):

- closeStdin() takes the same input mutex as send(), through the same bounded
  acquireInputLock, so a close never races a write on the same fd, and a caller behind
  a stalled writer is answered RESOURCE_EXHAUSTED "Terminal input is busy" with a
  retry-after, exactly as a queued send is.
- A deliberate close is tracked apart from dev's inputClosed (set when a write fails), so
  a send after it reads "Terminal input has been closed", not a broken pipe.
- IpcVersion.CURRENT moves to 1.4.0 (1.3.0 is now #1144's NodeSkipped), and
  :boss-ipc's module version follows, so verifyIpcVersionConsistency passes. The history
  note says the bump is not a capability signal: an older host answers CloseInput with
  UNIMPLEMENTED, which callers must treat as "not supported".
- inputBusy() moved to file scope and requireUsableInput() folded to one throw, to stay
  within detekt's TooManyFunctions and ThrowsCount limits.

Tests: TerminalCloseInputTest (5) covers EOF delivery with and without input, an
idempotent second close, a send refused after close while the process is still alive
(drain-stdin-hold fixture), and the busy answer while a 1 MiB write holds the lock,
bounded so a regression fails instead of hanging. Mutation-checked: an unbounded wait in
closeStdin, or dropping the closed-on-request check, each fail a test.

Refs #928
…che writes (#1679)

* docs(ci): record dev-push cache seeding and read-only PR caches

* ci: seed shared Gradle caches from dev, read-only caches on PRs

PRs stop writing PR-scoped Gradle caches (cache-read-only on pull_request); a ubuntu-only build on dev pushes seeds the shared caches PRs into dev restore.

* build(test): run desktop tests in two forks

* Revert maxParallelForks: forks would share test-home (per-fork homes needed first)

* docs(ci): fix stale validation sentences, pushes-only cache writes

* ci: only pushes write Gradle caches (dispatch runs read-only too)
Raghav-Bansal-15 and others added 3 commits September 24, 2026 15:36
Unauthenticated challenge and mobile-page paths logged raw emails and user
ids on every request, so persistent logs were keyed on attacker-controlled
input. Log masked forms instead.

(cherry picked from commit 0ec5e20)
@kshivang
kshivang marked this pull request as ready for review September 24, 2026 22:44
@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 168ed35 (no tests executed).

Review: PR #1490 @ 168ed35 → dev

Scope is a log-hygiene change: a new utils/logging.ts with three masking helpers, applied to ~25 console.log call sites, plus three tests. No behavioural code paths change, so regression risk to auth flows is low. My findings are therefore concentrated in the mask helpers themselves and in how strongly the new tests pin the behaviour.


Confirmed defects

1. maskEmail passes an unbounded, unsanitised attacker-controlled substring straight into the log line — supabase/functions/passkey/utils/logging.ts:11-16

export function maskEmail(email: string): string {
  ...
  return `${email.slice(0, 1)}***@${email.slice(at + 1)}`
}

The module docstring (logging.ts:3-7) states the exact threat model: "these call sites sit on unauthenticated request paths, so raw emails and user ids - attacker-controlled input - must not reach them." But the domain half is echoed verbatim with no length cap and no CR/LF/control-character stripping. The first call site is services/auth.ts:82, which logs before any lookup or validation of email:

console.log('🔑 Generating authentication challenge for email:', maskEmail(email))

An unauthenticated request with email = "a@x.com\n✅ Authentication successful for user: 1234…" produces a forged log line; a multi-kilobyte domain produces log flooding. Given the function's stated purpose is to neutralise attacker-controlled input on unauthenticated paths, this is a gap in the control itself, not merely a style nit. Suggested fix: cap length and strip [\r\n\u0000-\u001F] (or hash the domain) before interpolation.

Caveat: I cannot verify from the diff whether an upstream handler validates email shape before reaching generateAuthChallenge. If it does, severity drops to low; the helper is still the wrong place to rely on that.

2. Same passthrough, smaller blast radius, in maskUserId / maskSessionId — logging.ts:19-28. Both emit value.slice(0, 4). sessionId is explicitly client-supplied per the comment at services/auth.ts:472-474 ("a client-supplied sessionId can legitimately be reused"), so sessionId = "\n\n\n\nx" yields four raw newlines in the log. Bounded to 4 characters, so this is minor, but it is the same class of issue and the same one-line fix.

3. maskSessionId(undefined) and maskSessionId("abc") are indistinguishable from each other and from a real masked value — logging.ts:25-28, consumed at services/registration.ts:56:

console.log('… for user:', maskUserId(userId), 'sessionId:', maskSessionId(sessionId))

sessionId is optional here (sessionId?: string), and the same-device flow passes nothing. Previously the line read sessionId: undefined, which unambiguously identified the same-device path; it now reads sessionId: ***, which reads as "a session id existed and was masked." That is a real debuggability regression on the exact flow these logs exist to triage. utils/challenge.ts:30-31 got this right (options?.sessionId ? maskSessionId(...) : undefined); registration.ts:56 did not. Recommend making the helper return a distinct sentinel (e.g. 'none') for nullish input, or applying the ternary consistently.


Uncertain observations

4. Incomplete coverage of the stated policy. services/management.ts:48 masks the user id but leaves passkeyId raw:

console.log('🗑️ Deleting passkey:', passkeyId, 'for user:', maskUserId(userId))

If passkeyId is a DB row UUID this is probably fine; if it is (or is derived from) a credential id it is a per-user stable identifier and should be masked for consistency. I cannot determine which from the diff. Related: utils/challenge.ts:28 still logs challenge.substring(0, 20) — pre-existing and out of scope, but it means "logs carry no correlatable per-ceremony value" is not actually true.

5. maskEmail retains the full domain. For consumer domains this is fine; for single-tenant/corporate domains plus the first initial it is frequently re-identifying. Whether that clears your bar is a policy call, not a bug.

6. Cosmetic edge cases in maskEmail (logging.ts:13-15): input "@example.com" yields @***@example.com; input with no @ collapses to ***, silently erasing the fact that the input was malformed (which is itself a useful signal on an unauthenticated path).

7. Type-signature asymmetry. maskSessionId accepts string | null | undefined; maskEmail/maskUserId are typed string but carry runtime typeof guards (logging.ts:12, :20). Callers such as services/auth.ts:574 (maskEmail(userResult.user.email)) and utils/database.ts:553 (maskEmail(userData.email)) rely on those guards if the column is nullable. It works, but the types lie; widening the two signatures to match maskSessionId would make the contract honest.


Test gaps

8. The two service-level "no leak" tests can pass vacuously. tests/auth.test.ts:323-369 asserts only on captured log content and never asserts the call succeeded:

await generateAuthChallenge(mockClient as unknown as SupabaseClient, 'victim@example.com', 'session-xyz')
...
assertExists(logged.find(line => line.includes('v***@example.com')))
for (const line of logged) { assertEquals(line.includes('user-456'), false, …) }

The v***@example.com line is emitted unconditionally as the first statement of the function (auth.ts:82), so it is satisfied even if the RPC mock does not match and findUserByEmail returns User not found. In that early-return path the user-456 and session-xyz assertions are trivially true because those values were never reached — i.e. the test would still pass if auth.ts:93 (maskUserId(userId)) were reverted to raw. Adding assertEquals(result.success, true) (or asserting the specific masked user-id line is present) would close this. tests/mobile.test.ts:532-582 has the identical structure and the identical weakness, and additionally depends on mockChallenge.user_id === 'user-456', which is not visible in the diff — if it differs, the user-id assertion is inert.

9. No direct unit tests for utils/logging.ts. All three helpers are only exercised transitively. The interesting branches — empty string, missing @, length <= 4, null/undefined, non-string input (logging.ts:12, 20, 26) — are untested, and those are exactly the branches where the defensive guards live. A dozen table-driven assertions against the module directly would be cheaper and stricter than the service-level tests.

10. Assertion is coupled to an incidental prefix. tests/registration.test.ts:93 asserts logs.some(line => line.includes('sess…')), which only holds because the fixture happens to be 'session-secret-123'. Renaming the fixture silently guts the test. Asserting the exact expected masked string is more robust.

11. Console monkey-patching hygiene. All three tests use try/finally to restore, which is correct. Two residual risks: the capture closure calls JSON.stringify(arg) on non-strings (auth.test.ts:341, mobile.test.ts:555), which throws on circular structures — plausible for a Supabase error object — turning a masking regression into an opaque test crash; and global console replacement is not safe under intra-file parallelism should that ever be enabled.


Summary

The direction is right and the mechanical application across auth.ts, mobile.ts, registration.ts, management.ts, challenge.ts and database.ts looks consistent. Before merge I would fix (1) — sanitise and length-cap the domain passthrough in maskEmail, since it defeats the control's own stated threat model — and (3), the undefined → *** conflation at registration.ts:56. I would also strengthen the two service tests per (8) so they cannot pass on an early-return path, and add direct unit tests for logging.ts per (9).

Note: this is a diff-only review. I have not run the suite and have not inspected the surrounding modules, the mock Supabase client, or any call sites outside the hunks shown; claims about untouched code are explicitly flagged as uncertain above.

@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 56d05bc (no tests executed).

Review of PR #1490 @ 56d05bc — "mask identifiers in passkey function logs"

Scope of what I can assess: only the diff text. I did not run tests, and I cannot see unchanged lines outside the hunks (this matters for a couple of findings below, which I mark as unverified).

Overall: the change is mechanical and low-risk functionally — every masked value is consumed only by console.*, no control flow, return payloads, or DB predicates change. The concerns are about (a) the strength/semantics of the mask itself, (b) residual leak paths the PR leaves open, and (c) the shape of the new tests.


Confirmed defects / design problems (provable from the diff)

1. 4-character prefix masking collides at trivial scale and guts the security-alert logs

utils/logging.ts:25-28,36-40

return userId.length <= 4 ? '***' : `${safeFragment(userId, 4)}…`

For UUIDs that is 4 hex chars = 16 bits ⇒ ~50% chance of a collision once ~300 distinct ids appear in a log window, and near-certain ambiguity at thousands of users. The two call sites most damaged are the anti-cloning alerts:

  • services/auth.ts:298 — '❌ Signature counter regression for passkey:', maskPasskeyId(passkey.id)
  • services/auth.ts:331 — '❌ Signature counter was claimed concurrently for passkey:', maskPasskeyId(passkey.id)
  • utils/database.ts:414 — same for recordPasskeyUse

These are precisely the lines an operator needs to identify and revoke a specific credential. passkey.id and passkey.user_id are internal, server-generated UUIDs — not attacker-controlled, not user-identifying on their own. The PR's stated threat model in the header comment is "raw emails and user ids — attacker-controlled input — must not reach them" (utils/logging.ts:4-6), which does not apply to passkey.id. Suggest either (a) leave internal row UUIDs unmasked, or (b) if a pseudonym is required for compliance, use a truncated keyed HMAC (per-deployment secret) rather than a prefix — that gives you non-reversibility and uniqueness, which a prefix gives you neither of.

2. A prefix mask is partially reversible / low-entropy by construction

utils/logging.ts:11-13,21,27,39
A prefix leaks the highest-order bits of every identifier and is directly checkable against a candidate value (offline confirmation of "is user de30… you?"). For emails the mask retains the entire domain plus first initial (maskEmail returns v***@example.com), which for corporate or self-hosted domains is often re-identifying. This is pseudonymization, not masking; if the driver is a PII/GDPR requirement, reviewers should confirm that is acceptable, because the module comment claims the value is not "stored".

3. The most sensitive value in the same log record is still printed at 20 characters

utils/challenge.ts:27-32

console.log('Storing challenge:', {
  challenge: challenge.substring(0, 20) + '...',
  ...
  sessionId: options?.sessionId ? maskSessionId(options.sessionId) : undefined,

The session id (a client-supplied polling handle) is now cut to 4 chars, while the WebAuthn challenge — the ceremony's actual bearer value, and the lookup key used by .eq('challenge', challenge) in services/mobile.ts:63,152 — is still emitted as a 20-char prefix (~120 bits of a ~43-char base64url string) on the line right above. Pre-existing, but the PR touches this exact object and leaves the inconsistency in place; it undercuts the stated goal.

4. Empty-string session id silently logs undefined instead of a mask

utils/challenge.ts:30 uses a truthiness guard (options?.sessionId ? mask… : undefined) while maskSessionId already handles null/undefined → 'none' and '' → '***' (utils/logging.ts:37-38). The guard is redundant and produces a third, inconsistent rendering. Call maskSessionId(options?.sessionId) unconditionally.

5. Doc/implementation mismatch: masks are not ASCII

utils/logging.ts:10 states "Only bounded, single-line ASCII fragments may survive", but maskUserId/maskSessionId append U+2026 (…) at lines 27 and 39. Harmless in a UTF-8 sink, but the invariant as written is false and downstream log parsers/encoders sometimes aren't UTF-8 clean. Use ... or fix the comment.

6. No case normalization ⇒ same user yields different masks

utils/logging.ts:20-21. Victim@Example.com → V***@Example.com vs v***@example.com. Since the whole point is cross-line correlation, lowercase the domain (and initial) before masking.


Uncertain observations (need verification against unchanged code)

7. Raw error objects are still logged next to the masked values — likely residual leak

services/auth.ts:597

console.error('⚠️ Failed to persist session for session id:', maskSessionId(sessionId), tokenStoreError)

PostgREST/postgrest-js errors carry message/details/hint that routinely embed the offending row values (e.g. Key (session_id)=(…) already exists). If any of the masked identifiers can appear in a constraint violation, the raw value is re-emitted on the same line. Same pattern exists in utils/database.ts (console.error('Exception finding user:', error) at the context line after :516). I could not verify what those errors actually contain; worth a sanitizing wrapper around error logging rather than masking only the explicit arguments.

8. Coverage of the codebase is partial and unenforced

The diff masks services/{auth,management,mobile,registration}.ts and utils/{challenge,database}.ts, but does not touch utils/jwt.ts (which receives userResult.user.email at services/auth.ts:575), utils/error-handler.ts (withErrorHandler/withStatusErrorHandler wrap every service and presumably log the thrown error), routes/*, or the function entrypoint. I can't see those files, so I can't claim they leak — but there is no lint rule, no wrapper logger, and no type-level barrier added here, so nothing prevents the next console.log('…', email) from regressing this. A no-restricted-syntax/custom Deno lint rule, or routing all logging through a log() helper that masks by field name, would make this durable.

9. assertExists may not be imported in mobile.test.ts

tests/mobile.test.ts:584,585 use assertExists(...), but the hunk begins at line 7 and the diff never adds to the jsr:@std/assert import (unlike tests/auth.test.ts, whose import of assertExists is visible in its hunk). If line ~5 only imports assertEquals/assertStringIncludes, this file fails to type-check. Please confirm the existing import list.


Test gaps

10. The leak detectors can produce false negatives (Deno.inspect defaults)

tests/auth.test.ts:349-351 and tests/mobile.test.ts:558-560:

logged.push(args.map(arg => … : Deno.inspect(arg)).join(' '))

Deno.inspect defaults truncate: nested objects beyond depth render as [Object], long strings are abbreviated, and iterables are capped. A raw email nested inside a logged Supabase row/error (exactly the case in finding #7) would be elided, and the assertEquals(line.includes('victim@example.com'), false) loop would pass while the real console.log had leaked it. Use Deno.inspect(arg, { depth: Infinity, strAbbreviateSize: Infinity, iterableLimit: Infinity }) — or serialize with a full walker.

11. The strongest assertions are only applied to the two least sensitive paths

The all-console capture + raw-leak loop is applied to generateAuthChallenge and generateMobileRegistrationPage only. There is no test for:

  • checkAuthStatus (services/auth.ts:471,512,519,574,597) — the session-replay path, and the one that logs completedAuth.user_id and the persistence error.
  • completeAuthentication (services/auth.ts:298,331,338,374-380) — the cloned-authenticator alerts and the '🔍 Challenge data:' object.
  • services/mobile.ts:92,121,152 (authentication page).
  • services/management.ts:12 (listUserPasskeys) and :99 (updatePasskeyDisplayName).
  • utils/database.ts:258,414,422,448,470,494,509,513,536,553 directly.

12. Asymmetric console stubbing in the two smaller tests

tests/management.test.ts:135 and tests/registration.test.ts:89 stub only console.log; anything written to console.error/warn in those paths (e.g. deleteUserPasskey's fetch-error branch) escapes the assertion. Use the same all-channel capture helper as auth.test.ts — and factor that helper into tests/helpers/ rather than duplicating the 15-line stub/restore block four times.

13. Missing unit cases in tests/logging.test.ts

14. No regression guard against new unmasked call sites

Covered in #8; worth stating as a test gap too — the suite asserts today's call sites are masked but nothing fails when a new one is added.


Nits

  • maskPasskeyId (utils/logging.ts:31-33) is applied both to passkey row ids (services/auth.ts:298) and to credential ids (utils/database.ts:258,448). Two different namespaces rendered identically means pass… in one log line and pass… in another are not comparable — consider distinct prefixes (pk:/cred:) if you keep prefix masking.
  • maskEmail retains up to 100 attacker-controlled characters per line (utils/logging.ts:20) on unauthenticated endpoints; combined with findUserByEmail logging twice per miss (utils/database.ts:494,509), that's a small log-volume amplification vector. Probably acceptable; flagging for awareness.
  • Test assertions like logs.some(line => line.includes(maskPasskeyId('passkey-123'))) (tests/management.test.ts:147) compare the implementation against itself; they're only meaningful because the paired raw-value assertion exists. Prefer the literal 'pass…' as logging.test.ts correctly does.

Verdict

No functional regression found in the production code paths — masking is confined to log arguments. The blocking-ish items in my view are #1 (masking internal passkey/user UUIDs in the cloned-authenticator alerts makes those alerts unactionable, and the 4-char prefix collides at small scale) and #10 (the leak-detection tests can silently pass). #3, #7, and #8 determine whether this PR actually achieves its stated goal or just moves the leak one argument to the right.

@kshivang
kshivang merged commit 6b3e7e9 into dev Sep 25, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes requested Review changes must be addressed before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.