From a5037986b07992a5cd35970e3ff44f03633177a7 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 11 Nov 2026 00:25:24 -0500 Subject: [PATCH 1/5] fix(access): probe other-genesis neighbours, stop on pending ones, redact URLs --- .../modules/earn/access-policy/cli.test.ts | 60 ++++++++++++- deployments/modules/earn/access-policy/cli.ts | 89 +++++++++++++------ docs/earn/modules/access.md | 14 +-- 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index a3840e3..7161ea9 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { EarnStack } from '../../../core/stack.js' -import { policySiblingStacks, siblingEnvironment } from './cli.js' +import { policySiblingStacks, redactUrls, siblingEnvironment } from './cli.js' const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48' @@ -47,15 +47,17 @@ describe('policy sibling discovery', () => { 'other-moderato': { profile: authored('other-moderato'), pending: true }, }) await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( - /profile other-moderato: it has an unpublished deployment on policy 469/, + /profile other-moderato: it has an unpublished deployment/, ) }) - it('ignores an unpublished deployment on another policy', async () => { + it('stops on any unpublished deployment, whatever its authored policy', async () => { const { selected: path } = repository({ 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, }) - await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato: it has an unpublished deployment/, + ) }) it('names an unparseable neighbour instead of failing anonymously', async () => { @@ -125,6 +127,44 @@ describe('policy sibling discovery', () => { ) }) + describe('a recorded neighbour naming another genesis of this chain', () => { + const earnShare = '0x00000000000000000000000000000000000000E5' + const setup = () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato') }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ + target: { chainId: 42431, genesisHash: '0x' + '33'.repeat(32) }, + addresses: { earnShare }, + }), + ) + return path + } + + it('is skipped when its EarnShare has no code here', async () => { + const getCode = vi.fn(async () => undefined) + await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).resolves.toEqual( + [], + ) + expect(getCode).toHaveBeenCalledWith({ address: earnShare }) + }) + + it('stops the removal when its EarnShare has code here', async () => { + const getCode = vi.fn(async () => '0x6080' as const) + await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).rejects.toThrow( + /profile other-moderato: its record names genesis 0x3333.*may be live on this chain/, + ) + }) + + it('stops the removal when liveness cannot be checked', async () => { + await expect(policySiblingStacks(setup(), selected, genesis)).rejects.toThrow( + /may be live on this chain/, + ) + }) + }) + it('strictly loads a recorded neighbour on the same chain and genesis', async () => { const { profiles, selected: path } = repository({ 'other-moderato': { profile: authored('other-moderato', '470') }, @@ -185,3 +225,15 @@ describe('sibling environment', () => { }) }) }) + +describe('neighbour failure messages', () => { + it('keep only the origin of each URL', () => { + expect( + redactUrls( + 'HTTP request failed. URL: https://rpc.example/v2/sk_live_123?key=abc Details: x (https://rpc.example)', + ), + ).toBe( + 'HTTP request failed. URL: https://rpc.example/[redacted] Details: x (https://rpc.example)', + ) + }) +}) diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index 4616282..ac8974b 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' -import { getAddress } from 'viem' +import { getAddress, isAddress, type Address, type PublicClient } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { createClient } from 'viem/tempo' @@ -87,7 +87,7 @@ async function access(argv: readonly string[], output: Pick`, - * which alone supplies its reviewed identity and, when it sets any, its RPC settings. + * and is named. Any neighbour with only an unpublished deployment also stops it: its contracts + * may already be bound to the policy, and its pending state cannot confirm which. A record naming + * another genesis excludes a neighbour only when its EarnShare has no code on this chain. Each + * neighbour runs with its own `.env.`, which alone supplies its reviewed identity and, when it + * names any, its RPC settings. */ export async function policySiblingStacks( profilePath: string, stack: EarnStack, genesisHash: string, + client?: Pick, ) { if (stack.access.kind !== 'tip403-whitelist') return [] const root = profileRepositoryRoot(profilePath) @@ -132,7 +135,7 @@ export async function policySiblingStacks( const recorded = existsSync(profileDeploymentPath(path)) if (!recorded && !existsSync(pendingDeploymentPath(path))) continue const fail = (reason: unknown): never => { - const detail = reason instanceof Error ? reason.message : String(reason) + const detail = redactUrls(reason instanceof Error ? reason.message : String(reason)) throw new Error(`Cannot check removal safety against profile ${name}: ${detail}`, { cause: reason, }) @@ -141,13 +144,21 @@ export async function policySiblingStacks( const authored = loadEarnProfile(path) if (authored.id === stack.id) continue if (!recorded) { - if (authored.chainId === stack.network.chainId && transferPolicyId(authored) === policyId) { - fail(`it has an unpublished deployment on policy ${policyId}; publish its record first`) - } - continue + fail( + 'it has an unpublished deployment; publish its record or remove its pending state first', + ) + } + // Only a record that may target this chain can share the policy; others need no strict load. + const target = recordTarget(path, authored.chainId, stack.network.chainId, genesisHash) + if (target.kind === 'other-chain') continue + if (target.kind === 'other-genesis') { + // A wrong genesis must not hide a live stack: skip only when its EarnShare is not here. + const code = client && (await client.getCode({ address: target.earnShare })) + if (client && (!code || code === '0x')) continue + fail( + `its record names genesis ${target.genesisHash}, but its EarnShare may be live on this chain`, + ) } - // Only a record on this chain and genesis can share the policy; others need no strict load. - if (!sameTarget(path, authored.chainId, stack.network.chainId, genesisHash)) continue const values = siblingEnvironment(root, authored.id) const identityPath = values.EARN_TRUSTED_DEPLOYMENT_IDENTITY?.trim() const identity = identityPath @@ -155,7 +166,6 @@ export async function policySiblingStacks( : undefined const { profile, record } = loadEarnProfileBundle(path, identity) if (record.target.chainId !== stack.network.chainId) continue - if (record.target.genesisHash.toLowerCase() !== genesisHash.toLowerCase()) continue if (transferPolicyId(profile) !== policyId) continue siblings.push((await loadHostedProfileStack(path, values, identity)).stack) } catch (error) { @@ -169,33 +179,60 @@ export async function policySiblingStacks( } /** - * Whether a generated record may target this chain and genesis, read before the strict load so an - * unrelated neighbour never blocks a removal. Only a well-formed target that agrees with its - * authored chain excludes a neighbour; anything malformed or inconsistent counts as possibly the - * same, so the strict load rejects it. + * Classifies a generated record's target before the strict load, so a neighbour on another chain + * never blocks a removal. Only a well-formed target that agrees with its authored chain is trusted + * to differ; anything malformed or inconsistent counts as possibly the same, so the strict load + * rejects it. */ -function sameTarget( +function recordTarget( profilePath: string, authoredChainId: number, chainId: number, genesisHash: string, -) { +): + | { kind: 'possibly-same' } + | { kind: 'other-chain' } + | { kind: 'other-genesis'; genesisHash: string; earnShare: Address } { try { - const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { + const record = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { target?: { chainId?: unknown; genesisHash?: unknown } + addresses?: { earnShare?: unknown } + } + const target = record.target + if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) { + return { kind: 'possibly-same' } } - if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) return true if (typeof target.genesisHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(target.genesisHash)) { - return true + return { kind: 'possibly-same' } + } + if (target.chainId !== chainId) return { kind: 'other-chain' } + if (target.genesisHash.toLowerCase() === genesisHash.toLowerCase()) { + return { kind: 'possibly-same' } + } + const earnShare = record.addresses?.earnShare + if (typeof earnShare !== 'string' || !isAddress(earnShare)) return { kind: 'possibly-same' } + return { + kind: 'other-genesis', + genesisHash: target.genesisHash, + earnShare: getAddress(earnShare), } - return ( - target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase() - ) } catch { - return true + return { kind: 'possibly-same' } } } +/** Keeps only each URL's origin, so RPC paths and query tokens never reach the terminal. */ +export function redactUrls(message: string) { + return message.replace(/https?:\/\/[^\s"'<>)]+/g, (match) => { + try { + const url = new URL(match) + return url.pathname === '/' && !url.search ? url.origin : `${url.origin}/[redacted]` + } catch { + return '[redacted URL]' + } + }) +} + const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const /** diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index cd1149d..a47e56e 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -76,12 +76,14 @@ policy identity and does not block unrelated removals; registry failures still s A TIP-403 policy can be shared: several stacks may attach the same policy on one chain, and a removal applies to all of them. `remove` therefore runs these checks for every deployed profile in the same profiles directory (each `/profile.json` beside the selected one, symlinked or not) -whose verified profile and record match the selected chain, genesis, and `tip403PolicyId`. Each -runs with its own `.env.`, which wins over the shell, alone supplies its -`EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of them. A -refusal names the stack that needs the target. Any neighbour with a record that fails to load, and -any neighbour with an unpublished deployment on the same policy, stops the command and is named. -Stacks without a record beside a profile (one kept elsewhere, or an earlier version superseded by a +whose verified profile and record match the selected chain and `tip403PolicyId`. A record naming +another genesis of the same chain is skipped only when its EarnShare has no code on the selected +chain. Each runs with its own `.env.`, which wins over the shell, alone supplies its +`EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of +them; credentials without a URL there are refused. A refusal names the stack that needs the target. +Any neighbour with a record that fails to load, and any neighbour with only an unpublished +deployment, stops the command and is named; failure messages keep only each URL's origin. Stacks +without a record beside a profile (one kept elsewhere, or an earlier version superseded by a redeployment that is still bound to the policy) are not discovered; check them before removing a member they may need. From 43452962dcbc67ddeb188e3a9f62f4c35110fd0a Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 11 Nov 2026 00:33:55 -0500 Subject: [PATCH 2/5] fix(access): bind pending neighbours to their intent before deciding they share the policy --- .../modules/earn/access-policy/cli.test.ts | 50 ++++++++++++++++--- deployments/modules/earn/access-policy/cli.ts | 15 ++++-- docs/earn/modules/access.md | 3 +- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index 7161ea9..eb6965a 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -1,8 +1,11 @@ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadEarnProfile } from '../../../core/bundle.js' +import { deploymentIntentHash } from '../../../core/profile.js' +import { resolveEarnFactoryRelease } from '../../../core/releases.js' import type { EarnStack } from '../../../core/stack.js' import { policySiblingStacks, redactUrls, siblingEnvironment } from './cli.js' @@ -14,7 +17,7 @@ function repository(neighbours: Record { mkdirSync(join(directory, id), { recursive: true }) writeFileSync(join(directory, id, 'profile.json'), JSON.stringify(profile)) - if (pending) writeFileSync(join(directory, id, 'deployment.pending.json'), '{}') + if (pending) writePending(join(directory, id)) } const profiles = join(root, 'profiles') write(profiles, 'selected-moderato', authored('selected-moderato')) @@ -24,6 +27,29 @@ function repository(neighbours: Record { ) }) - it('stops on any unpublished deployment, whatever its authored policy', async () => { + it('ignores a pending deployment bound to another policy', async () => { const { selected: path } = repository({ 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, }) + await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) + }) + + it('stops when a pending deployment no longer matches its edited profile', async () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'profile.json'), + JSON.stringify(authored('other-moderato', '471')), + ) await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( - /profile other-moderato: it has an unpublished deployment/, + /profile other-moderato: Pending deployment .* does not match this profile/, ) }) it('names an unparseable neighbour instead of failing anonymously', async () => { const { selected: path } = repository({ - 'broken-moderato': { profile: { id: 'broken-moderato' }, pending: true }, + 'broken-moderato': { profile: { id: 'broken-moderato' } }, }) + writeFileSync(join(dirname(path), '..', 'broken-moderato', 'deployment.pending.json'), '{}') await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( /profile broken-moderato:/, ) @@ -77,7 +115,7 @@ describe('policy sibling discovery', () => { join(elsewhere, 'linked-moderato', 'profile.json'), JSON.stringify(authored('linked-moderato')), ) - writeFileSync(join(elsewhere, 'linked-moderato', 'deployment.pending.json'), '{}') + writePending(join(elsewhere, 'linked-moderato')) symlinkSync(join(elsewhere, 'linked-moderato'), join(profiles, 'linked-moderato')) await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( /profile linked-moderato: it has an unpublished deployment/, diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index ac8974b..a32842c 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -11,6 +11,7 @@ import { loadDeploymentEnvironment, loadEarnProfile, loadEarnProfileBundle, + loadEarnProfileForRead, loadReviewedDeploymentIdentity, pendingDeploymentPath, profileDeploymentPath, @@ -106,8 +107,9 @@ async function access(argv: readonly string[], output: Pick`, which alone supplies its reviewed identity and, when it * names any, its RPC settings. @@ -144,9 +146,12 @@ export async function policySiblingStacks( const authored = loadEarnProfile(path) if (authored.id === stack.id) continue if (!recorded) { - fail( - 'it has an unpublished deployment; publish its record or remove its pending state first', - ) + // Bind the authored profile to its pending intent first, so an edited profile cannot hide it. + const pending = loadEarnProfileForRead(path) + if (pending.chainId === stack.network.chainId && transferPolicyId(pending) === policyId) { + fail(`it has an unpublished deployment on policy ${policyId}; publish its record first`) + } + continue } // Only a record that may target this chain can share the policy; others need no strict load. const target = recordTarget(path, authored.chainId, stack.network.chainId, genesisHash) diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index a47e56e..987eedc 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -81,8 +81,7 @@ another genesis of the same chain is skipped only when its EarnShare has no code chain. Each runs with its own `.env.`, which wins over the shell, alone supplies its `EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of them; credentials without a URL there are refused. A refusal names the stack that needs the target. -Any neighbour with a record that fails to load, and any neighbour with only an unpublished -deployment, stops the command and is named; failure messages keep only each URL's origin. Stacks +Any neighbour with a record that fails to load, and any neighbour with only an unpublished deployment that, bound to its profile through its pending intent hash, targets this chain and policy (or cannot be bound), stops the command and is named; failure messages keep only each URL's origin. Stacks without a record beside a profile (one kept elsewhere, or an earlier version superseded by a redeployment that is still bound to the policy) are not discovered; check them before removing a member they may need. From f1e510c6695524303f5c2361e7031295424a2ed1 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 11 Nov 2026 00:42:01 -0500 Subject: [PATCH 3/5] fix(access): redact the whole URL token, including parenthesized paths --- deployments/modules/earn/access-policy/cli.test.ts | 6 ++++++ deployments/modules/earn/access-policy/cli.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index eb6965a..0130992 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -265,6 +265,12 @@ describe('sibling environment', () => { }) describe('neighbour failure messages', () => { + it('redact a whole URL whose path contains parentheses', () => { + expect( + redactUrls('failed: https://rpc.example/(v2)/fake-api-secret?key=fake-query-secret.'), + ).toBe('failed: https://rpc.example/[redacted].') + }) + it('keep only the origin of each URL', () => { expect( redactUrls( diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index a32842c..0a53195 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -228,12 +228,17 @@ function recordTarget( /** Keeps only each URL's origin, so RPC paths and query tokens never reach the terminal. */ export function redactUrls(message: string) { - return message.replace(/https?:\/\/[^\s"'<>)]+/g, (match) => { + // Take the whole token, since parentheses and other punctuation are valid in paths, and keep + // only trailing sentence punctuation outside the redacted URL. + return message.replace(/https?:\/\/[^\s"'<>]+/g, (token) => { + const trailing = /[).,;:!?]+$/.exec(token)?.[0] ?? '' + const candidate = token.slice(0, token.length - trailing.length) try { - const url = new URL(match) - return url.pathname === '/' && !url.search ? url.origin : `${url.origin}/[redacted]` + const url = new URL(candidate) + const kept = candidate === url.origin || candidate === `${url.origin}/` + return (kept ? url.origin : `${url.origin}/[redacted]`) + trailing } catch { - return '[redacted URL]' + return '[redacted URL]' + trailing } }) } From b902b963c11cd60b7dbb9e1c7e3ad4b20ff3d1a1 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 11 Nov 2026 00:50:49 -0500 Subject: [PATCH 4/5] fix(access): strip trailing URL punctuation in linear time --- deployments/modules/earn/access-policy/cli.test.ts | 9 +++++++++ deployments/modules/earn/access-policy/cli.ts | 7 +++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index 0130992..7b73760 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -271,6 +271,15 @@ describe('neighbour failure messages', () => { ).toBe('failed: https://rpc.example/[redacted].') }) + it('handle a long punctuation run in linear time', () => { + const run = '.'.repeat(200_000) + const started = performance.now() + expect(redactUrls(`https://rpc.example/${run}x${run}`)).toBe( + `https://rpc.example/[redacted]${run}`, + ) + expect(performance.now() - started).toBeLessThan(1_000) + }) + it('keep only the origin of each URL', () => { expect( redactUrls( diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index 0a53195..b255ecf 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -231,8 +231,11 @@ export function redactUrls(message: string) { // Take the whole token, since parentheses and other punctuation are valid in paths, and keep // only trailing sentence punctuation outside the redacted URL. return message.replace(/https?:\/\/[^\s"'<>]+/g, (token) => { - const trailing = /[).,;:!?]+$/.exec(token)?.[0] ?? '' - const candidate = token.slice(0, token.length - trailing.length) + // Scan back from the end: linear, unlike an unanchored trailing-run regex. + let end = token.length + while (end > 0 && ').,;:!?'.includes(token[end - 1]!)) end-- + const trailing = token.slice(end) + const candidate = token.slice(0, end) try { const url = new URL(candidate) const kept = candidate === url.origin || candidate === `${url.origin}/` From 884a41170f58fc75870278b1adb322970d24c163 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 11 Nov 2026 00:52:42 -0500 Subject: [PATCH 5/5] fix(access): redact URLs whose credentials contain quotes or angle brackets --- deployments/modules/earn/access-policy/cli.test.ts | 13 +++++++++++++ deployments/modules/earn/access-policy/cli.ts | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index 7b73760..71ff097 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -271,6 +271,19 @@ describe('neighbour failure messages', () => { ).toBe('failed: https://rpc.example/[redacted].') }) + it('redact a whole URL whose credentials contain quotes or angle brackets', () => { + for (const secret of ["pa'ss", 'pa"ss', 'pass']) { + const out = redactUrls(`failed: https://user:${secret}@rpc.example/v2 next`) + expect(out).toBe('failed: https://rpc.example/[redacted] next') + } + }) + + it('keep quotes and angle brackets around a URL outside it', () => { + expect(redactUrls('url "https://rpc.example/v2/key" or .')).toBe( + 'url "https://rpc.example/[redacted]" or .', + ) + }) + it('handle a long punctuation run in linear time', () => { const run = '.'.repeat(200_000) const started = performance.now() diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index b255ecf..2ae4b48 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -228,12 +228,12 @@ function recordTarget( /** Keeps only each URL's origin, so RPC paths and query tokens never reach the terminal. */ export function redactUrls(message: string) { - // Take the whole token, since parentheses and other punctuation are valid in paths, and keep - // only trailing sentence punctuation outside the redacted URL. - return message.replace(/https?:\/\/[^\s"'<>]+/g, (token) => { + // Take the whole non-space token, since quotes, brackets and other punctuation are valid in + // paths and credentials, and keep only trailing punctuation outside the redacted URL. + return message.replace(/https?:\/\/\S+/g, (token) => { // Scan back from the end: linear, unlike an unanchored trailing-run regex. let end = token.length - while (end > 0 && ').,;:!?'.includes(token[end - 1]!)) end-- + while (end > 0 && `).,;:!?'"<>`.includes(token[end - 1]!)) end-- const trailing = token.slice(end) const candidate = token.slice(0, end) try {