Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
167a5f2
fix: mask filesystem paths in telemetry error text
Aug 19, 2026
4899189
fix: mask cloud-storage URIs and paths with embedded spaces
Aug 19, 2026
9d56682
fix: bracket anchors, spaced cloud keys, terminal spaced components
Aug 19, 2026
40cb7fe
fix: spaced-username tails and Windows closing delimiters
Aug 19, 2026
b466d17
fix: file:/ URIs, case-insensitive Windows homes, long-extension prose
Aug 19, 2026
12728e0
fix: unicode path components and colon-adjacent paths
Aug 19, 2026
acef14f
fix: combining marks, curly apostrophes, ;/< boundaries — and rule order
Aug 19, 2026
fabd76b
fix: ASCII apostrophes as path content when word-char follows
Aug 19, 2026
ab8417d
fix: delimiters inside path components — and the halting condition
Aug 19, 2026
7b794e7
fix: linearize the delimiter lookahead — ReDoS, and two coverage gaps
Aug 19, 2026
6e1d639
fix: dot-relative paths, symbol components, cloud terminal keys
Aug 19, 2026
a514316
refactor: compose path rules from shared fragments; five coverage fixes
Aug 19, 2026
8801300
fix: parens inside components, nested home roots (WSL)
Aug 19, 2026
f64496d
fix: windows dot-relative paths, attached dotted filenames after deli…
Aug 19, 2026
a880343
fix: backslash path components, extended-length windows homes; move h…
Aug 19, 2026
7d8678e
fix: double spaces in components, current-drive-rooted windows paths
Aug 19, 2026
be0daf9
fix: strip ANSI sequences, drive-relative windows paths, quote delimi…
Aug 19, 2026
f8939f1
fix: quote-pair safety, pipe anchors, OSC escape stripping
Aug 19, 2026
c963b71
fix: spaced first components in dot-relative paths, drive-relative fo…
Aug 19, 2026
499e956
fix: home-rule reach through spaced prefixes and UNC shares; drive-re…
Aug 19, 2026
7eb8382
fix: rooted-proof breadth (spaced/symbol components), tilde home tail
Aug 19, 2026
a5f79d8
fix: multi-word PII tails, proof punctuation, bounded delimiter scans
Aug 19, 2026
6a0dfd3
fix: proof bounds match the filesystem component limit; shallow path …
Aug 19, 2026
43b5d68
fix: spaced shallow terminals, name particles in PII tails, extension…
Aug 19, 2026
454cb28
fix: remove the i flag from home/cloud rules — case-fold breaks the t…
Aug 19, 2026
78f92a4
fix: legacy Documents and Settings home roots, 14-char extension window
Aug 19, 2026
adce061
fix: delimiter proofs accept mixed unspaced+spaced runs
Aug 19, 2026
96dbe0e
fix: shell redirects anchor paths, single-component absolute files mask
Aug 19, 2026
7565405
fix: closing delimiters anchor paths; forward-slash UNC with dotless …
Aug 19, 2026
b75d618
fix: colon-anchored bare absolute files (ENOENT:/private.sql)
Aug 19, 2026
7707270
fix: forward-slash UNC homes get the high-PII tail
Aug 19, 2026
af81386
fix: NBSP-bearing components, hyphenated extensions
Aug 19, 2026
1e1e2a3
fix: PowerShell backslash tilde paths
Aug 19, 2026
6bdbd1b
fix: dotfile tilde paths, letter-bearing drive proofs, NBSP particles
Aug 19, 2026
ddf1dad
fix: one-char backslash tilde components
Aug 19, 2026
0b43716
fix: one-char rooted components, brace anchors, unicode extensions
Aug 19, 2026
f902eea
fix: ampersand anchors, component-standard bounds, mark-bearing exten…
Aug 19, 2026
a811a23
fix: +& in rooted components (len 3+), 30-char extension window
Aug 19, 2026
2e1275f
fix: collapse pure separator runs before the composed rules
Aug 19, 2026
abd0ff4
feat: known-prefix literal masking layer, fast-path gates, shallow wi…
Aug 19, 2026
aa5d6d1
test: restructure path-masking suite by subject
Aug 19, 2026
ced57f4
fix: layer-zero ordering and boundaries, drive-relative single files
Aug 19, 2026
a32d716
fix: tilde symbol components, drive-relative terminal filename breadth
Aug 19, 2026
35320ed
fix: JSON-doubled home separators, honest NBSP tests, comment accuracy
Aug 19, 2026
22f0c54
chore: rewrap known-prefix comment to block style
Aug 19, 2026
4acad5e
fix: bounded bridges, linear drive-colon, fresh cwd, growth tests, FQ…
Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const log = Log.create({ service: "telemetry" })
* | extend err = tostring(customDimensions.error_class)
* | summarize count() by err
*/

// altimate_change end

/** True when a test runner is driving the process rather than a real user session.
Expand All @@ -64,6 +65,91 @@ function isAutomatedRun(): boolean {
return Boolean(process.env.BUN_TEST || process.env.VITEST || process.env.JEST_WORKER_ID)
}

// altimate_change start — composed path-masking rules (shared fragments)
// R: one path-run character — anything but whitespace/separators/quotes,
// plus an apostrophe when a word character follows (O'Connor vs closing ').
const PM_R = "(?:[^\\s\\/\\\\'\"`]|'(?=[\\p{L}\\p{N}_]))"
// slash-delimited variant: backslash is path content, not a separator
const PM_R_P = "(?:[^\\s\\/'\"`]|'(?=[\\p{L}\\p{N}_]))"
const PM_WORD = "(?:[\\p{L}\\p{M}\\p{N}_‘’-]|'(?=[\\p{L}\\p{N}_]))"
const PM_ANCHOR = "(^|[\\s\"'`=(,[{:;<|>)\\]}&])"
const PM_SP = "[^\\S\\t\\n\\r\\v\\f]"
const PM_EXT = "\\.[\\p{L}\\p{M}\\p{N}-]{0,29}[\\p{L}\\p{M}\\p{N}]"
// span char: path content incl. delimiters (, ; ) ] } >) that a later
// separator — or an attached dotted terminal filename (;draft.sql) —
// proves is path content — multi-word spaced runs allowed, all
// quantified units space- or separator-anchored with disjoint inner classes
// (unambiguous parse => linear time; the nested-quantifier ReDoS shape is
// banned here).
const SEP_P = "\\/"
const SEP_W = "[\\\\\\/]"
// per-letter case expansion — used instead of the i flag on home/cloud
// rules: under /iu, conformant engines case-fold \p{Lu}, which would turn
// the capitalized-tail gate into "match any word" (V8 folds; JSC does not —
// never rely on the divergence)
const pmCI = (w: string) => w.split("").map((c) => ("[" + c + c.toUpperCase() + "]")).join("")
const pmR = (sep: string) => (sep === SEP_P ? PM_R_P : PM_R)
const PM_WR = "(?:[^\\s\\/\\\\'\"`,;:\\]}>]|'(?=[\\p{L}\\p{N}_]))"
const PM_WR_P = "(?:[^\\s\\/'\"`,;:\\]}>]|'(?=[\\p{L}\\p{N}_]))"
const pmWR = (sep: string) => (sep === SEP_P ? PM_WR_P : PM_WR)
const PM_SEG_L = "(?=[^\\s'\"`]{0,128}[\\p{L}\\p{M}]|[\\s'\"`,;)\\]}>]|$)"
const pmSpan = (sep: string) =>
"(?:[^\\s'\"`)\\]},;>]|'(?=[\\p{L}\\p{N}_])|[,;)\\]}>](?=" + pmR(sep) + "{0,256}(?:" + PM_SP + "{1,2}" + pmWR(sep) + "{1,64}){0,2}(?:" + sep + PM_SEG_L + "|" + PM_EXT + "(?=$|[\\s.,;:)\\]}!?])))|[\"'`](?=" + pmR(sep) + "{1,256}(?:" + PM_SP + "{1,2}" + pmWR(sep) + "{1,64}){0,2}(?:" + sep + PM_SEG_L + "|" + PM_EXT + "(?=$|[\\s.,;:)\\]}!?]))))"
const pmChunks = (sep: string) =>
"(?:(?:" + PM_SP + "{1,2}" + pmWR(sep) + "{1,64}){1,2}" + sep + PM_SEG_L + pmSpan(sep) + "*){0,2}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — the two-word bridge cap re-opens the spaced-path leak: a directory component of 4+ words now leaks its tail, in every rule family.

pmChunks is (?:(?:SP{1,2} WR{1,64}){1,2} sep SEG_L span*){0,2} — at most two bridge words per chunk, at most two chunks. A directory component with four or more space-separated words exhausts that budget mid-component; the match stops there and the remainder is emitted raw. This regresses 4899189e7, the commit that fixed the original spaced-path finding.

input 22f0c541 4acad5ea
read /data/my big client folder/models/x.sql read <path> read <path> big client folder/models/x.sql
read /srv/acme corp data warehouse/models/x.sql read <path> read <path> corp data warehouse/models/x.sql
read C:\data\my big client folder\models\x.sql read <path> read <path> big client folder\models\x.sql
s3://bucket/my big client folder/models/x.parquet <path> <path> client folder/models/x.parquet
read ~/w0 w1 w2 w3/models/x.sql read <path> read <path> w2 w3/models/x.sql
read //srv/share/Users/jdoe/w0 w1 w2 w3/x.sql read <path> read <path> w2 w3/x.sql

Swept component width 1→6: 1–3 mask, 4+ leak. Strictly per-component — /data/w0 w1 w2/w3 w4 w5/x.sql (six words over two components) masks cleanly, /data/w0 w1 w2 w3/x.sql (four in one) does not. A second, rarer trigger is the {0,2} chunk count, which is what breaks /Users/Jane Doe/client repo/deep nested dir/model.sql even though no component exceeds three words.

Nine realistic folder names from ordinary macOS/Windows/cloud layouts — 6 of 9 regressed, two leaking a client organisation name outright:

1 /Users/jdoe/OneDrive - Acme Corporation Ltd/data/x.sql          ok
2 /Users/jdoe/Documents/Q3 2026 Client Deliverables/model.sql     ok
3 C:\Users\jdoe\Documents\New Folder with Long Name\x.sql      -> <path> with Long Name\x.sql
4 /Users/jdoe/Desktop/Copy of Client Data 2026/x.csv           -> <path> 2026/x.csv
5 ~/Dropbox/Acme Corp Data Warehouse/models/x.sql              -> <path>/models/x.sql
6 /Volumes/Acme Client Shared Drive/data/x.parquet             -> <path> Client Shared Drive/data/x.parquet
7 s3://lake/raw events by client region/2026/part.parquet      -> <path> by client region/2026/part.parquet
8 /Users/jdoe/Library/Mobile Documents/com~apple~CloudDocs/x.sql   ok
9 /srv/data/acme financial services ltd/models/x.sql           -> <path> financial services ltd/models/x.sql

All nine mask completely on 22f0c541. The known-prefix literal layer doesn't rescue it either — it masks the prefix and leaves the client-named tail, including under $HOME and the active project cwd.

No test covers a 4-word directory component. The 4-word allowance in pmSpFile and the 12-word allowance in pmSpFileX are about terminal filenames; the directory-component tests top out at three words, so the boundary that broke sits exactly in the untested gap.

On the fix — I don't think "raise the bound" is right, and your earlier reasoning is why. You rejected {1,8} on pmSpFile with a concrete counterexample (read /opt/x error reading the project config.yml here consuming five prose words), and the same objection applies here: the bridge cap is load-bearing, and widening it plainly re-opens what I raised last round.

What does separate the two classes is whether the path so far already ended in a dotted extension. Every one of the seven shapes I gave you last round ends in one (/app/x.sql, C:\proj\x.sql, ~/proj/x.sql, s3://b/k.parquet), and none of the leak cases above do — they're mid-path directory components. So: keep the tight bound after a dotted extension, and allow a wider one ({1,8}) when the run so far has no extension — the same extension-lookbehind discriminator PM_TERM_COND already uses.

I checked this against both sets: it covers 5/5 of the leak class and keeps 4/6 of the prose class safe. The two it doesn't cover (dbt deps failed in /Users/j/p: package hub/dbt-utils not found, and your own /opt/x … config.yml example) are extensionless and would still bridge — that's the undecidable residue you've already documented, not a regression. Your call whether the trade is worth it, but it's a strictly better position than the current bound.

Related, same root cause: PM_SEG_L's {0,128} scan means a component whose first letter falls past position 128 after a bridge breaks the chain (/opt/client repo/<129 digits>a/private.sql leaks), and PM_WR{1,64} does the same for a bridge word over 64 characters. Both narrow, both the same defect. And the narrowed fast-path gate costs the same way: c:my very secret file.sql and c:a b c d.sql masked before and are now untouched, because (?: [^\s:]{1,255}){0,2} allows only two spaces.

// terminal dotted filename: up to four spaced words that END in an extension
const pmSpFile = (sep: string) => "(?:(?:" + PM_SP + "{1,2}" + pmR(sep) + "+){1,4}(?<=" + PM_EXT + "))?"
Comment on lines +100 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mask terminal filenames with more than four spaced words

When an explicit shallow path has five or more spaced continuations, for example read ./Q1 final audited customer revenue report.sql failed, the bounded repetition rejects the terminal filename and the entire customer-specific path remains unchanged. Filesystems do not impose a four-word limit, so keep the match linear using a component-length bound rather than limiting the number of words.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f902eea91 — with an important scope line: explicit shallow paths (./-prefixed, where everything up to the boundary is filename) now use a length-bounded variant (12 words × 64 chars), so your example masks. Deep tails keep {1,4} deliberately — there the bound is a prose cap, not a filesystem claim: read /opt/x error reading the project config.yml here must not chase the dotted token through five prose words (this exact trade-off was probed and documented in the earlier spfile thread; the control is re-pinned alongside your fix).

const PM_TERM_COND = "(?:(?<!" + PM_EXT + ")" + PM_SP + "{1,2}" + PM_WORD + "+(?=$|[.,;:)\\]}!?]))?"
const PM_TERM_UNC =
"(?:(?<!" + PM_EXT + ")" + PM_SP + "{1,2}" + PM_WORD + "+(?:" + PM_SP + "{1,2}(?:[\\p{Lu}\\p{Lo}]" + PM_WORD + "*|(?:v[ao]n|de[nrl]?|d[aiou]|dos|la|les?|los|bin|ibn|al|el|te[nr])(?=" + PM_SP + ")))*)?"
const PM_WC = "(?:[\\p{L}\\p{N}_#@().'-]{2,}|[\\p{L}\\p{N}_#@().'+&-]{3,})"
const PM_WCC = "[\\p{L}\\p{N}_#@().'+&-]+"
const pmSpFileX = (sep: string) => "(?:(?:" + PM_SP + "{1,2}" + pmR(sep) + "{1,64}){1,12}(?<=" + PM_EXT + "))?"
const pmTail = (sep: string, term: string) => pmSpan(sep) + "*" + pmChunks(sep) + pmSpFile(sep) + term
// Known-prefix literals — the local user's home and cwd are KNOWN values,
// replaced by exact match AFTER the structural rules (structure must see the
// original string: stripping the prefix first orphans terminal spaced
// components). The literal pass mops up whatever structure missed. Exact
// matching handles every username shape (spaces, NBSP, unicode) with zero
// false positives; the structural rules below remain for paths the
// literals cannot know (other drives, UNC shares, cloud URIs, relative
// forms, WSL-mounted homes). Variants cover JSON-doubled backslashes and
// swapped separators. Same approach as Salesforce's telemetry GDPR scrub
// (os.homedir() literal) and gatsby-telemetry's cleanPaths (cwd prefixes).
const pmEscape = (v: string) => v.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&")
const pmPrefixVariants = (root: string): RegExp[] => {
if (!root || root.length < 4) return []
const out: RegExp[] = []
for (const v of new Set([root, root.replace(/\\/g, "\\\\"), root.replace(/\\/g, "/")])) {
out.push(new RegExp("(?<![\\w.-])" + pmEscape(v), "gi"))
}
return out
}
const PM_HOME_PREFIXES = pmPrefixVariants(os.homedir())
// The CLI chdirs into the project after this module loads (tui/attach/run),
// so the cwd literals are rebuilt whenever process.cwd() changes — a stale
// import-time cwd would miss exactly the shallow, extensionless project root
// the structural rules cannot mask.
let pmCwdCache = ""
let pmCwdPrefixes: RegExp[] = []
function pmKnownPrefixes(): RegExp[] {
const cwd = process.cwd()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the process cwd has been deleted or becomes inaccessible, pmKnownPrefixes() throws from process.cwd() while masking an error. Catch that lookup failure and retain the last cached cwd (or skip cwd prefixes) so telemetry masking cannot turn the original error into a second exception.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/telemetry/index.ts, line 136:

<comment>When the process cwd has been deleted or becomes inaccessible, `pmKnownPrefixes()` throws from `process.cwd()` while masking an error. Catch that lookup failure and retain the last cached cwd (or skip cwd prefixes) so telemetry masking cannot turn the original error into a second exception.</comment>

<file context>
@@ -112,19 +117,35 @@ const pmTail = (sep: string, term: string) => pmSpan(sep) + "*" + pmChunks(sep)
+let pmCwdCache = ""
+let pmCwdPrefixes: RegExp[] = []
+function pmKnownPrefixes(): RegExp[] {
+  const cwd = process.cwd()
+  if (cwd !== pmCwdCache) {
+    pmCwdCache = cwd
</file context>
Suggested change
const cwd = process.cwd()
const cwd = (() => {
try {
return process.cwd()
} catch {
return pmCwdCache
}
})()

if (cwd !== pmCwdCache) {
pmCwdCache = cwd
pmCwdPrefixes = pmPrefixVariants(cwd)
}
return [...pmCwdPrefixes, ...PM_HOME_PREFIXES]
}
const PATH_RULES = {
cloud: new RegExp(PM_ANCHOR + "(?:(?:" + [pmCI("gs"), pmCI("s3") + "[anAN]?", pmCI("abfs") + "[sS]?", pmCI("wasb") + "[sS]?", pmCI("adl"), pmCI("dbfs"), pmCI("hdfs")].join("|") + "):\\/\\/|" + pmCI("file") + ":\\/{1,3})" + pmTail(SEP_P, PM_TERM_UNC), "gu"),
windowsHome: new RegExp(PM_ANCHOR + "(?:(?:\\\\\\\\\\?\\\\)?[A-Za-z]:" + SEP_W + "{0,2}|(?:\\\\\\\\(?:\\?\\\\" + pmCI("unc") + "\\\\)?|(?<!:)\\/\\/(?=[^\\s\\/\\\\]+" + SEP_W + "))(?:" + PM_R + "+(?:" + PM_SP + "{1,2}" + PM_WR + "{1,64})*" + SEP_W + "{1,2})*|" + SEP_W + "{1,2})(?:" + pmCI("users") + "|" + pmCI("documents") + " " + pmCI("and") + " " + pmCI("settings") + ")" + SEP_W + "{1,2}" + pmTail(SEP_W, PM_TERM_UNC), "gu"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — windowsHome was never bounded, so the round-1 bridging bug still lives in this family, and the FQDN change widened its reach.

The commit bounded pmChunks, pmSpan and the windows opener, but this rule keeps its own prefix loop unchanged:

(?:PM_R+(?:PM_SP{1,2} PM_WR{1,64})*SEP_W{1,2})*

The inner spaced-word repetition is still * — no two-word cap, no PM_SEG_L proof. So the exact bug I raised last round survives here, including the date and fraction shapes the commit message lists as fixed:

open //server.example.com/share/x failed with <40 ordinary words>/Users/jdoe/secret.txt  ->  open <path>
open //server.example.com/share/x failed on 8/17/2026/Users/jdoe/secret.txt              ->  open <path>
open //server.example.com/share/x failed at ratio 1/2/Users/jdoe/secret.txt              ->  open <path>
open \\server\share\x failed with many ordinary bridge words here/Users/jdoe/secret.txt  ->  open <path>

Isolated to windowsHome by replacing each rule individually.

I want to be precise about what's new here, because most of it isn't. Checked against 22f0c541: the dotless (//srv/share/x …) and backslash (\\server\share\x …) spellings over-masked identically before this commit, so the unbounded production is pre-existing — I simply missed it last round. What is new is its reach. Removing the dotless restriction from this rule's // opener for the FQDN fix means FQDN hosts now enter it too, and that case was clean before:

//server.example.com/share/x failed with <40 words>/Users/jdoe/secret.txt
  22f0c541 ->  open //server.example.com/share/x failed with word0 … word39 <path>   (prose preserved)
  4acad5ea ->  open <path>                                                            (clause destroyed)

One caveat on reproducing this: the trailing path has to be attached to the last prose word by a separator. With a space before it (… word39 /Users/jdoe/…) the chain can't bridge and all 40 words survive on both revisions — so a probe using the spaced form will show no difference.

Suggested fix: compose this prefix from the same bounded primitives the other rules now use. Also worth adding a bridging control per family — the new tests in this area are POSIX-only, which is why nothing caught this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — FQDN UNC /home/ is still unmasked, and the segment list in your reply doesn't match the code.

The reply says the disambiguator is "Users/home/Documents and Settings". The alternation here is pmCI("users") | pmCI("documents") + " " + pmCI("and") + " " + pmCI("settings")home isn't in it. posixHome does carry users|home, but it can't fire on this input because the leading // leaves no valid PM_ANCHOR.

//server.example.com/share/Users/jdoe/secret.txt   ->  <path>       fixed
//server.example.com/share/home/jdoe/secret.txt    ->  unchanged    leaks
//server.example.com/share/homes/jdoe/secret.txt   ->  unchanged    leaks
//srv/share/home/jdoe/secret.txt                   ->  <path>       dotless host, generic rule
\\server.example.com\share\home\jdoe\secret.txt    ->  <path>       backslash spelling

So the two spellings still disagree on the same path — the same defect as last round, moved from Users to home. Checked against 22f0c541: this line is unchanged by the commit, so it's not a regression, it's the half of the fix that didn't land. Linux and NAS-hosted /home/ shares are the common corporate case, and the username is right there in the next segment.

Suggested fix: add case-expanded home (and homes) to the alternation.

Every schemed public URL is protected by the (?<!:) lookbehind rather than by the segment list, so this can't touch them — I verified on https://docs.example.com/home/getting-started, https://raw.githubusercontent.com/org/repo/main/home/x.yml, http://example.com/home/user/profile and https://api.example.com/v1/home. The one behaviour that would change is bare protocol-relative CDN URLs: //cdn.example.com/home/assets/app.js is preserved today and would start masking. That's the same trade you already accepted for //example.com/Users/guide.html, so it's consistent — worth stating in the comment rather than leaving to be rediscovered.

windows: new RegExp(PM_ANCHOR + "(?:[A-Za-z]:" + SEP_W + "|(?<!:)\\/\\/(?=[^\\s\\/\\\\.]+" + SEP_W + ")|[A-Za-z]:(?=" + PM_R + "{1,256}(?:" + PM_SP + "{1,2}" + PM_R + "{1,256})*\\\\|(?=[^\\s\\/\\\\]{0,256}[\\p{L}])" + PM_R + "{1,256}(?:" + PM_SP + "{1,2}" + PM_R + "{1,256})*\\/|" + PM_R + "{1,256}(?:" + PM_SP + "{1,2}" + PM_R + "{1,256}){0,6}(?<=\\.[\\p{L}\\p{M}\\p{N}-]{0,29})(?<=[\\p{L}\\p{M}][\\p{L}\\p{M}\\p{N}-]{0,29})(?=$|[\\s.,;:)\\]}!?]))|\\\\\\\\|\\.{1,2}\\\\(?=" + PM_R + "{1,256}(?:" + PM_SP + "{1,2}" + PM_R + "{1,256})*\\\\|[^\\s\\\\]{1,256}" + PM_EXT + "(?=$|[\\s.,;:)\\]}!?]))|\\\\(?=(?:" + PM_WC + "(?:" + PM_SP + "{1,2}" + PM_WCC + ")*\\\\){2}|" + PM_WC + "(?:" + PM_SP + "{1,2}" + PM_WCC + ")*\\\\[^\\s\\\\]{1,256}" + PM_EXT + "(?=$|[\\s.,;:)\\]}!?])|" + PM_WC + "(?:" + PM_SP + "{1,2}" + PM_WCC + ")+\\\\[\\p{L}\\p{N}]|[\\p{L}\\p{N}_-]\\\\(?:[\\p{L}\\p{N}_-]{2,}|\\.[\\p{L}\\p{N}_-]{2,})|[^\\s\\\\]{1,256}" + PM_EXT + "(?=$|[\\s.,;:)\\]}!?])))" + pmSpan(SEP_W) + "+" + pmChunks(SEP_W) + pmSpFile(SEP_W) + PM_TERM_COND, "gu"),
posixHome: new RegExp(PM_ANCHOR + "\\/(?:" + PM_R_P + "+(?:" + PM_SP + "{1,2}" + PM_WR_P + "{1,64}){0,2}\\/" + PM_SEG_L + ")*(?:" + pmCI("users") + "|" + pmCI("home") + ")\\/" + pmTail(SEP_P, PM_TERM_UNC), "gu"),
posix: new RegExp(PM_ANCHOR + "(?:\\.{0,2}\\/(?:" + PM_R_P + "+(?:" + PM_SP + "{1,2}" + PM_WR_P + "{1,64}){0,2}\\/" + PM_SEG_L + ")+" + pmTail(SEP_P, PM_TERM_COND) + "|(?:\\.{1,2}\\/|\\/(?!\\/))" + pmSpan(SEP_P) + "+" + pmSpFileX(SEP_P) + "(?<=" + PM_EXT + ")(?=$|[\\s.,;:)\\]}!?]))", "gu"),
tilde: new RegExp(PM_ANCHOR + "~[\\p{L}\\p{M}\\p{N}_.-]*(?:\\/|\\\\(?=" + PM_WC + "(?:" + PM_SP + "{1,2}" + PM_WCC + ")*[\\\\\\/]|\\.[\\p{L}\\p{N}_-]{2,}[\\\\\\/]|[\\p{L}\\p{N}_-]\\\\(?:[\\p{L}\\p{N}_-]{2,}|\\.[\\p{L}\\p{N}_-]{2,})))" + pmTail(SEP_W, PM_TERM_UNC), "gu"),
}
// altimate_change end

export namespace Telemetry {
const FLUSH_INTERVAL_MS = 5_000
const MAX_BUFFER_SIZE = 200
Expand Down Expand Up @@ -1381,12 +1467,64 @@ export namespace Telemetry {
// Bearer … Authorization headers leaked in error text
// Each match replaces with a fixed redaction so length-based fingerprinting
// can't reconstruct the original token.

export function maskString(s: string): string {
return s
// Consumers truncate masked output to <= 2000 chars; masking beyond 8 KB
// of input buys nothing and unbounded input is what turns any super-
// linear rule into a stall (a wide generated SELECT reached seconds).
if (s.length > 8192) s = s.slice(0, 8192)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — the 8 KB entry truncation runs before redaction, so a credential straddling the cut ships in the clear. This one is on me: I suggested this truncation in the last round, and it was the wrong advice.

maskString now truncates as its first statement, ahead of the sk-, Bearer, quote and path rules. Two of those are length-gated, and both fail open when the cut shortens their input:

  • sk- and Bearer require {20,} trailing characters. Cut a credential so fewer than 20 remain and no rule matches at all.
  • The quote rule /"(?:[^"\\]|\\.)*"/g needs a closing quote. Cut inside a quoted value and the collapse never happens.

Reproduced with a long quoted literal in front — it collapses to ?, so the surviving fragment ends up at the very front of the output:

"A"×8170 + " Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"  ->  ? Bearer ABCDEFGHIJKL
"A"×8170 + " sk-abcdefghijklmnopqrstuvwxyz0123456789"      ->  ? sk-abcdefghijklmnop
"A"×8170 + ' "customer-secret-value-here"'                 ->  ? "customer-secret-va
"A"×8180 + the same three                                  ->  ? Bearer AB / ? sk-abcdef / ? "customer

The whole masked output is 11–21 characters in each case, so the .slice(0, 2000) output guard at register.ts:433 provides no protection — the fragment is the payload.

Path rules are unaffected. I walked a /Users/jdoe/secret-client-repo/... path across the cut in 4-character steps and it either masks cleanly or degrades to a harmless /Use; same for ~/, C:\ and s3://. They aren't length-gated, which is exactly why they behave differently.

The truncation also isn't buying anything. Your {1,256} bounds did the real work. I removed the truncation line entirely and re-timed the full chain:

shape (no truncation) 8 KB 32 KB 128 KB 512 KB ratio 512k/8k (linear = 64)
a: repeat 0.1 0.2 0.9 3.5 ms 54
v:col SQL 0.2 0.5 2.1 8.8 ms 56
wide SELECT 0.1 0.3 1.3 4.6 ms 64
repeated real ENOENT lines 0.1 0.4 1.4 6.1 ms 49

Linear everywhere, 8.8 ms worst case on half a megabyte.

Suggested fix: mask first, truncate the masked result. A whitespace-boundary cut is not a sufficient substitute — an unterminated quoted value containing spaces still loses its closing quote. If you want to keep an input cap for defence in depth, run the three cheap credential rules on the full string first and truncate only before the path stack.

Two smaller consequences worth a line in the comment: two long SQL strings identical for their first 8,192 characters now produce identical masked text and therefore an identical errorHash, so sql-execute.ts:126 clusters them together (they hashed distinctly before); and a path lying past offset 8192 is silently dropped rather than masked.

Separately, mcp/index.ts:212 already has this shape — e.stderr.trim().slice(0, 500) masked afterwards — with the identical failure ("x"×485 + " sk-…"… sk-abcdefghijk sliced first, … sk-*** masked first). Pre-existing and not a blocker, but the fix is the same.

let out = s
// ANSI CSI sequences (colored subprocess stderr) would otherwise split
// tokens so neither credential nor path rules can see them
.replace(/\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g, "")
.replace(/sk-(?:ant-)?[A-Za-z0-9_-]{20,}/g, "sk-***")
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer ***")
// Fast path: a string with no separator cannot contain a path — skip the
// whole path stack (most telemetry strings carry no path at all).
if (out.includes("/") || out.includes("\\") || /(?<![A-Za-z0-9])[A-Za-z]:[^\s:]{1,255}(?: [^\s:]{1,255}){0,2}\.[A-Za-z]/.test(out)) {
out = out
// altimate_change start — mask filesystem paths in error text
// Six masking rules (cloud URIs, Windows home, Windows/UNC incl. .\ and
// ..\, POSIX home, POSIX incl. ./ and ../, ~ incl. ~username), composed from shared
// fragments below (PATH_RULES) — one source of truth after repeated
// lockstep edits drifted (see PR history). Ordered after the credential
// rules and BEFORE the email/internal-host rules so whole URIs mask
// before userinfo can fragment into <email>. Public URL interiors are
// structurally safe: after "https:" comes "//", which cannot start a
// segment chain. Doctrine: over-masking is the correct failure mode.
// HOME-ROOTED paths and CLOUD URIs consume one unconditional trailing
// word (spaced usernames / object keys — the high-PII classes),
// suppressed after a dotted extension so "x.sql was deleted" prose
// survives; other rules consume a trailing word only at end-of-string /
Comment thread
ralphstodomingo marked this conversation as resolved.
// before punctuation — except spaced terminal FILENAMES, which may span
// interior words when the run ends in a dotted extension (up to 4 words
// on deep paths, 12 on explicit shallow ./-style paths). Residue (by design): one prose word may be
// over-masked after extensionless home/cloud paths; a non-home,
// non-cloud path's terminal spaced component can leak ONE structure
// word mid-sentence (no personal names in that class); a delimiter
// followed by neither a further separator nor a dotted terminal
// filename is a permanent boundary.
.replace(/(^|[\s"'`=(,[{:;<|>)\]}&])[\\/]{4,}(?=$|[\s"'`,;)\]}<>|&])/g, "$1<path>")
.replace(PATH_RULES.cloud, "$1<path>")
// the windows rules carry the widest opener alternation — they cannot
// match without a backslash, a boundary drive-colon, or a non-scheme //
if (out.includes("\\") || /(?<![A-Za-z0-9])[A-Za-z]:/.test(out) || /(?<!:)\/\//.test(out)) {
out = out.replace(PATH_RULES.windowsHome, "$1<path>").replace(PATH_RULES.windows, "$1<path>")
}
out = out
.replace(PATH_RULES.posixHome, "$1<path>")
.replace(PATH_RULES.posix, "$1<path>")
.replace(PATH_RULES.tilde, "$1<path>")
for (const re of pmKnownPrefixes()) out = out.replace(re, "<path>")
out = out
// a literal-prefix mask followed by a structurally-masked remainder
// collapses to one marker
.replace(/<path>(?:[\\/]?<path>)+/g, "<path>")
}
return out
// altimate_change end
// Email addresses — providers occasionally echo caller identity in error text.
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
.replace(/(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
// Internal hostnames in URLs — keeps parity with `parseAPICallError`'s
// `maskInternalHost` so an error message containing the same URL doesn't
// leak through telemetry while metadata.url is masked. Covers:
Expand Down
Loading
Loading