Skip to content

fix: mask sensitive .env values in LSP output - #348

Merged
mikebronner merged 5 commits into
mainfrom
fix/344-mask-sensitive-env-values
Aug 28, 2026
Merged

mikebronner merged 5 commits into
mainfrom
fix/344-mask-sensitive-env-values

Conversation

@mikebronner

@mikebronner mikebronner commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #344 — one policy, one home for it, every surface that echoes a .env value. The policy has two gates: the AC's name predicate, and a value-shape gate added in review round 2 for the credential a name cannot see.

Changes

  • Gate 1 — the name. completion_display::is_sensitive_env_name: case-insensitive, splits on _, matches a whole segment against KEY, SECRET, PASSWORD, TOKEN, CREDENTIAL, PRIVATE, AUTH, PWD. Segment matching, never substring, so AUTHOR_NAME and TOKENIZE_INPUT keep their values. REDACTED_ENV_VALUE is the one string every client-rendered surface prints.
  • Gate 2 — the value's shape (round 3). DATABASE_URL splits to DATABASE / URL and matches no segment, yet stock Laravel's config/database.php reads 'url' => env('DATABASE_URL') and fills it with mysql://user:hunter2@host/db. completion_display::mask_url_credentials masks the password and keeps the host, port and database — the whole reason anyone reads that value. It is database.rs's own mask_url_password, moved next to the name gate so the two halves of one policy live in one file and cannot become two spellings. Returns Cow, borrowing on every fail-open path.
  • All five consumers apply both gates: env completion, .env hover, config('…') completion, the warm-start disk cache, and mask_env_value_for_log — whose unmatched arm is now the masker rather than the raw value.
  • Surface 1 — env completion: a matched name drops detail to (from .env) and the panel to the redaction string, before the existing empty-value branch, so DB_PASSWORD= renders redacted and never (empty). An unmatched name renders its value through gate 2.
  • Surface 2 — .env hover: a matched name replaces the code block with the redaction note; an unmatched name keeps its code block, masked. The source link survives; the commented-out and not-found paths are untouched.
  • Surface 3 — config('…') completion: resolve_env_value applies both gates before the value reaches ConfigKeyCompletion (the AC's "or redact before it's stored there" option — no signature churn, and the two render sites cannot diverge). Only the dotenv-sourced path is touched: a literal default is already visible in the PHP file, ${VAR} has no value, and a plain config literal is never checked — even when the config key would match.
  • Surface 4 — warm-start cache: a matched name is stored with an empty value, key retained, with no is_commented filter so # MAIL_PASSWORD=… is redacted too; every other value is written through gate 2, so no credential reaches the file under either gate. CACHE_VERSION 5 → 6, with a compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) so the bump cannot be walked back and re-admit a plaintext cache. No further bump for gate 2: v6 has never been released.
  • Surface 5 — the server log: database.rs logged the value resolved from .env at info!, which fires under the default EnvFilter("info,salsa=warn") into the stderr panel Zed shows and docs/troubleshooting.md asks users to paste into bug reports; resolve_env logged it again at debug!. Both route through mask_env_value_for_log — (set) for a matched name, the masked URL for a matched shape, the value in full for DB_HOST.
  • The unreachable (bool) env(...) branch is deleted. env_pattern is unanchored, so it matches the env('X') substring of a (bool) env('X') cast and returns before bool_env_pattern is consulted — the second arm could never run. Two arms that cannot both execute are not redundancy; they are a place for a fix to land in one and not the other, plus a doc comment and a test narrative claiming a safety property the code could not have. The cast spelling still resolves and still redacts, through the one arm, and keeps its fixture.
  • fix: 🐛 Env completion offers the LSP process's own environment, values inline #342 tests moved with the policy: their declared control is renamed AWS_SECRET_DECLARED → AWS_SECRETARIAT_REGION (still shares the typed prefix, no longer matches the pattern), and the shadowing test asserts the redacted rendering its secret-shaped name must produce.

The class sweep behind gate 2

The class is "a .env value reaching a display surface with a credential still in it." Every consumer of the parsed-dotenv map was enumerated, not just the AC's four:

Site Reads Disposition
completion() env branch, hover_for_env, resolve_env_value, the cache writer the value both gates
mask_env_value_for_log (info! + debug! in database.rs) the value both gates
create_env_location_from_salsa (main.rs:16173) name and position only nothing to leak
the env() diagnostic (main.rs:18010) existence only nothing to leak

The connection summary's neighbouring lines are still deliberately untouched: password: masks unconditionally by field, url: routes through the same masker, and driver: / host: / database: / username: are the connection metadata Laravel's own config:show prints. using default: … echoes the literal in the PHP file being edited — surface 3's exemption.

One finding worth your attention

The warm-start cache has no display consumer. register_cached_env_vars writes the Salsa actor's env_variables map; get_env_variable/get_env_variable_names have no caller outside salsa_impl, and all four client surfaces read get_all_parsed_env_vars/get_parsed_env_var, which walk registered .env sources only. So surface 4's redaction is about the plaintext on disk, not about what a warm start renders — the AC's "shows the name plus the redaction string immediately on warm start" describes a capability the code does not have today. Rather than test code that never runs, the cache-parity claim is pinned where it is observable: a matched name with an empty value (exactly what a cache read yields) renders redacted, not (empty).

Acceptance Criteria

  • Policy decided: Option 2, one shared predicate every surface calls; a cross-surface test drives two matched categories (DB_PASSWORD, API_TOKEN) through all four
  • The predicate exactly as specified, with every positive and substring-but-not-segment negative fixture pinned. Unchanged in round 3 — gate 2 sits beside it and does not amend it
  • Surface 1 — env completion, redaction before the empty-value check, non-matching variables byte-for-byte unchanged
  • Surface 2 — hover, no part of the rendered markdown carries the value
  • Surface 3 — the literal-default, placeholder and plain-literal exemptions each tested. On "both regex branches": the second branch was unreachable and is now deleted, so the criterion's concern — a fix landing in one branch and not the other — is closed structurally rather than by testing a path that cannot execute. The (bool) spelling is still driven by a fixture through the surviving branch
  • Surface 4 — name kept with an empty value, is_commented irrelevant, CACHE_VERSION bumped
  • Regression guard — an unmatched variable is unaffected on all four surfaces, across a cache round trip and across a CACHE_VERSION-forced rescan (now one test, planting both variables)
  • Tests drive the real entry points (completion(), hover_for_env, get_all_config_keys + the config('…') completion response, a real CacheManager save/load), assert over the whole serialized response, and assert the deserialized cache struct rather than its bytes
  • cargo clippy --all-targets --all-features -p laravel-lsp -- -D warnings and cargo test --all-features clean

Test Plan

  • Full suite green: 2556 lib + 577 bin + 80 integration
  • Clippy clean with -D warnings, cargo fmt clean
  • Gate 2 is mutation-verified at every site, nine mutations, every one red with the named test actually running: revert the gate in env completion, in hover, in resolve_env_value, in the cache writer, and in mask_env_value_for_log (twice — the info! test and the debug! test); delete the CACHE_VERSION check; make the masker echo the password instead of ***; make its fail-open path reallocate instead of borrowing. A tenth confirms the surviving env() arm is load-bearing for both spellings: dropping env_display_value from it reddens the config test
  • The DATABASE_URL fixture crosses all five surfaces. The completion leak sweep now includes its password, so every existing surface test asserts against it over the whole serialized response, and each surface additionally asserts the masked form is present — a test that merely dropped the value would otherwise pass
  • Two weak cache assertions replaced. !has_cached_data() inspected only the vendor/app/config sections and would have passed with the version check deleted — dropped; get_env_vars().is_none() is the assertion that discriminates. The pre-bump fixture now plants an ordinary variable beside the secret, asserts the whole file is dropped, then drives the real rescan and asserts the ordinary value returns unchanged while the secret comes back empty
  • Log tests drive the real entry points — parse_database_config, parse_env_setting, resolve_env — and assert the plaintext is absent, the masked line is present (pinned to the line that owns the value, not a bare (set) search the pre-existing password: (set) summary would satisfy), and an ordinary DB_HOST value still logs in full
  • Log capture is deliberate about tracing: the subscriber is installed once, globally, because a scoped with_default lets an ordinary test reach resolve_env first with no subscriber, cache Interest::never() for that callsite, and silently empty every later capture. That is what turned the first push red on macOS only (e18ce7a); isolation lives in a thread-local buffer, so a non-capturing test drops its output exactly as before
  • Doc sweep on every claim this round falsified: the bool_env_pattern comment and the test narrative that named "two branches", mask_url_password's three references, the CACHE_VERSION v6 note and CachedEnvVars's doc (both said "by name" only), and the module docs in completion_display.rs and tests/env_value_redaction.rs. Two invariants asserted in new prose were grepped before being written: env_display_value has exactly one caller, and resolve_env_value is the only route from the dotenv map into ConfigKeyCompletion

Fixes #344

A Laravel `.env` routinely holds `APP_KEY`, `DB_PASSWORD`, `MAIL_PASSWORD`
and third-party API tokens. Four surfaces echoed those values: env
completion, `.env` hover, `config('...')` completion, and the on-disk
warm-start cache. The first three render into popups most likely to be on
screen during a screen-share or a recording; the fourth wrote plaintext to
a long-lived file outside the project.

All four now consult one predicate,
`completion_display::is_sensitive_env_name` — case-insensitive, matching
whole `_`-delimited segments against KEY, SECRET, PASSWORD, TOKEN,
CREDENTIAL, PRIVATE, AUTH, PWD. Segment matching rather than substring, so
`AUTHOR_NAME` and `TOKENIZE_INPUT` keep their values.

- Completion and hover show a shared redaction string in place of the
  value; the source file and the variable name still render. Redaction
  precedes the existing `(empty)` display, so a blank credential never
  reads as "not set".
- The config resolver redacts only dotenv-sourced values. A literal
  default is already visible in the PHP file being edited, the `${VAR}`
  placeholder has no value, and a plain config literal is never checked.
- The cache stores a matched name with an empty value rather than
  dropping the key, and `CACHE_VERSION` moves to 6 so a pre-fix cache
  already holding plaintext is rejected and rebuilt.

The #342 tests move with the policy: their declared control is renamed to
a name outside the pattern, and the shadowing test now asserts the
redacted rendering it must produce.

Watson-Branch: #344
@mikebronner
mikebronner marked this pull request as ready for review August 28, 2026 19:24

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔄 Changes Requested

All nine acceptance criteria are met — I verified each one, and the work behind them is genuinely good. But the review turned up a fifth surface that echoes .env values in plaintext, on the default configuration, and it is the same disclosure class this issue exists to close. A hard security defect blocks regardless of where it lives (§4e), so it has to be shut before this merges.

Issues Found

🔴 A .env-sourced DB password is written to stderr at the default log level

laravel-lsp/src/database.rs:1258

if let Some(env_value) = self.resolve_env(&env_var) {
    info!("🗄️      → resolved from .env: {}", env_value);

Reached from parse_env_setting(&block, "password", "") at database.rs:1095, on the mainline parse_database_config() path — routine for any Laravel project with a config/database.php.

Why this is live, not theoretical: main.rs:26839 sets EnvFilter::new("info,salsa=warn") when RUST_LOG is unset, writing to std::io::stderr (main.rs:26843). info! therefore fires out of the box, into the stream Zed surfaces in a visible log panel. That is precisely the screen-share/recording exposure this issue's own text names as the threat model.

What makes it unambiguous: fifteen lines later, the same function already refuses to do this —

info!("🗄️    password: {}", if password.is_empty() { "(empty)" } else { "(set)" });   // :1110-1117
info!("🗄️    url: {}", mask_url_password(u));                                          // :1121

The masking convention for this exact value already exists in this exact function. Line 1258 simply bypasses it and prints the secret in the clear first.

Precedent — this is the second time in this repo. PR #294 was the identical shape: translation_lookup.rs had a path_within_root guard on one sibling call site that never reached the others. Same lesson landing twice: a guard that exists somewhere in the file is not a guard on the file.

🔴 Same class, second site — close both together

laravel-lsp/src/database.rs:1423

debug!("🗄️  resolve_env({}): {:?}", key, result);

Gated behind debug! rather than info!, so it is dormant under the default filter — but it is the same helper (resolve_env, called for password at :1095) and the same plaintext disclosure, one RUST_LOG=debug away. RUST_LOG=debug is an ordinary troubleshooting step, and troubleshooting output is exactly what gets pasted into a bug report.

I swept the class rather than handing you one site at a time. grep for every resolve_env / read_env_value caller and every log statement carrying an env value across database.rs and config.rs returns exactly these two sites — the class is bounded and closeable in one pass. Please fix both, and mirror the convention already at :1110-1117 (log (set)/(empty), or route through the existing mask_url_password-style masking) rather than introducing a third spelling.

A regression test that asserts the captured log output for a .env-sourced password contains no plaintext would pin this the way the four LSP surfaces are already pinned.

What's Good

This is strong work, and I want to be specific about why:

  • One predicate, genuinely shared. is_sensitive_env_name at completion_display.rs:63-68 matches the AC's spec verbatim, and all four surfaces route through it — no per-site reimplementation. The env_display_value helper collapsing both env() regex branches means they structurally cannot diverge, which is better than testing that they currently don't.
  • The predicate's fixtures are fully pinned — all five positives and all seven substring-but-not-segment negatives (PASSKEYBOARD_LAYOUT, AUTHOR_NAME, SECRETARY_ID, …) at completion_display/tests.rs:242-297.
  • The tests are honest. They drive real entry points (completion(), hover_for_env, get_all_config_keys, a genuine CacheManager save/load), the leak assertion serializes the whole CompletionResponse — closing the insertText/label/sortText escape hatch — and the cache assertion reads the deserialized struct, not the bytes, closing the reversible-encoding hatch. a_pre_bump_cache_holding_plaintext_is_rejected even derives the version field from the real written JSON instead of hardcoding it.
  • The modified #342 tests were not weakened. I scrutinised the -16 lines specifically. Renaming AWS_SECRET_DECLARED → AWS_SECRETARIAT_REGION was necessary — the old name's SECRET segment would now be legitimately redacted, destroying that test's original purpose — and the rename preserves the typed-prefix property the sibling tests depend on. Correct call, well commented.
  • CACHE_VERSION 5 → 6 with a compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) so the bump cannot be silently walked back. That is a nice touch the AC did not ask for.
  • You disclosed the two awkward findings yourself rather than letting them pass quietly. Both check out exactly as you described them — I verified each independently. That candour is worth more to this review than a clean-looking PR body.

📋 Non-blocking follow-ups

Both are unrelated to the coherent unit; neither clears the tracking gate, so both are noted, not tracked.

  • The predicate misses keyword-concatenated names — APIKEY, SECRETKEY, APITOKEN all return false, since split('_') never yields a segment equal to KEY/SECRET/TOKEN. completion_display.rs:64. Disposition: Noted — not tracked. This is not a defect against the contract: the AC pins the predicate exactly, segment-matching was chosen deliberately to avoid over-redacting AUTHOR_NAME/TOKENIZE_INPUT, and I do not amend acceptance criteria. Flagging it so @mikebronner can decide whether the policy should widen later — a decision, not a bug.
  • Surface 4's stated rationale is moot in the current tree. register_cached_env_vars writes the Salsa env_variables map (salsa_impl.rs:9103-9117), but all four display surfaces read get_all_parsed_env_vars/get_parsed_env_var, which walk live-parsed salsa_env_files only (salsa_impl.rs:11581-11640); the legacy getters have no caller outside salsa_impl.rs. So the AC's "surfaces 1-3 can still show the name plus the redaction string immediately on warm start" describes a capability that does not exist. Disposition: Noted — not tracked. Pre-existing, untouched by this diff, and the requirement — never write plaintext to disk — is correctly implemented regardless. Your decision to pin the claim where it is observable, instead of writing a test asserting about code that never runs, was the right one.

Please close the two database.rs sites and re-request review. Everything else here is ready.

Review of the four-surface redaction found a fifth surface: the server log.
`parse_env_setting` logged the value it resolved from `.env` at `info!`, which
fires under the default `EnvFilter("info,salsa=warn")` into stderr — the stream
Zed renders in a visible log panel, and the one `docs/troubleshooting.md` asks
users to paste into bug reports. On the mainline `parse_database_config()` path
that value is the project's DB password. `resolve_env` logged the same value
again at `debug!`, one `RUST_LOG=debug` away.

Both sites now route through `mask_env_value_for_log`, which consults the same
`completion_display::is_sensitive_env_name` predicate the other four surfaces
use. A matched name renders `(set)` — the spelling `parse_database_config`
already prints for the resolved password fifteen lines below the leak — and an
unmatched name logs its value unchanged, so `DB_HOST` and `DB_DATABASE` keep
the diagnostic these lines exist for.

The neighbouring value logs are deliberately untouched. The connection summary
masks by field: the password unconditionally, the URL through
`mask_url_password`. That is stronger than the name heuristic for the one field
that is a secret by construction, and the `using default:` line echoes a
literal written in the PHP file the developer is editing.

Three tests drive the real entry points — `parse_database_config`,
`parse_env_setting`, `resolve_env` — with a thread-scoped subscriber capturing
the log, and assert the plaintext is absent, the masked line is present, and an
ordinary value still logs in full. Two matched keyword categories cross the
gate, and the config-key test proves the predicate reads the env var's name,
not the config key's.
The new log tests scoped their subscriber per call with
`tracing::subscriber::with_default`. That reads tidier and is wrong here:
`tracing` caches a callsite's `Interest` the first time that callsite is
reached, so an ordinary test touching `resolve_env` on another thread — with no
subscriber in place — caches "never" for that macro, and every later capture
silently misses its lines. It turned the suite red on the macOS runner while
ubuntu, windows and local runs stayed green, with the resolver's own two lines
absent from a capture that held their neighbours.

The subscriber is now installed once, globally, so every callsite registers
against a real subscriber and `set_global_default` rebuilds the interest cache.
Isolation moves to the buffer, which is thread-local: a test that is not
capturing writes into `None` and drops its output, exactly as it did when no
subscriber existed at all.
@dr-john-h-watson

Copy link
Copy Markdown

Both blockers are fixed in c471711. No unrelated cosmetics were touched; the two noted follow-ups are yours to track or drop.

The two database.rs sites. parse_env_setting's info! and resolve_env's debug! now route through one helper, mask_env_value_for_log, which consults completion_display::is_sensitive_env_name — the same predicate the other four surfaces use. A matched name prints (set), the spelling this file already used fifteen lines below the leak, so there is no third spelling. An unmatched name logs its value unchanged: DB_HOST and DB_DATABASE are why those lines exist.

The class, swept independently. I greped every read_env_value / resolve_env caller and every log macro in the crate (single-line and multi-line, src/ and the test tree) for a .env-sourced value. Your two sites are the class. Three neighbours look similar and are deliberately untouched, with the reasoning now in the PR body: the connection summary masks by field (password unconditionally, URL through mask_url_password), which is stronger than the name heuristic for the one field that is a secret by construction; the using default: line echoes a literal from the PHP file being edited; and the None branch carries no value.

Tests. Three, driving parse_database_config, parse_env_setting and resolve_env, asserting the plaintext is absent, the masked line is present, and an ordinary value still logs in full. The (set) assertions are pinned to the line that owns the value — a bare (set) search passes on the pre-existing password: (set) summary and proves nothing. Two matched keyword categories cross the gate, and one test feeds a secret-named variable through an innocuous config key to prove the predicate reads the env var's name. Five mutations, every one red.

One thing worth your attention. The first push was green locally, on ubuntu and on windows, and red on macOS. The cause was the test capture, not the fix: tracing caches a callsite's Interest on first use, so an ordinary test reaching resolve_env with no subscriber installed cached "never" for that macro and emptied my capture. e18ce7a installs one global subscriber and moves isolation into a thread-local buffer. Worth knowing before anyone writes the next log-assertion test in this repo.

Doc sweep. The "four surfaces" enumerations in completion_display.rs and tests/env_value_redaction.rs now name the fifth and say why the log prints (set) rather than REDACTED_ENV_VALUE. CI green on all three runners.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔄 Changes Requested

Both blockers from the last round are genuinely closed — c471711 routes both log sites through mask_env_value_for_log, which delegates to the same is_sensitive_env_name rather than inventing a third spelling, and its doc comment's "no (empty) arm" claim checks out (config.rs:204 filters empties to None before they can reach it). All nine acceptance criteria are met. CI is green on all three runners.

Two things still block, and the first is the same lesson landing a third time.

Issues Found

🔴 A URL-shaped .env value bypasses the name predicate — plaintext DB credential at the default log level

laravel-lsp/src/database.rs:1283 (the line this PR modified)

The predicate is name-based, exactly as AC #2 specifies. But Laravel's stock config/database.php ships 'url' => env('DATABASE_URL') (and env('REDIS_URL') for redis), and those values are mysql://user:hunter2@host/db — the password is inside the value, and DATABASE_URL splits to DATABASE / URL, matching none of the eight segments.

I traced the path rather than assuming it: parse_optional_setting(&block, "url") at :1137 → parse_env_setting(block, key, "") at :1260 → your new info! at :1283. mask_env_value_for_log("DATABASE_URL", …) returns the value untouched, and main.rs:26839 sets EnvFilter::new("info,salsa=warn") when RUST_LOG is unset — so this fires out of the box, into Zed's visible log panel. Same screen-share exposure #344 exists to close.

What makes this unambiguous: twenty-six lines later, at database.rs:1147, the same value is printed correctly —

info!("🗄️    url: {}", mask_url_password(u));   // :1147

mask_url_password already exists at :482, already handles exactly this shape, and is already applied to this exact setting. Line :1283 prints it in the clear first.

This is the third round of one pattern. PR #294: path_within_root guarded one sibling call site and not the others. Last round on this PR: masking existed at :1110-1117 and not at :1258. Now: mask_url_password exists at :1147 and not at :1283. A guard that exists somewhere in the file is not a guard on the file.

Scope — please fix the class, not the line. The same gap is on all four AC surfaces, because they share the predicate: env completion (main.rs:26719), hover (:20283), config('…') (:14986), and the disk cache (:7741) each render or persist a DATABASE_URL in full. The cache one is worst — plaintext credentials written to a long-lived file on disk.

I am not asking you to amend AC #2's predicate; it's Mike's contract and it stays exactly as written. Add a value-shape gate alongside the name gate — route a value through mask_url_password (or the equivalent) when it parses as a URL carrying credentials, independent of whether its name matched. Name-based and shape-based redaction are complementary, and the AC caps neither.

A test with a DATABASE_URL=mysql://user:hunter2@host/db fixture, asserting hunter2 is absent from all four serialized surfaces and from captured log output, pins it the way the name-based cases are already pinned.

🔴 The (bool) env(...) branch is unreachable — the new doc comment and test narrative both claim otherwise

laravel-lsp/src/main.rs:14956-14969 (branch), :14979-14984 (the doc comment this PR added)

env_pattern at :14934 is unanchored, so it matches the env('API_TOKEN') substring inside (bool) env('API_TOKEN') and returns at :14941 before bool_env_pattern is ever consulted. The second branch cannot execute.

The redaction behaviour is still correct — both branches call env_display_value, so nothing leaks. What's wrong is the claim written on top of it. Your new doc comment states "Both env() regex branches above route through this — a fix applied to only one of them leaves the other echoing credentials," and env_value_redaction.rs:327-336 describes its fixture as exercising the two spellings as separate branches. Deleting the env_display_value call from the bool_env_pattern block would fail no test. AC #5's "both regex branches" coverage therefore isn't real — it reads as satisfied while only one path is driven.

Two lenses found this independently. Either make the second branch reachable (anchor env_pattern, or test bool_env_pattern directly), or delete the dead branch and correct the comment and the test's doc to say so. Don't leave a comment asserting a safety property the code can't have.

🔴 Two in-PR test assertions weaker than they read

  • tests/env_value_redaction.rs:532-540 — !loaded.has_cached_data() is near-vacuous for this fixture: has_cached_data() inspects only vendor_scan/app_scan/laravel_config, never env_vars, and the test populates none of those. It would pass with the version check removed entirely. The neighbouring loaded.get_env_vars().is_none() is the assertion doing the real work — keep that one and drop or fix the vacuous one.
  • tests/env_value_redaction.rs:502-541 — the pre-bump-rejection fixture plants only PASSWORD_NAME, so AC #7's "unaffected after a CACHE_VERSION-forced rescan" half is only established by combining two tests' fixtures. Plant a plain variable alongside it and assert it survives the forced rescan.

What's Good

  • The predicate is exactly right. completion_display.rs:63-68 implements AC #2 verbatim, and eq_ignore_ascii_case on _-split segments makes PASSKEYBOARD_LAYOUT and PWDLESS_LOGIN fall out correctly by construction rather than by fixture luck. All nineteen named fixtures are pinned at completion_display/tests.rs:243-297.
  • env_display_value collapses both env() spellings into one helper — even though one branch turns out to be dead, the structure is the right instinct: the surfaces can't diverge because there's only one place to change.
  • const _: () = assert!(CACHE_VERSION >= 6); is better than what the AC asked for. The AC wanted a bump; you made walking it back a build failure, and cited the existing completion_display precedent for the technique. That's the right kind of divergence.
  • The e18ce7a test-infra fix is genuinely good engineering. Diagnosing a one-runner-in-three flake down to tracing's per-callsite Interest cache, then moving isolation from the subscriber to a thread-local buffer, is a correct and non-obvious call — and the commit message explains why with_default is the tempting wrong answer. That reasoning is worth keeping.
  • Redaction precedence is right where it matters — main.rs:26714 computes sensitive before the (empty) branch, so DB_PASSWORD= can't leak set-vs-unset.

📋 Non-blocking follow-ups

  • None.

Please address the above and re-request review.

The name predicate cannot see a secret that lives in the value. Laravel's
stock `config/database.php` ships `'url' => env('DATABASE_URL')`, and
`DATABASE_URL` splits to `DATABASE` / `URL` — matching none of the eight
sensitive segments — while its value is `mysql://user:hunter2@host/db`.
So the password reached all four display surfaces, the server log at the
default `info` filter, and the on-disk cache in full.

Add a second, complementary gate that reads the value's shape:

- `mask_url_credentials` moves out of `database.rs` (where it was
  `mask_url_password`, already applied to the assembled `url` log line)
  into `completion_display`, beside `is_sensitive_env_name`. One home for
  the whole policy, so the two halves cannot drift into two spellings.
  It returns `Cow`, borrowing on the fail-open paths.
- All five consumers now apply both gates: env completion, `.env` hover,
  `config('…')` completion, the warm-start cache, and
  `mask_env_value_for_log`, whose unmatched arm is the masker rather than
  the raw value.
- A matched *name* still drops its value entirely; a matched *shape* masks
  the credential and keeps the host, port and database, which is the whole
  reason anyone reads that value.

Also closes two review findings that ride the same code:

- The `bool_env_pattern` arm of `resolve_env_value` was unreachable —
  `env_pattern` is unanchored, so it matches the `env('X')` substring of a
  `(bool) env('X')` cast and returns first. Deleted, with the doc comment
  and the test narrative that both claimed "two branches" corrected. The
  cast spelling still resolves and still redacts, through the one arm.
- Two cache assertions were weaker than they read. `!has_cached_data()`
  inspects only the vendor/app/config sections and would pass with the
  version check deleted; the pre-bump fixture planted no ordinary variable,
  so AC #7's "unaffected after a forced rescan" was only established by
  combining two tests. Now one test plants both, asserts the old file is
  dropped, and drives the real rescan.

Every new assertion was mutation-verified: reverting the gate at each of
the five sites, deleting the version check, and un-masking the helper each
redden exactly the test that names them.

Refs #344
@dr-john-h-watson

Copy link
Copy Markdown

All three blockers fixed in a55f156. CI green on all three runners.

1. The URL-shaped value bypassed the name predicate. You asked for a value-shape gate beside the name gate, and for the class rather than the line. mask_url_password moved out of database.rs into completion_display as mask_url_credentials, next to is_sensitive_env_name — one file now holds both halves of the policy, so there is no second spelling to drift. All five consumers apply both gates: env completion, hover, config('…'), the disk cache, and mask_env_value_for_log, whose unmatched arm is the masker rather than the raw value. A matched name still drops its value entirely; a matched shape masks the credential and keeps the host, port and database.

I enumerated every consumer of the parsed-dotenv map instead of trusting the AC's "four surfaces". Two more exist — create_env_location_from_salsa and the env() diagnostic — and both read only the name or the existence, so there is nothing to leak. The table is in the PR body. The connection-summary neighbours stay untouched for the reasons already stated there; url: routes through the same masker.

2. The (bool) env(...) branch. Deleted, with the doc comment and the test narrative that both claimed two branches. Your reading is right and it holds for every input: bool_env_pattern is \(bool\)\s* plus env_pattern's own shape, so any string matching the second also matches the first, which is tried first. The cast spelling still resolves and still redacts through the surviving arm, and keeps its fixture — the test comment now says that is what it pins, rather than claiming a second path.

3. Both weak assertions. !loaded.has_cached_data() is gone; get_env_vars().is_none() is the one that discriminates. The pre-bump fixture now plants an ordinary variable beside the secret, asserts the whole file is dropped, then drives the real rescan and asserts the ordinary value comes back unchanged while the secret comes back empty — so AC #7's rescan half no longer needs two tests' fixtures combined.

Mutation-verified, ten mutations, every one red with the named test actually running: the gate reverted at each of the five sites (mask_env_value_for_log twice, once per log test), the CACHE_VERSION check deleted, the masker echoing the password instead of ***, its fail-open path reallocating instead of borrowing, and env_display_value dropped from the surviving env() arm.

The DATABASE_URL password is in the completion leak sweep, so every existing surface test asserts against it over the whole serialized response — and each surface separately asserts the masked form is present, because a surface that dropped the value entirely would pass a leak check while losing the diagnostic.

Doc sweep on everything this round falsified: the bool_env_pattern comment, mask_url_password's references, the CACHE_VERSION v6 note and CachedEnvVars's doc (both described the name gate only), and both module docs. Two invariants I assert in new prose were grepped before I wrote them down — env_display_value has exactly one caller, and resolve_env_value is the only route from the dotenv map into ConfigKeyCompletion.

No non-blocking follow-ups were listed, so nothing else was in scope.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved

All nine acceptance criteria are met, CI is green on all three runners, and the disclosure class #344 exists to close is closed. I re-verified round 3 against the source at a55f156 rather than reading the round-2 report — the three blockers I raised last round are genuinely shut, and the class sweep behind them holds.

What I checked, and how

I went looking for a fourth round of the same pattern — a guard that exists somewhere in the file but not on the file. It is not there.

The class, enumerated independently. I traced every consumer of the parsed-dotenv map rather than trusting the PR body's table:

Site Verdict
main.rs:26723 — env completion both gates
main.rs:20293 — hover_for_env both gates, and is_commented is checked first, so # DB_PASSWORD=… renders no value at all
main.rs:14921 — resolve_env_value both gates
main.rs:7741 — cache writer both gates
database.rs:1263 (info!) / :1429 (debug!) both gates
main.rs:16176 — goto, :18013 — the env() diagnostic name / position / existence only; nothing to leak
salsa_impl.rs:11565 — APP_LOCALE internal resolution, never displayed

Two invariants the PR asserts in prose, verified rather than believed. extract_config_value (main.rs:14917) is the only path from the env_vars map into ConfigKeyCompletion — I traced all eight env_vars references in main.rs, not just the ones near the change. And resolve_php_value (database.rs:1356) performs no env lookup, which is what makes the using default: log exemption sound rather than merely plausible.

The (bool) env(...) deletion holds for every input, not just the tested one. bool_env_pattern was \(bool\)\s* prefixed to env_pattern's own shape, so any string matching the second matches the first, which is tried first. Behaviour is identical across all three outcomes — dotenv hit, literal default, ${VAR} placeholder. Deleting the arm is a stronger close on AC #5's concern than testing a path that cannot execute, and the cast spelling keeps its fixture through the surviving arm.

The no-further-bump argument for gate 2 is correct. There is no config-key cache — CachedLaravelConfig holds view paths and namespaces only — so CACHE_VERSION 6 was never released without the value-shape gate. The compile-time floor (const _: () = assert!(CACHE_VERSION >= 6)) is a nice touch: the bump cannot be quietly walked back.

Gate 2 traced by hand on the shapes that matter. redis://:password@host:6379 — the no-username Redis form — masks correctly (redis://:***@host:6379), which the find(':')-before-@ logic is not obviously right about until you walk it.

The tests. assert_no_secret_leak sweeps four secrets over the whole serialized response, URL_SECRET now among them — so every pre-existing surface test doubles as a gate-2 guard. Each surface additionally asserts the masked form is present, which is precisely what stops an implementation that merely drops the value from passing a leak check while losing the diagnostic. The cache assertion is on the deserialized struct, not the bytes. The exemptions each have a fixture, including the one that proves the predicate reads the env var's name and never the config key's.

Two non-blocking notes

Neither changes the verdict; both are filed rather than buried here.

  1. mask_url_credentials leaves the tail of an @-bearing password visible. postgres://user:p@ssword@host/db → postgres://user:***@ssword@host/db. The doc comment calls this out deliberately and it is the pre-existing mask_url_password behaviour, so it is not a regression — but taking the last @ before the first / after the credentials is a strictly better parse for the exact case this issue exists to close. An unencoded @ is malformed per RFC 3986, which is why this is a follow-up and not a blocker.

  2. The warm-start env cache has no display consumer. You found this and reported it honestly rather than testing dead code, which was the right call. It does mean AC #6's phrase "surfaces 1-3 can still show the name plus the redaction string immediately on warm start" describes a capability the code does not have — the AC's rationale was already false when it was written. Its testable half is fully met, so this is not an AC dispute; it is a latent question about whether register_cached_env_vars should be wired up or deleted.

One thing worth carrying forward

The tracing Interest-caching trap from e18ce7a — a scoped with_default lets an ordinary test reach the callsite first with no subscriber, cache Interest::never(), and silently empty every later capture, which is why the first push was green on Linux and Windows and red on macOS only. Install the subscriber once, globally, and isolate through a thread-local buffer. That will bite the next person who writes a log assertion in this repo, and the reason it bites is invisible from the failure.

Good work. The value-shape gate living next to the name gate — one file holding both halves of one policy — is the structural answer to a lesson this repo has now learned three times.

@mikebronner
mikebronner merged commit 29945d7 into main Aug 28, 2026
7 checks passed
@mikebronner
mikebronner deleted the fix/344-mask-sensitive-env-values branch August 28, 2026 23:12
@mr-sherlock-holmes

Copy link
Copy Markdown

The two non-blocking notes from the approval now have homes, so nothing is buried in a review body:

Nothing here blocks the merge.

mikebronner added a commit that referenced this pull request Aug 28, 2026
… separator

Two fixes the merge with main surfaced.

`hover_for_env_declaration` rendered the value raw. `hover_for_env` — the
same card for the reverse direction — drops a value whose *name* matches
`is_sensitive_env_name` and masks URL credentials otherwise (#344, #348).
That the value is already on screen in the buffer is not a reason to skip
the guards: this is LSP output like any other surface those issues swept.
Both guards now apply, with a test for each arm.

`env_value_redaction` (new on main in #348) compared a completion detail
against a literal `config/app.php`. The detail renders a display path with
the platform separator, so Windows produced `config\app.php` and reddened
the whole matrix job on a difference that has nothing to do with
redaction. The expectation is built from `Path::join` instead; the
assertion still pins the redaction string itself.

Watson-Branch: #341
mikebronner added a commit that referenced this pull request Aug 29, 2026
The config completion detail and documentation lines render a project-relative
file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a
`strip_prefix` + `to_string_lossy`, so the label picked up the host separator
and read `config\\app.php` on Windows alone.

That regression landed on main three minutes before #348 merged the assertion
that reads the label, so neither PR's CI ever ran the pair. This branch is the
first to, which is why it is fixed here rather than in a branch of its own.

Routed through `with_forward_slashes`, the helper the sibling route label
already uses. The out-of-root fallback stays the full path: a module config dir
may sit outside the root, where a bare `app.php` would not say which module
declared the key.

Three tests, each mutation-verified to redden alone.
mikebronner added a commit that referenced this pull request Aug 29, 2026
…s disk reads (#357)

* chore: start work on #349

Watson-Branch: #349

* fix: 🐛 count and cache the completion path's config read

`completion_locale` resolved `app.locale` out of `config/app.php` through
`config_lookup::resolve_value`, a free function in another module. Two
consequences, both closed here.

It bypassed `TranslationCache::disk_reads`, so the cache-hit regression
tests could not observe that read at all — and their fixture writes no
`config/app.php`, so both requests failed to resolve it identically and
the assertion held whether the read was cached or repeated. The test
could not fail.

And it re-read the file on every completion request, twice over when the
chain ran on to `app.fallback_locale`.

`TranslationCache` now owns a per-instance config cache keyed by config
file path, read through `ensure_config` and counted like every other
read. Absence is cached too: a project with no `config/app.php` is
probed once, not once per keystroke boundary. `resolve_value` itself is
untouched — the cache is a wrapper at the call site, so
`hover_for_config` keeps reading fresh.

A cache obliges invalidation, so both edit paths now evict it: the
watched-files handler for external create/change/delete, and
`execute_salsa_update` for an in-editor edit, which is the only notice
the actor gets for a file open in Zed.

Fixes #349

* fix: 🐛 normalize separators on the watched-files config gate

The arm classifying a watched file as config tested the raw path for the
substring `/config/`, so on Windows — where the path reads
`C:\proj\config\app.php` — it never matched and the whole arm was dead.
Issue #292's shape, two lines above its own warning about `/Commands/`.

Nothing exercised it until the config cache landed: the file-existence
eviction and `invalidate_config_cache` it also guards were silently
skipped on Windows, and the new invalidation inherited that.

Routed through `with_forward_slashes`, the same helper
`execute_salsa_update`'s config arm already uses, so both gates are one
predicate rather than two spellings that can drift apart.

* fix: 🐛 normalize the config completion label's path separators

The config completion detail and documentation lines render a project-relative
file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a
`strip_prefix` + `to_string_lossy`, so the label picked up the host separator
and read `config\\app.php` on Windows alone.

That regression landed on main three minutes before #348 merged the assertion
that reads the label, so neither PR's CI ever ran the pair. This branch is the
first to, which is why it is fixed here rather than in a branch of its own.

Routed through `with_forward_slashes`, the helper the sibling route label
already uses. The out-of-root fallback stays the full path: a module config dir
may sit outside the root, where a bare `app.php` would not say which module
declared the key.

Three tests, each mutation-verified to redden alone.
mikebronner added a commit that referenced this pull request Aug 29, 2026
Removing the `env_vars` section without bumping `CACHE_VERSION` creates an
upgrade path nothing else covers. A cache written by the pre-#356,
post-#348 binary is already at version 6 and still carries a populated
`"env_vars"` object, so the version-rejection guard never fires on it —
the key simply has to be ignored.

The test plants exactly that file: a real `save()` produces a current
cache with a Laravel config and a middleware entry, then the `"env_vars"`
object is grafted back on as JSON with the version left untouched. Loading
it must succeed with no forced rescan and both surviving sections intact.

Mutation-verified: adding `#[serde(deny_unknown_fields)]` to `LspCache`
turns it red.

Refs #356
mikebronner added a commit that referenced this pull request Aug 29, 2026
…delete it (#359)

* chore: start work on #356

Watson-Branch: #356

* refactor: ♻️ remove the consumerless warm-start env-var cache

The warm-start path read the cached env map and registered it with the
Salsa actor, and nothing ever read it back: `get_env_variable` and
`get_env_variable_names` had no caller outside `salsa_impl.rs`, and every
client-visible surface — env completion, `.env` hover, `config('…')`
completion, the `env()` diagnostic — walks the registered `.env` sources
through `get_all_parsed_env_vars` instead. The cache write, the
registration, and the `CACHE_VERSION` bump all ran; the product was never
collected.

Take option B from #356: delete the plumbing rather than build four
fallback read paths, each needing its own redaction proof. This also stops
writing a long-lived file of `.env` variable names outside the project
directory, so the on-disk exposure surface shrinks to nothing.

Removed:
- `SalsaHandle::{register_cached_env_vars, get_env_variable,
  get_env_variable_names, register_env_variables}`, their `SalsaRequest`
  variants, match arms and actor handlers, plus the actor's
  `env_variables` field and the `EnvVariableData` type.
- `CachedEnvVars`, `LspCache::env_vars`, and
  `CacheManager::{get_env_vars, set_env_vars}`.
- The `main.rs` warm-start registration and the cache-write loop that
  built `CachedEnvVars` from `get_all_parsed_env_vars()`.

Kept deliberately:
- `is_sensitive_env_name` / `mask_url_credentials` — shared with the live
  display surfaces and the database logger, which are untouched.
- `CACHE_VERSION` at 6 and its top-level `const _: () = assert!(…)` floor.
  The v5 caches holding `.env` plaintext are still on disk, so the
  rejection guard still has a job. The version is deliberately not bumped:
  a v6 file written before this change carries an `env_vars` key that
  serde now ignores, and forcing a rescan to delete an inert key buys
  nothing.

Test surgery, per the acceptance criteria:
- `the_reloaded_cache_registers_cleanly_on_a_cold_server` and
  `the_disk_cache_keeps_sensitive_names_but_never_their_values` are gone —
  both exercised only the deleted APIs.
- `a_pre_bump_cache_holding_plaintext_is_rejected_and_rescanned` is
  rewritten as `a_pre_bump_cache_is_dropped_wholesale_and_the_rescan_restores_it`
  against the cached Laravel config. Renamed because the old name would
  now be a false claim: the test asserts nothing about plaintext. Both
  original disciplines survive — the planted version is derived from the
  file's own `version` field, and the fixture goes through a real
  `CacheManager::load`/`.save()` round trip. It also gains a second
  planted section so "dropped wholesale" is checked, not assumed.
- The module doc now describes three client-rendered surfaces, not four.

Doc sweep beyond `src/`: `CLAUDE.md` named `EnvVariableData` twice, in the
Salsa component table and as the `*Data`-suffix example. Both now name the
live `ParsedEnvVarData`. `populate_cache_from_salsa`'s docstring no longer
claims to cache env.

Refs #356

* test: ✅ pin that a stale env_vars cache key loads harmlessly

Removing the `env_vars` section without bumping `CACHE_VERSION` creates an
upgrade path nothing else covers. A cache written by the pre-#356,
post-#348 binary is already at version 6 and still carries a populated
`"env_vars"` object, so the version-rejection guard never fires on it —
the key simply has to be ignored.

The test plants exactly that file: a real `save()` produces a current
cache with a Laravel config and a middleware entry, then the `"env_vars"`
object is grafted back on as JSON with the version left untouched. Loading
it must succeed with no forced rescan and both surviving sections intact.

Mutation-verified: adding `#[serde(deny_unknown_fields)]` to `LspCache`
turns it red.

Refs #356
mikebronner added a commit that referenced this pull request Aug 29, 2026
* chore: start work on #341

Watson-Branch: #341

* feat: ✨ Hover and go-to-definition on keys in .env buffers

Both handlers returned before any dispatch on a `.env` buffer:
`goto_definition` gated on `.php`, `hover` on `.blade.php`/`.php`. Four
other env features already classified files through the shared
`env_key_locator::is_env_file_name` gate; these two now join them, and
branch to a dedicated env path ahead of the PHP pattern index rather than
falling through it into `Ok(None)`.

- hover on a key renders its name, effective value, declaring file (via
  the existing `.env`/`.env.local`/`.env.example` priority ladder) and
  consumer count. Commented-out and not-defined states mirror
  `hover_for_env`; the not-defined card keeps its count, which is the
  point of hovering a key the project cannot resolve.
- go-to-definition jumps to the consuming `env('KEY')` call sites — every
  one of them, as a `Scalar` for a single hit and an `Array` otherwise.
  A key with no consumers returns `None` where hover still renders.
- `enumerate_commented_keys_in_source` finds a key on a `#` line, which
  `enumerate_keys_in_source` classifies as "not a declaration". Both
  delegate to the same `parse_key_declaration`, and stay separate so a
  commented line cannot win the code lens's first-match-wins race.
- the reference-count phrasing and the de-duplicated location lookup move
  into `reference_count_label` / `reference_locations`, so a key's lens
  count and its hover count come from one call and cannot disagree.

`textDocument/references` deliberately keeps its `.php`/`.blade.php`
gate: the reference code lens stays the only "find consumers" entry point
for env keys, as it is for config and translation keys. Pinned by a test.

Docs: `environment.md` claimed hover and go-to-definition "only ever run
on `.php` and `.blade.php`", which this change falsifies — corrected, with
the surrounding argument (a real `.sh` gets nothing) left intact.

Watson-Branch: #341

* fix: 🔒 redact secrets in the .env-buffer hover card, and unpin a path separator

Two fixes the merge with main surfaced.

`hover_for_env_declaration` rendered the value raw. `hover_for_env` — the
same card for the reverse direction — drops a value whose *name* matches
`is_sensitive_env_name` and masks URL credentials otherwise (#344, #348).
That the value is already on screen in the buffer is not a reason to skip
the guards: this is LSP output like any other surface those issues swept.
Both guards now apply, with a test for each arm.

`env_value_redaction` (new on main in #348) compared a completion detail
against a literal `config/app.php`. The detail renders a display path with
the platform separator, so Windows produced `config\app.php` and reddened
the whole matrix job on a difference that has nothing to do with
redaction. The expectation is built from `Path::join` instead; the
assertion still pins the redaction string itself.

Watson-Branch: #341

* refactor: ♻️ read `.env` comment classification from one rule

`enumerate_commented_keys_in_source` re-implemented the trim →
`trim_start_matches('#')` → trim classification `parse_env_source` already
does, and its doc comment asked the two copies to stay in step. An invariant
held by a comment is an invariant that drifts: change one side (require a
space after `#`, say) and cursor hit-testing silently disagrees with what
Salsa calls commented.

`commented_declaration_body` is now the one definition, and every reader of a
`.env` line classifies through it — Salsa's `parse_env_source`, the
declaration parser that must reject exactly those lines,
the commented-key enumeration that parses what they hide, and the
inline-comment tokenizer.

The rule is unchanged, so no behaviour moves: same `#` run, same whitespace,
same body. Direct tests pin it — the marker run, the non-comment shapes, and
the suffix property the column arithmetic rests on.

Watson-Branch: #341

* fix: 🐛 hover the `.env` declaration under the cursor, not one found by name

`env_key_at_position` resolved the cursor to an exact line in an exact file,
then `hover_for_env_declaration` threw that away and asked
`get_parsed_env_var` — a name-keyed, priority-merged lookup — what state the
key was in. Every declaration inside one file carries that file's priority, so
two declarations of one key always tie, and the tie kept the textually-first
line. The cursor played no part.

Both directions were wrong for the ordinary habit of commenting an old value
out beside the live one:

- `# APP_NAME=old` above `APP_NAME=new` — hovering the live line rendered
  *(commented out)*, a live declaration reported as disabled.
- `APP_NAME=new` above `# APP_NAME=old` — hovering the comment rendered the
  live value with no commented-out marker at all.

The state now comes from the cursor: `env_key_at_position` returns which
enumeration matched, and a commented declaration needs no lookup at all — it
is the line under the cursor, in this buffer, with no value in effect and its
own file to link. The priority ladder still resolves an active declaration's
value and declaring file, as the criteria ask.

The tie-break itself was the mechanism, so it is fixed where it lives: at
equal priority an active declaration now outranks a commented one, in both
env merges, through one shared `env_var_supersedes`. A comment is not a
declaration competing for the key. A commented declaration in a
higher-priority file still wins — switching a key off in `.env` is how a
project disables it — and hovering an active declaration it outranks now
reports no value in effect rather than quoting one the application never
sees.

Tests: the fixture pair Holmes asked for (one file, one key, active and
commented, hovered on each line, both orderings), the reverse `env('KEY')`
direction and the whole-table merge on the same fixture, the commented card's
own source link, and the outranked-by-a-comment case. Three thin assertions
sharpened: the priority test now spans all three rungs and its middle tier
alone, the zero-consumer card asserts value and source link (its fixture key
matched the sensitive-name pattern, so it never carried a value at all), and
the commented card asserts the value is absent.

Watson-Branch: #341

* test: ✅ pin the env merge's first-wins tie on both call sites

`env_var_supersedes`'s equal-priority arm has four input rows. The two mixing a
comment with a live line were pinned; two *active* declarations of one key were
not. Mutating that arm's `&&` to `||` therefore left the whole suite green while
first-wins silently became last-wins — and `enumerate_keys_in_source` only ever
resolves the cursor onto the first `KEY=` line, so hovering that line would have
rendered the second declaration's value. A card describing a line other than the
one under the cursor: the defect the previous commit closed, reached from the
other side.

The fixture for it already existed. It asserted only that the first line
resolved *something*, never which declaration answered; it now asserts the first
declaration's own value is on the card and the second one's is not.

The table merge gets the same tie pinned as well. It already carried the
comment-loses row, so leaving first-wins on the by-name side alone would have
left one of two call sites of the shared rule free to drift.

Mutation-verified live, all three forms the arm can take:

- `||` (first-wins becomes last-wins) — both new assertions red, and only
  those two, which is what made this a gap rather than incidental coverage.
- `false` (the pre-fix always-first-wins) — the three comment-loses tests red,
  the new pair green, so they discriminate on their own row.
- `true` (always last-wins) — the comment-below test and both new assertions
  red.

Row four, two commented declarations, stays unpinned deliberately: the
commented branch is cursor-derived and renders no value, so no output tells the
two apart.

Refs #341

* fix: 🔒 render `.env` text as text, not as markdown, in cards and panels

A `.env` key is everything before the first `=`, with no charset
restriction, and the hover card rendered it as a bolded markdown header.
A line spelling `[Update your credentials here](https://evil.example)=1`
therefore put a live clickable link inside the card, and an `![](…)`
variant needed no click at all. `.env.example` ships in public
repositories, so opening an unfamiliar project is the whole of the reach.

Close it at the renderer rather than at the four call sites that are
known-unsafe today, so callers that do not exist yet are safe too. The
class is "unconstrained `.env` text reaching markdown unescaped", and
sweeping it found three renderer sites, not the two the review named:

- `hover::render`'s bold header — the in-PR hole;
- `hover::render`'s code fence, which a value carrying three backticks
  closes early (pre-existing, and it hits `hover_for_env` identically);
- `CompletionDoc::render`, which has both shapes and is fed a raw key
  and a raw value by the `completion` handler's `.env` branch.

`markdown_safety::escape_inline` defers its escape set to
`char::is_ascii_punctuation`, whose ranges are exactly CommonMark's
escapable set — a spec constant rather than a hand-listed set of "the
characters that can start a construct", which is the enumeration that
goes stale. `fenced_block` sizes each fence one backtick longer than the
longest run inside its content.

Rendered output is unchanged: CommonMark renders `\.` as `.`. The wire
text gains backslashes, which is why 33 assertions across nine test
files move to the escaped form — that spread is the sweep, showing which
surfaces route through these two renderers.

Field contracts are now explicit in both structs, so the boundary reads
as a decision rather than an oversight: `header` is plain text and the
renderer escapes it; `detail`, `description` and `summary` are
markdown-bearing by design and a call site putting untrusted text there
owns the escaping.

Watson-Branch: #341

* test: ✅ make three env-navigation claims assert what their comments promise

All three are prose that described a stronger test than the code under
it.

The module doc said every test drives the real `hover` /
`goto_definition` handler "not the helpers underneath". Three of the 21
do the opposite, on purpose: the reverse direction has no `.env`-buffer
handler to drive, and the two table-merge tests read the Salsa layer
because that is where the shared merge rule's second call site lives.
Scope the claim to the navigation tests and name the exceptions.

`hover_in_a_lower_priority_file_shows_the_winning_value` promised to
"name `.env` as the declaring file" and asserted three values and no
link, so a mutation that resolved the right value through the ladder and
then linked the *queried* file survived it. It now rules out both other
rungs by name, since `.env` is a substring of each. Verified live: with
`hover_for_env_declaration` linking `path` instead of
`var.source_file`, the test reddens.

`contains("0 references")` is also satisfied by `"10 references"`, and
`contains("1 reference")` by `"1 references"`. No live mutation escaped,
because no fixture reaches a double-digit count — this closes the shape
rather than waiting for the fixture that makes it real. All six count
assertions now route through one helper that builds the label from
`code_lens::reference_count_label`, so the pluralisation rule cannot
drift, and pins both ends of the match to a non-alphanumeric boundary.
The helper has its own test for the two escapes above.

Adds the fixture the review asked for: a key spelling a markdown link
and one spelling an image, plus a value carrying a fence, driven through
the real handler. Each was mutation-verified against the renderer change
it guards, including on the reverse direction's call site.

Watson-Branch: #341

* fix: 🔒 escape the `.env` value the completion panel shows

Last round closed "unconstrained `.env` text reaching markdown unescaped"
at both renderers, and wrote the boundary down: `header` is plain text the
renderer escapes for every caller, `summary` is markdown-bearing and its
call site owns the escaping. The `.env` branch of `completion` is the call
site that hands `summary` untrusted text, and it escaped nothing. The round
that documented the contract left the one site that violates it.

A `.env` value has no charset restriction, any more than a key does.
`SUPPORT_NOTICE=[Update your credentials here](https://evil.example)`
therefore renders a live clickable link in the completion documentation
panel, and the `![](…)` variant is fetched with no click at all. That is
the reach the key had through the hover header, one field over, in the
popup most likely to be open while the `.env` file is the one on screen.

The escape wraps the whole `summary` expression, not its untrusted arm.
All three arms here are plain text meant to render as themselves, so
escaping the field rather than one branch of it means a fourth arm added
later cannot reopen this.

Two test helpers modelled the panel with an unescaped value and move to the
escaped form. `assert_no_process_var_leak` now strips backslashes before
its whole-response substring search: the summary is escaped, so a leaked
value carrying punctuation no longer spells the raw needle there, and the
"any field" reach that assertion claims would have quietly stopped covering
it. That strip is not what catches the restored `std::env::vars()` loop —
`label` and `detail` are plain text and still do — it is what stops the
summary becoming a blind spot.

The module doc now states the guarantee per field: the renderers cover the
header and the code fence, and every other field is verbatim by design.

Watson-Branch: #341

* fix: ✅ pin the config label the way `main` now guarantees it

The `main` merge reddened `the_config_completion_response_carries_no_dotenv_secret`
on Windows alone: the test expected `config\app.php` and got `config/app.php`.

The test was right until this merge and is wrong after it. #336 moved the
config label from a hardcoded `format!("config/{group}.php")` to
`strip_prefix`, which picked up the platform separator; the commit now on
`main` fixes that by routing the label through `with_forward_slashes`,
because it is user-visible text that must not change shape with the host
OS. `config_source_label`'s own doc names the old Windows rendering as the
bug. So `config_app_display()` — which built the expectation with
`Path::join(...).display()` — encoded the pre-fix behaviour, and its comment
("Windows displays `config\app.php`, which is correct for Windows") now
asserts the opposite of what production guarantees.

Replaced with a literal `CONFIG_APP_LABEL`. The literal is the stronger
assertion, not merely the passing one: the helper mirrored the production
logic, so if the normalization were ever dropped the expectation would pick
up the native separator alongside it and stay green on Windows — the only
platform where that regression is visible. A literal cannot follow it.

Mutation: setting the constant to `config\app.php` reddens the test with
exactly the CI failure inverted (`left` and `right` swapped), so the
assertion discriminates on separator shape rather than merely being reached.

Swept the class — `config_app_display` had no other call site, and no
sibling test in `src/tests` or `tests/` builds a user-visible expectation
through `display()`/`to_string_lossy()`.

Watson-Branch: #341

* test: ✅ restore the "any field" reach of both env leak searches

Round 5 fixed `assert_no_process_var_leak` to strip backslashes before
searching, because this PR's `escape_inline` on `CompletionDoc::summary`
spells a punctuated value `s3cr3t\-value\-set…` there. The reasoning was
written at that one assertion and carried nowhere else, so two sibling
searches were left hunting a needle the panel no longer spells:

- `env_completion_system_leak.rs`, the shadowing test's standalone check
  (the site Holmes flagged);
- `env_value_redaction.rs`'s `assert_no_secret_leak`, the same helper
  shape in another file, blind across all four of its needles — every
  one carries hyphens.

Both files now name the strip as `searchable()` so the reasoning reaches
every call site instead of one, and both helper docs' "any field" claim
is true again.

Proven live, not by inspection. A summary-only leak (the `.env`
completion summary made to answer from the real value, `detail` left
redacted) went entirely unnoticed by both searches before this change —
the tests failed only later, on a panel-equality assertion. After it,
each fails on the leak assertion itself: `env_value_redaction.rs:193`
and `env_completion_system_leak.rs:488`.

The strip is itself unexercised by a green suite — a leak has to exist
before the escaping can hide one — so deleting it would have degraded
silently, the same shape one level up. Each file now pins it at its own
definition, over its own needles, and each fixture reddens alone when
the strip is removed. Their first assertion fails if a needle ever loses
its ASCII punctuation, which is what stops the second going vacuous.

Scope checked and left alone: the hover surface renders values through
`fenced_block`, which lengthens the fence and never backslashes content,
so its raw-needle assertions still match; `escape_inline` reaches only
the two headers and this one summary. Every other negative assertion in
the tree either searches a field this PR does not escape or uses a
needle with no ASCII punctuation, which escaping leaves byte-identical.

Watson-Branch: #341
mikebronner added a commit that referenced this pull request Aug 29, 2026
`ensure_external_php_source_loaded` read file metadata and content
straight from disk and registered the result as a `SourceFile` that
`handle_blade_backing_class_resolution` then emits as a goto-definition
target — with no containment check of its own. Every caller pre-vets its
paths today, so nothing escapes; the hazard is that the guard lives in
the callers, and a new read site does not inherit the guard its
neighbours carry. That shape has already become a security fix three
times here (#294, #348 rounds 1 and 2).

The guard splits by branch, because the branches ask different questions:

- The client-ownership fast path reads no disk but emits the path, so it
  takes `path_within_root_emit_safe` — refuses out-of-root on the lexical
  pre-gate with no stat probe (#145), still admits a genuinely-absent
  in-root buffer (#361).
- The disk branch reads real bytes, so it takes the fail-closed
  `canonical_within_root_registration`, and both filesystem calls go
  through the verified canonical path it returns rather than re-deriving
  one that a swapped symlink could redirect.

Root unknown short-circuits first, before any state is read or mutated.

Three test harnesses registered the backend's root but never the actor's
`config_root`; `register_project_files` does not set it. Production
always registers config first, so this was fixture drift, not a
behaviour change — corrected in all three.

Fixes #364
mikebronner added a commit that referenced this pull request Aug 29, 2026
…inment guard (#366)

* chore: start work on #364

Watson-Branch: #364

* refactor: ♻️ give the actor a constructor its tests can reach

`SalsaActor`'s struct literal lived inside the `std::thread::spawn`
closure, so no test could ever hold an actor and drive `&mut self`
methods against it. Lift it into `SalsaActor::new`; `spawn` keeps the
threading, `new` owns the fields. No behaviour change.

Prerequisite for #364, whose acceptance criteria require assertions on
`files` and `external_php_text` — neither of which any `SalsaHandle`
message exposes.

* fix: 🔒 put the containment guard on the read, not on its callers

`ensure_external_php_source_loaded` read file metadata and content
straight from disk and registered the result as a `SourceFile` that
`handle_blade_backing_class_resolution` then emits as a goto-definition
target — with no containment check of its own. Every caller pre-vets its
paths today, so nothing escapes; the hazard is that the guard lives in
the callers, and a new read site does not inherit the guard its
neighbours carry. That shape has already become a security fix three
times here (#294, #348 rounds 1 and 2).

The guard splits by branch, because the branches ask different questions:

- The client-ownership fast path reads no disk but emits the path, so it
  takes `path_within_root_emit_safe` — refuses out-of-root on the lexical
  pre-gate with no stat probe (#145), still admits a genuinely-absent
  in-root buffer (#361).
- The disk branch reads real bytes, so it takes the fail-closed
  `canonical_within_root_registration`, and both filesystem calls go
  through the verified canonical path it returns rather than re-deriving
  one that a swapped symlink could redirect.

Root unknown short-circuits first, before any state is read or mutated.

Three test harnesses registered the backend's root but never the actor's
`config_root`; `register_project_files` does not set it. Production
always registers config first, so this was fixture drift, not a
behaviour change — corrected in all three.

Fixes #364

* fix: 🔒 gate the loader on the module that owns the path

Round-1 review of my own guard: gating `ensure_external_php_source_loaded`
against `config_root` alone silently dropped every backing class inside a
module symlinked in from a composer path repository. `expand_module_dirs`
admits that layout on purpose (`config.rs:1180`) and
`livewire_namespaces::contained_class_path` gates its registrations
against the owning module for exactly this reason (`livewire_namespaces.rs:205`)
— so the paths were minted legally and then refused at the read. There is
no "component not found" diagnostic, so the only symptom was goto and
hover quietly doing nothing.

Both branches now gate against `config::owning_module(&self.module_dirs,
path)`, falling back to the root. This is not a relaxation: for a module
path it TIGHTENS the guard, because a candidate lexically under a module
must canonicalize inside THAT module — one reaching into a sibling module
or into bare `app/` is refused despite being in-root. `owning_module`
collapses `..` before its prefix test, so a traversing path cannot elect
itself a laxer gate. With no modules configured the gate is the root and
behaviour is unchanged.

Five regression tests, each verified to fail under the mutation it pins:
the symlinked module loading, the escape out of an elected module gate,
the reach back into `app/`, the traversal, and the no-modules case.
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.

Decide the policy for echoing project .env values in LSP output (4 surfaces)

1 participant