From 07186b24cb9ce846f90a19f1e736e114a1e6caad Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 10 Nov 2026 23:15:11 -0500 Subject: [PATCH 1/4] fix(access): verify and name policy-sharing neighbours before removal --- .../modules/earn/access-policy/cli.test.ts | 84 ++++++++++++++++ deployments/modules/earn/access-policy/cli.ts | 95 ++++++++++++++----- .../earn/access-policy/membership.test.ts | 9 ++ .../modules/earn/access-policy/membership.ts | 13 ++- docs/earn/modules/access.md | 16 ++-- 5 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 deployments/modules/earn/access-policy/cli.test.ts diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts new file mode 100644 index 0000000..014c45d --- /dev/null +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import type { EarnStack } from '../../../core/stack.js' +import { policySiblingStacks } from './cli.js' + +const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48' + +/** A profile repository with the selected profile and the given neighbours. */ +function repository(neighbours: Record) { + const root = mkdtempSync(join(tmpdir(), 'access-siblings-')) + const write = (directory: string, id: string, profile: unknown, pending = false) => { + mkdirSync(join(directory, id), { recursive: true }) + writeFileSync(join(directory, id, 'profile.json'), JSON.stringify(profile)) + if (pending) writeFileSync(join(directory, id, 'deployment.pending.json'), '{}') + } + const profiles = join(root, 'profiles') + write(profiles, 'selected-moderato', authored('selected-moderato')) + for (const [id, { profile, pending }] of Object.entries(neighbours)) { + write(profiles, id, profile, pending) + } + return { root, profiles, selected: join(profiles, 'selected-moderato', 'profile.json') } +} + +function authored(id: string, policy = '469') { + return { + version: 1, + id, + chainId: 42431, + tip403PolicyId: policy, + engine: { kind: 'erc4626', venue }, + } +} + +const selected = { + id: 'selected-moderato', + network: { chainId: 42431 }, + access: { kind: 'tip403-whitelist', transferPolicyId: '469' }, +} as unknown as EarnStack +const genesis = `0x${'11'.repeat(32)}` + +describe('policy sibling discovery', () => { + it('stops on an unpublished deployment that shares the policy, naming it', async () => { + const { selected: path } = repository({ + '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/, + ) + }) + + it('ignores an unpublished deployment on 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('names an unparseable neighbour instead of failing anonymously', async () => { + const { selected: path } = repository({ + 'broken-moderato': { profile: { id: 'broken-moderato' }, pending: true }, + }) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile broken-moderato:/, + ) + }) + + it('follows a symlinked neighbour directory', async () => { + const { profiles, selected: path } = repository({}) + const elsewhere = mkdtempSync(join(tmpdir(), 'access-sibling-target-')) + mkdirSync(join(elsewhere, 'linked-moderato')) + writeFileSync( + join(elsewhere, 'linked-moderato', 'profile.json'), + JSON.stringify(authored('linked-moderato')), + ) + writeFileSync(join(elsewhere, 'linked-moderato', 'deployment.pending.json'), '{}') + 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 5323ce5..12f24d1 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -10,6 +10,9 @@ import { configuredTempoChain } from '../../../chain/tempo.js' import { loadDeploymentEnvironment, loadEarnProfile, + loadEarnProfileBundle, + loadReviewedDeploymentIdentity, + pendingDeploymentPath, profileDeploymentPath, profileRepositoryRoot, resolveProfilePath, @@ -82,7 +85,10 @@ async function access(argv: readonly string[], output: Pick/profile.json` entries in its - * parent directory) on the same chain that attaches the same TIP-403 policy, loaded live with its - * own `.env.`. A sibling's reviewed identity comes only from its own `.env.`, never from - * the shell, which names the selected profile's. A profile without a generated record has nothing - * deployed to protect; any other load failure stops the removal rather than skipping it. + * Every other deployed stack beside `profilePath` (each `/profile.json` in its parent + * directory, symlinked or not) on the same chain and genesis that attaches the same TIP-403 policy, + * loaded live. Sharing is decided from each neighbour's verified authored/generated pair, so a + * neighbour whose profile no longer matches its record, or that fails to load, stops the removal + * and is named. A neighbour with an unpublished deployment on this policy also stops it: its + * contracts may already be bound to the policy. Each neighbour runs with its own `.env.`, + * which alone supplies its reviewed identity and, when it sets any, its RPC settings. */ -async function policySiblingStacks(profilePath: string, stack: EarnStack) { +export async function policySiblingStacks( + profilePath: string, + stack: EarnStack, + genesisHash: string, +) { if (stack.access.kind !== 'tip403-whitelist') return [] const root = profileRepositoryRoot(profilePath) - const profilesDirectory = dirname(dirname(profilePath)) + const directory = dirname(dirname(profilePath)) const policyId = BigInt(stack.access.transferPolicyId) const siblings: EarnStack[] = [] - for (const entry of readdirSync(profilesDirectory, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const path = join(profilesDirectory, entry.name, 'profile.json') - if (path === profilePath || !existsSync(path) || !existsSync(profileDeploymentPath(path))) { - continue + for (const name of readdirSync(directory).sort()) { + const path = join(directory, name, 'profile.json') + if (path === profilePath || !existsSync(path)) continue + 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) + throw new Error(`Cannot check removal safety against profile ${name}: ${detail}`, { + cause: reason, + }) } - const profile = loadEarnProfile(path) - if (profile.id === stack.id || profile.chainId !== stack.network.chainId) continue - if (transferPolicyId(profile) !== policyId) continue try { - const values = loadDeploymentEnvironment(root, profile.id, process.env) - const own = loadDeploymentEnvironment(root, profile.id, {}) - const siblingValues = { - ...values, - EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, + 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 } - siblings.push((await loadHostedProfileStack(path, siblingValues)).stack) + const values = siblingEnvironment(root, authored.id) + const identityPath = values.EARN_TRUSTED_DEPLOYMENT_IDENTITY?.trim() + const identity = identityPath + ? loadReviewedDeploymentIdentity(resolveProfilePath(identityPath)) + : 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) { - throw new Error( - `Cannot check removal safety for ${profile.id}, which shares policy ${policyId}`, - { cause: error }, - ) + if (error instanceof Error && error.message.startsWith('Cannot check removal safety')) { + throw error + } + fail(error) } } return siblings } +const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const + +/** + * A neighbour's runtime values: its own `.env.` wins over the shell. Its reviewed identity + * comes only from that file, and RPC settings move as a group, so the shell's endpoint never + * receives the neighbour's credentials. + */ +function siblingEnvironment(root: string, profileId: string) { + const own = loadDeploymentEnvironment(root, profileId, {}) + const values: Record = { + ...loadDeploymentEnvironment(root, profileId, process.env), + ...own, + EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, + } + if (rpcKeys.some((key) => own[key])) for (const key of rpcKeys) values[key] = own[key] + return values +} + /** * The access administrator key from the profile's runtime config. Like hosted E2E, an unset * `EARN_ACCESS_ADMIN_PRIVATE_KEY` falls back to `DEPLOYER_PRIVATE_KEY`, announced on stderr so the diff --git a/deployments/modules/earn/access-policy/membership.test.ts b/deployments/modules/earn/access-policy/membership.test.ts index 7d7263f..6e6e1ec 100644 --- a/deployments/modules/earn/access-policy/membership.test.ts +++ b/deployments/modules/earn/access-policy/membership.test.ts @@ -240,6 +240,15 @@ describe('access removal across stacks sharing a policy', () => { expect(client.getLogs).toHaveBeenCalledTimes(1) }) + it('names the sibling stack that blocks a removal', async () => { + const sibling = siblingDeployment() + await expect( + assertAccessRemovalSafe(removalClient(), protectedDeployment(), sibling.contracts.earnVault, [ + sibling, + ]), + ).rejects.toThrow(/for stack sibling, EarnVault 0x0+13, which shares policy 469/) + }) + it('refuses before signing when only the sibling needs the target', async () => { const sibling = siblingDeployment() const request = vi.fn(async () => { diff --git a/deployments/modules/earn/access-policy/membership.ts b/deployments/modules/earn/access-policy/membership.ts index 0794d71..f69fa29 100644 --- a/deployments/modules/earn/access-policy/membership.ts +++ b/deployments/modules/earn/access-policy/membership.ts @@ -156,8 +156,17 @@ export async function assertAccessRemovalSafe( siblings: readonly EarnStack[] = [], ) { const { access } = requireProtectedAccess(deployment) - for (const stack of [deployment, ...sharingPolicy(deployment, access, siblings)]) { - await assertStackRemovalSafe(client, stack, target) + await assertStackRemovalSafe(client, deployment, target) + for (const stack of sharingPolicy(deployment, access, siblings)) { + try { + await assertStackRemovalSafe(client, stack, target) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `${detail} (for stack ${stack.id}, EarnVault ${stack.contracts.earnVault}, which shares policy ${access.transferPolicyId})`, + { cause: error }, + ) + } } } diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index 1474dcd..9defdf7 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -75,13 +75,15 @@ virtual aliases for both required members and receivers. An unregistered claim r policy identity and does not block unrelated removals; registry failures still stop the command. 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 with a generated -`deployment.json`) whose chain and `tip403PolicyId` match, each loaded with its own `.env.`, -which alone supplies its `EARN_TRUSTED_DEPLOYMENT_IDENTITY`; a sibling that fails to load stops -the command. Stacks attaching the policy from profiles kept elsewhere are not discovered. -The scan requires a valid deployment-block anchor, reads request and closure events in bounded -block ranges, and checks only outstanding claims, with at most 16 concurrent reads. Each change is -simulated, sent, and read back against the verified stack and policy bindings. +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 sets any. 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 +redeployment that is still bound to the policy) are not discovered; check them before removing a +member they may need. ## Tests From 3a2eca5269b6e8c426f2b9652a4bbdf980defdc0 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 10 Nov 2026 23:23:41 -0500 Subject: [PATCH 2/4] fix(access): pre-filter neighbours by record target, dedupe aliases, keep RPC groups whole --- .../modules/earn/access-policy/cli.test.ts | 71 ++++++++++++++++++- deployments/modules/earn/access-policy/cli.ts | 44 +++++++++--- docs/earn/modules/access.md | 2 +- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index 014c45d..3f6fc56 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -1,10 +1,10 @@ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { EarnStack } from '../../../core/stack.js' -import { policySiblingStacks } from './cli.js' +import { policySiblingStacks, siblingEnvironment } from './cli.js' const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48' @@ -81,4 +81,71 @@ describe('policy sibling discovery', () => { /profile linked-moderato: it has an unpublished deployment/, ) }) + + it('counts a symlink alias of the selected profile or a neighbour once', async () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato'), pending: true }, + }) + symlinkSync(join(profiles, 'selected-moderato'), join(profiles, 'latest')) + symlinkSync(join(profiles, 'other-moderato'), join(profiles, 'zz-alias')) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato: it has an unpublished deployment/, + ) + }) + + it('skips a recorded neighbour on another chain without loading it strictly', async () => { + const { profiles, selected: path } = repository({ + 'other-mainnet': { profile: { ...authored('other-mainnet'), chainId: 4217 } }, + }) + writeFileSync( + join(profiles, 'other-mainnet', 'deployment.json'), + JSON.stringify({ target: { chainId: 4217, genesisHash: `0x${'22'.repeat(32)}` } }), + ) + await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) + }) + + 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') }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ target: { chainId: 42431, genesisHash: genesis } }), + ) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato:/, + ) + }) +}) + +describe('sibling environment', () => { + afterEach(() => vi.unstubAllEnvs()) + + it.each([ + ['a blank URL', 'TEMPO_RPC_URL=\n'], + ['only a URL', 'TEMPO_RPC_URL=https://sibling.example\n'], + ])('takes the whole RPC group from a file naming %s', (_, file) => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), file) + vi.stubEnv('TEMPO_RPC_URL', 'https://shell.example') + vi.stubEnv('TEMPO_RPC_USERNAME', 'shell-user') + vi.stubEnv('TEMPO_RPC_PASSWORD', 'shell-pass') + const values = siblingEnvironment(root, 'other-moderato') + expect(values.TEMPO_RPC_USERNAME).toBeUndefined() + expect(values.TEMPO_RPC_PASSWORD).toBeUndefined() + expect(values.TEMPO_RPC_URL).not.toBe('https://shell.example') + }) + + it('keeps the shell RPC group when the file names none of it', () => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), 'OTHER=1\n') + vi.stubEnv('TEMPO_RPC_URL', 'https://shell.example') + vi.stubEnv('TEMPO_RPC_USERNAME', 'shell-user') + vi.stubEnv('TEMPO_RPC_PASSWORD', 'shell-pass') + expect(siblingEnvironment(root, 'other-moderato')).toMatchObject({ + TEMPO_RPC_URL: 'https://shell.example', + TEMPO_RPC_USERNAME: 'shell-user', + TEMPO_RPC_PASSWORD: 'shell-pass', + }) + }) }) diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index 12f24d1..d44ba7b 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' @@ -120,9 +120,15 @@ export async function policySiblingStacks( const directory = dirname(dirname(profilePath)) const policyId = BigInt(stack.access.transferPolicyId) const siblings: EarnStack[] = [] + // Entries resolve through symlinks; aliases of a stack, including the selected one, count once. + const seen = new Set([realpathSync(dirname(profilePath))]) for (const name of readdirSync(directory).sort()) { - const path = join(directory, name, 'profile.json') - if (path === profilePath || !existsSync(path)) continue + const entry = join(directory, name) + if (!existsSync(join(entry, 'profile.json'))) continue + const real = realpathSync(entry) + if (seen.has(real)) continue + seen.add(real) + const path = join(real, 'profile.json') const recorded = existsSync(profileDeploymentPath(path)) if (!recorded && !existsSync(pendingDeploymentPath(path))) continue const fail = (reason: unknown): never => { @@ -140,6 +146,8 @@ export async function policySiblingStacks( } continue } + // Only a record on this chain and genesis can share the policy; others need no strict load. + if (!sameTarget(path, stack.network.chainId, genesisHash)) continue const values = siblingEnvironment(root, authored.id) const identityPath = values.EARN_TRUSTED_DEPLOYMENT_IDENTITY?.trim() const identity = identityPath @@ -160,21 +168,41 @@ export async function policySiblingStacks( return siblings } +/** + * Whether a generated record targets this chain and genesis, read before the strict load so an + * unrelated neighbour never blocks a removal. An unreadable target counts as possibly the same. + */ +function sameTarget(profilePath: string, chainId: number, genesisHash: string) { + try { + const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { + target?: { chainId?: unknown; genesisHash?: unknown } + } + if (typeof target?.chainId !== 'number' || typeof target.genesisHash !== 'string') return true + return ( + target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase() + ) + } catch { + return true + } +} + const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const /** * A neighbour's runtime values: its own `.env.` wins over the shell. Its reviewed identity - * comes only from that file, and RPC settings move as a group, so the shell's endpoint never - * receives the neighbour's credentials. + * comes only from that file, and RPC settings move as a group: all from the file when it names any + * of them, even blank, else all from the shell, so credentials never reach another endpoint. */ -function siblingEnvironment(root: string, profileId: string) { +export function siblingEnvironment(root: string, profileId: string) { const own = loadDeploymentEnvironment(root, profileId, {}) + const shell = loadDeploymentEnvironment(root, profileId, process.env) const values: Record = { - ...loadDeploymentEnvironment(root, profileId, process.env), + ...shell, ...own, EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, } - if (rpcKeys.some((key) => own[key])) for (const key of rpcKeys) values[key] = own[key] + const fromFile = rpcKeys.some((key) => key in own) + for (const key of rpcKeys) values[key] = fromFile ? own[key] : shell[key] return values } diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index 9defdf7..cd1149d 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -78,7 +78,7 @@ removal applies to all of them. `remove` therefore runs these checks for every d 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 sets any. A +`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 From 9708368b3d66cd82580be18e282579dc9faa0f77 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 10 Nov 2026 23:31:35 -0500 Subject: [PATCH 3/4] fix(access): strictly load neighbours with malformed or inconsistent record targets --- .../modules/earn/access-policy/cli.test.ts | 21 +++++++++++++++++++ deployments/modules/earn/access-policy/cli.ts | 20 +++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index 3f6fc56..bfd8715 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -104,6 +104,27 @@ describe('policy sibling discovery', () => { await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) }) + it.each([ + ['an empty genesis hash', { chainId: 42431, genesisHash: '' }, 42431], + ['a truncated genesis hash', { chainId: 42431, genesisHash: '0x1234' }, 42431], + ['a chain that disagrees with its profile', { chainId: 4217, genesisHash: 'G' }, 42431], + ])('strictly loads a neighbour whose record has %s', async (_, target, authoredChain) => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: { ...authored('other-moderato'), chainId: authoredChain } }, + }) + const record = { + ...target, + genesisHash: target.genesisHash === 'G' ? '0x' + '22'.repeat(32) : target.genesisHash, + } + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ target: record }), + ) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato:/, + ) + }) + 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') }, diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index d44ba7b..e473115 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -147,7 +147,7 @@ export async function policySiblingStacks( continue } // Only a record on this chain and genesis can share the policy; others need no strict load. - if (!sameTarget(path, stack.network.chainId, genesisHash)) continue + 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 @@ -169,15 +169,25 @@ export async function policySiblingStacks( } /** - * Whether a generated record targets this chain and genesis, read before the strict load so an - * unrelated neighbour never blocks a removal. An unreadable target counts as possibly the same. + * 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. */ -function sameTarget(profilePath: string, chainId: number, genesisHash: string) { +function sameTarget( + profilePath: string, + authoredChainId: number, + chainId: number, + genesisHash: string, +) { try { const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { target?: { chainId?: unknown; genesisHash?: unknown } } - if (typeof target?.chainId !== 'number' || typeof target.genesisHash !== 'string') return true + 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 ( target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase() ) From 486c97706ea40ec591ffabfea0807e0d045b7994 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 10 Nov 2026 23:38:39 -0500 Subject: [PATCH 4/4] fix(access): refuse sibling RPC credentials without a sibling RPC URL --- .../modules/earn/access-policy/cli.test.ts | 15 +++++++++++++++ deployments/modules/earn/access-policy/cli.ts | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts index bfd8715..a3840e3 100644 --- a/deployments/modules/earn/access-policy/cli.test.ts +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -157,6 +157,21 @@ describe('sibling environment', () => { expect(values.TEMPO_RPC_URL).not.toBe('https://shell.example') }) + it.each([ + ['credentials only', 'TEMPO_RPC_USERNAME=u\nTEMPO_RPC_PASSWORD=p\n'], + [ + 'a blank URL with credentials', + 'TEMPO_RPC_URL=\nTEMPO_RPC_USERNAME=u\nTEMPO_RPC_PASSWORD=p\n', + ], + ])('refuses a file with %s', (_, file) => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), file) + vi.stubEnv('TEMPO_RPC_URL', 'https://private.example') + expect(() => siblingEnvironment(root, 'other-moderato')).toThrow( + 'sets TEMPO_RPC credentials without TEMPO_RPC_URL', + ) + }) + it('keeps the shell RPC group when the file names none of it', () => { const { root } = repository({}) writeFileSync(join(root, '.env.other-moderato'), 'OTHER=1\n') diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index e473115..4616282 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -201,7 +201,8 @@ const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as /** * A neighbour's runtime values: its own `.env.` wins over the shell. Its reviewed identity * comes only from that file, and RPC settings move as a group: all from the file when it names any - * of them, even blank, else all from the shell, so credentials never reach another endpoint. + * of them, even blank, else all from the shell, so credentials never reach another endpoint. File + * credentials without a file URL are refused rather than sent to the public fallback. */ export function siblingEnvironment(root: string, profileId: string) { const own = loadDeploymentEnvironment(root, profileId, {}) @@ -212,6 +213,16 @@ export function siblingEnvironment(root: string, profileId: string) { EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, } const fromFile = rpcKeys.some((key) => key in own) + if ( + fromFile && + !own.TEMPO_RPC_URL?.trim() && + (own.TEMPO_RPC_USERNAME || own.TEMPO_RPC_PASSWORD) + ) { + // Without its own URL the hosted public endpoint would receive these private credentials. + throw new Error( + `.env.${profileId} sets TEMPO_RPC credentials without TEMPO_RPC_URL; set the URL there too`, + ) + } for (const key of rpcKeys) values[key] = fromFile ? own[key] : shell[key] return values }