From 07aeed8cc1a9b4187ad9cce1293369ac45afe206 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 23 Jul 2026 16:11:15 +0300 Subject: [PATCH 1/4] AIT-256: mask the email in login identity echoes The bootstrap instruction template renders its Expected-output line with a masked email (screen-recording safety) and relies on the CLI echoing the same masked form, so the paste-into-wrong-AI check compares like with like. The CLI printed the raw address, so every legitimate run mismatched the expected output and agents learned to rationalize past the check. - new src/output/mask.ts (displayEmail), byte-for-byte lockstep with the backend's maskEmail; unit-tested including the lockstep examples. - applied to all three human-readable echoes in login.ts (bootstrap-code login, replaced-session notice, email-OTP login). --json stays raw. - bootstrap tests updated to assert the masked contract. Pre-existing failures in cli-error-integration.test.ts (3) reproduce on clean origin/main and are unrelated. --- src/auth/__tests__/bootstrap.test.ts | 10 +++++----- src/auth/login.ts | 7 ++++--- src/output/__tests__/mask.test.ts | 17 +++++++++++++++++ src/output/mask.ts | 23 +++++++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 src/output/__tests__/mask.test.ts create mode 100644 src/output/mask.ts diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 9c20d3a..7906391 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -244,7 +244,7 @@ describe('hookmyapp login --code', () => { // Identity echo present in stdout. const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Logged in as info@ordvir\.com, workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, ); // runWizard was invoked — the /workspaces apiClient mock confirms it. @@ -399,10 +399,10 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Replaced previous session \(was: old@other\.com, workspace "Old Workspace"\)/, + /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com, workspace "Old Workspace"\)/, ); expect(out).toMatch( - /Logged in as info@ordvir\.com, workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, ); // "was:" MUST appear before the "Logged in as" line. const wasIdx = out.search(/Replaced previous session/); @@ -440,7 +440,7 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toMatch(/Replaced previous session/); - expect(out).toMatch(/Logged in as info@ordvir\.com/); + expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com/); logSpy.mockRestore(); }); @@ -582,7 +582,7 @@ describe('hookmyapp login --code', () => { // literal "Logged in as", email, comma, space, workspace name in // double quotes. House style bans em-dashes in user-facing copy. expect(out).toMatch( - /\u2713.*Logged in as info@ordvir\.com, workspace "Or's Workspace"/, + /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, ); logSpy.mockRestore(); }); diff --git a/src/auth/login.ts b/src/auth/login.ts index d106601..96836c8 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -3,6 +3,7 @@ import { saveCredentials, peekIdentity } from './store.js'; import { AuthError, NetworkError, ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; import { c, icon } from '../output/color.js'; +import { displayEmail } from '../output/mask.js'; import { cliCommandPrefix } from '../output/cli-self.js'; import { getEffectiveApiUrl, @@ -468,11 +469,11 @@ export async function runBootstrapCodeExchange( (prior.email !== data.user.email || prior.workspaceSlug !== data.workspace.name) ) { console.log( - `${c.success(icon.success)} Replaced previous session (was: ${prior.email}, workspace "${prior.workspaceSlug}")`, + `${c.success(icon.success)} Replaced previous session (was: ${displayEmail(prior.email)}, workspace "${prior.workspaceSlug}")`, ); } console.log( - `${c.success(icon.success)} Logged in as ${data.user.email}, workspace "${data.workspace.name}"`, + `${c.success(icon.success)} Logged in as ${displayEmail(data.user.email)}, workspace "${data.workspace.name}"`, ); await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); @@ -598,7 +599,7 @@ async function persistAgentCredential( } const n = cred.scopes.length; console.log( - `${c.success(icon.success)} Logged in as ${email} (${n} scope${n === 1 ? '' : 's'})`, + `${c.success(icon.success)} Logged in as ${displayEmail(email)} (${n} scope${n === 1 ? '' : 's'})`, ); } diff --git a/src/output/__tests__/mask.test.ts b/src/output/__tests__/mask.test.ts new file mode 100644 index 0000000..bf5894c --- /dev/null +++ b/src/output/__tests__/mask.test.ts @@ -0,0 +1,17 @@ +import { describe, test, expect } from 'vitest'; +import { displayEmail } from '../mask.js'; + +// Lockstep contract: these expectations mirror maskEmail in the hookmyapp +// backend instruction template (AIT-256). If one side changes, both must. +describe('displayEmail', () => { + test('masks local part after 2 chars and domain to first char + tld', () => { + expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com'); + expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com'); + }); + + test('degrades safely on malformed input', () => { + expect(displayEmail('nodomain')).toBe('***'); + expect(displayEmail('@lead.com')).toBe('***'); + expect(displayEmail('a@b')).toBe('a***@b***'); + }); +}); diff --git a/src/output/mask.ts b/src/output/mask.ts new file mode 100644 index 0000000..ae5d17f --- /dev/null +++ b/src/output/mask.ts @@ -0,0 +1,23 @@ +/** + * Deterministic display mask for the login identity echo (AIT-256). + * + * MUST stay byte-for-byte in lockstep with `maskEmail` in the hookmyapp + * backend (`backend/src/auth/bootstrap/instruction-template.ts`): the + * bootstrap instruction block renders the Expected-output line masked, and + * the executing AI compares this CLI's echo against it. Same mask on both + * sides keeps the paste-into-wrong-AI safety net working — a different + * account still yields a different masked string — while the raw address + * never appears on screen (screen-recording safety). + * + * `--json` output is exempt: machine consumers get the raw email. + */ +export function displayEmail(email: string): string { + const at = email.indexOf('@'); + if (at <= 0) return '***'; + const local = email.slice(0, at); + const domain = email.slice(at + 1); + const lastDot = domain.lastIndexOf('.'); + const tld = lastDot > 0 ? domain.slice(lastDot) : ''; + const domainName = lastDot > 0 ? domain.slice(0, lastDot) : domain; + return `${local.slice(0, 2)}***@${domainName.charAt(0)}***${tld}`; +} From 9e44d2f5c0a4a1d35f6ef61541a859ef5d19e8a7 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 23 Jul 2026 16:51:19 +0300 Subject: [PATCH 2/4] AIT-256: collision-resistant discriminator + raw-email negative assertions (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: two accounts sharing the masked prefix (jo***@g***.com is common, and 'My Workspace' is the default workspace name) could collide their way past the wrong-person check. displayEmail now appends a non-reversible [xxxx] tag — first 4 hex of sha256 of the normalized address — kept in lockstep with the backend maskEmail. CodeRabbit: mask + bootstrap tests now also assert the raw addresses never appear in human-readable output. --- src/auth/__tests__/bootstrap.test.ts | 14 +++++++++----- src/output/__tests__/mask.test.ts | 26 +++++++++++++++++++------- src/output/mask.ts | 20 +++++++++++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 7906391..f06864a 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -244,7 +244,7 @@ describe('hookmyapp login --code', () => { // Identity echo present in stdout. const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, ); // runWizard was invoked — the /workspaces apiClient mock confirms it. @@ -399,11 +399,14 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com, workspace "Old Workspace"\)/, + /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com \[a913\], workspace "Old Workspace"\)/, ); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, ); + // Raw addresses must never reach the human-readable output. + expect(out).not.toContain('info@ordvir.com'); + expect(out).not.toContain('old@other.com'); // "was:" MUST appear before the "Logged in as" line. const wasIdx = out.search(/Replaced previous session/); const loggedIdx = out.search(/Logged in as/); @@ -440,7 +443,8 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toMatch(/Replaced previous session/); - expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com/); + expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com \[115c\]/); + expect(out).not.toContain('info@ordvir.com'); logSpy.mockRestore(); }); @@ -582,7 +586,7 @@ describe('hookmyapp login --code', () => { // literal "Logged in as", email, comma, space, workspace name in // double quotes. House style bans em-dashes in user-facing copy. expect(out).toMatch( - /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace"/, + /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, ); logSpy.mockRestore(); }); diff --git a/src/output/__tests__/mask.test.ts b/src/output/__tests__/mask.test.ts index bf5894c..a418931 100644 --- a/src/output/__tests__/mask.test.ts +++ b/src/output/__tests__/mask.test.ts @@ -3,15 +3,27 @@ import { displayEmail } from '../mask.js'; // Lockstep contract: these expectations mirror maskEmail in the hookmyapp // backend instruction template (AIT-256). If one side changes, both must. +// The [xxxx] tag is the first 4 hex chars of sha256(trimmed lowercased email). describe('displayEmail', () => { - test('masks local part after 2 chars and domain to first char + tld', () => { - expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com'); - expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com'); + test('masks local part after 2 chars, domain to first char + tld, and appends the discriminator', () => { + expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com [115c]'); + expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com [a52e]'); }); - test('degrades safely on malformed input', () => { - expect(displayEmail('nodomain')).toBe('***'); - expect(displayEmail('@lead.com')).toBe('***'); - expect(displayEmail('a@b')).toBe('a***@b***'); + test('never emits the raw address', () => { + for (const raw of ['info@ordvir.com', 'edgargov55@gmail.com']) { + expect(displayEmail(raw)).not.toContain(raw); + expect(displayEmail(raw)).not.toContain(raw.split('@')[0]); + } + }); + + test('colliding masked prefixes stay distinguishable via the discriminator', () => { + expect(displayEmail('info@ordvir.com')).not.toBe(displayEmail('invoice@other.com')); + }); + + test('degrades safely on malformed input without leaking it', () => { + expect(displayEmail('nodomain')).toMatch(/^\*\*\* \[[0-9a-f]{4}\]$/); + expect(displayEmail('@lead.com')).toMatch(/^\*\*\* \[[0-9a-f]{4}\]$/); + expect(displayEmail('a@b')).toMatch(/^a\*\*\*@b\*\*\* \[[0-9a-f]{4}\]$/); }); }); diff --git a/src/output/mask.ts b/src/output/mask.ts index ae5d17f..65dfaec 100644 --- a/src/output/mask.ts +++ b/src/output/mask.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + /** * Deterministic display mask for the login identity echo (AIT-256). * @@ -5,19 +7,27 @@ * backend (`backend/src/auth/bootstrap/instruction-template.ts`): the * bootstrap instruction block renders the Expected-output line masked, and * the executing AI compares this CLI's echo against it. Same mask on both - * sides keeps the paste-into-wrong-AI safety net working — a different - * account still yields a different masked string — while the raw address - * never appears on screen (screen-recording safety). + * sides keeps the paste-into-wrong-AI safety net working while the raw + * address never appears on screen (screen-recording safety). + * + * The trailing `[xxxx]` is a non-reversible discriminator (first 4 hex of + * SHA-256 of the normalized address) so two accounts sharing a masked + * prefix (jo***@g***.com is common) still render distinct echoes and a + * wrong-account paste cannot collide its way past the check. * * `--json` output is exempt: machine consumers get the raw email. */ export function displayEmail(email: string): string { + const tag = createHash('sha256') + .update(email.trim().toLowerCase()) + .digest('hex') + .slice(0, 4); const at = email.indexOf('@'); - if (at <= 0) return '***'; + if (at <= 0) return `*** [${tag}]`; const local = email.slice(0, at); const domain = email.slice(at + 1); const lastDot = domain.lastIndexOf('.'); const tld = lastDot > 0 ? domain.slice(lastDot) : ''; const domainName = lastDot > 0 ? domain.slice(0, lastDot) : domain; - return `${local.slice(0, 2)}***@${domainName.charAt(0)}***${tld}`; + return `${local.slice(0, 2)}***@${domainName.charAt(0)}***${tld} [${tag}]`; } From a369591c9d82410b5a4f0cabde8f080dc7ee5ff5 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 23 Jul 2026 17:03:18 +0300 Subject: [PATCH 3/4] AIT-256: widen discriminator to 8 hex (review round 2) 16 bits was weak enough to undercut the collision claim. 8 hex chars = 32 bits; comment reworded to present the tag as a sanity check against accidental wrong-account pastes, not authentication. Lockstep with the backend maskEmail in hookmyapp PR #133. --- src/auth/__tests__/bootstrap.test.ts | 10 +++++----- src/output/__tests__/mask.test.ts | 12 ++++++------ src/output/mask.ts | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index f06864a..01d2ddd 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -244,7 +244,7 @@ describe('hookmyapp login --code', () => { // Identity echo present in stdout. const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, ); // runWizard was invoked — the /workspaces apiClient mock confirms it. @@ -399,10 +399,10 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com \[a913\], workspace "Old Workspace"\)/, + /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com \[a913eca2\], workspace "Old Workspace"\)/, ); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, ); // Raw addresses must never reach the human-readable output. expect(out).not.toContain('info@ordvir.com'); @@ -443,7 +443,7 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toMatch(/Replaced previous session/); - expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com \[115c\]/); + expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\]/); expect(out).not.toContain('info@ordvir.com'); logSpy.mockRestore(); }); @@ -586,7 +586,7 @@ describe('hookmyapp login --code', () => { // literal "Logged in as", email, comma, space, workspace name in // double quotes. House style bans em-dashes in user-facing copy. expect(out).toMatch( - /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com \[115c\], workspace "Or's Workspace"/, + /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, ); logSpy.mockRestore(); }); diff --git a/src/output/__tests__/mask.test.ts b/src/output/__tests__/mask.test.ts index a418931..8a6d7e8 100644 --- a/src/output/__tests__/mask.test.ts +++ b/src/output/__tests__/mask.test.ts @@ -3,11 +3,11 @@ import { displayEmail } from '../mask.js'; // Lockstep contract: these expectations mirror maskEmail in the hookmyapp // backend instruction template (AIT-256). If one side changes, both must. -// The [xxxx] tag is the first 4 hex chars of sha256(trimmed lowercased email). +// The [xxxx] tag is the first 4 hex chars of sha256(trimmed lowercased email), first 8 hex. describe('displayEmail', () => { test('masks local part after 2 chars, domain to first char + tld, and appends the discriminator', () => { - expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com [115c]'); - expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com [a52e]'); + expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com [115c9dc4]'); + expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com [a52e31c0]'); }); test('never emits the raw address', () => { @@ -22,8 +22,8 @@ describe('displayEmail', () => { }); test('degrades safely on malformed input without leaking it', () => { - expect(displayEmail('nodomain')).toMatch(/^\*\*\* \[[0-9a-f]{4}\]$/); - expect(displayEmail('@lead.com')).toMatch(/^\*\*\* \[[0-9a-f]{4}\]$/); - expect(displayEmail('a@b')).toMatch(/^a\*\*\*@b\*\*\* \[[0-9a-f]{4}\]$/); + expect(displayEmail('nodomain')).toMatch(/^\*\*\* \[[0-9a-f]{8}\]$/); + expect(displayEmail('@lead.com')).toMatch(/^\*\*\* \[[0-9a-f]{8}\]$/); + expect(displayEmail('a@b')).toMatch(/^a\*\*\*@b\*\*\* \[[0-9a-f]{8}\]$/); }); }); diff --git a/src/output/mask.ts b/src/output/mask.ts index 65dfaec..96a7248 100644 --- a/src/output/mask.ts +++ b/src/output/mask.ts @@ -10,10 +10,10 @@ import { createHash } from 'node:crypto'; * sides keeps the paste-into-wrong-AI safety net working while the raw * address never appears on screen (screen-recording safety). * - * The trailing `[xxxx]` is a non-reversible discriminator (first 4 hex of - * SHA-256 of the normalized address) so two accounts sharing a masked - * prefix (jo***@g***.com is common) still render distinct echoes and a - * wrong-account paste cannot collide its way past the check. + * The trailing `[xxxxxxxx]` is a non-reversible discriminator (first 8 hex + * of SHA-256 of the normalized address) so two accounts sharing a masked + * prefix (jo***@g***.com is common) still render distinct echoes. It is a + * sanity check against accidental wrong-account pastes, not authentication. * * `--json` output is exempt: machine consumers get the raw email. */ @@ -21,7 +21,7 @@ export function displayEmail(email: string): string { const tag = createHash('sha256') .update(email.trim().toLowerCase()) .digest('hex') - .slice(0, 4); + .slice(0, 8); const at = email.indexOf('@'); if (at <= 0) return `*** [${tag}]`; const local = email.slice(0, at); From 097be43de48da3b6af79bf752819fe8375d64fc0 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 23 Jul 2026 17:22:37 +0300 Subject: [PATCH 4/4] AIT-256: drop the email hash; workspace publicId is the discriminator (review round 3) Codex flagged the unsalted email-hash tag as an enumerable identifier on public surfaces; the collision concern is covered instead by rendering the workspace publicId in the identity echo (random, customer-visible, zero PII): Logged in as xx***@y***.com, workspace "Name" (ws_XXXXXXXX). Lockstep with the backend expected-output line in hookmyapp#133. --- src/auth/__tests__/bootstrap.test.ts | 10 +++++----- src/auth/login.ts | 2 +- src/output/__tests__/mask.test.ts | 17 ++++++----------- src/output/mask.ts | 18 ++++++------------ 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 01d2ddd..3674ae1 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -244,7 +244,7 @@ describe('hookmyapp login --code', () => { // Identity echo present in stdout. const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace" \(ws_/, ); // runWizard was invoked — the /workspaces apiClient mock confirms it. @@ -399,10 +399,10 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).toMatch( - /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com \[a913eca2\], workspace "Old Workspace"\)/, + /Replaced previous session \(was: ol\*\*\*@o\*\*\*\.com, workspace "Old Workspace"\)/, ); expect(out).toMatch( - /Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, + /Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace" \(ws_/, ); // Raw addresses must never reach the human-readable output. expect(out).not.toContain('info@ordvir.com'); @@ -443,7 +443,7 @@ describe('hookmyapp login --code', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toMatch(/Replaced previous session/); - expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\]/); + expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com, workspace/); expect(out).not.toContain('info@ordvir.com'); logSpy.mockRestore(); }); @@ -586,7 +586,7 @@ describe('hookmyapp login --code', () => { // literal "Logged in as", email, comma, space, workspace name in // double quotes. House style bans em-dashes in user-facing copy. expect(out).toMatch( - /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com \[115c9dc4\], workspace "Or's Workspace"/, + /\u2713.*Logged in as in\*\*\*@o\*\*\*\.com, workspace "Or's Workspace" \(ws_/, ); logSpy.mockRestore(); }); diff --git a/src/auth/login.ts b/src/auth/login.ts index 96836c8..f4aadcd 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -473,7 +473,7 @@ export async function runBootstrapCodeExchange( ); } console.log( - `${c.success(icon.success)} Logged in as ${displayEmail(data.user.email)}, workspace "${data.workspace.name}"`, + `${c.success(icon.success)} Logged in as ${displayEmail(data.user.email)}, workspace "${data.workspace.name}" (${data.workspace.id})`, ); await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); diff --git a/src/output/__tests__/mask.test.ts b/src/output/__tests__/mask.test.ts index 8a6d7e8..86436f0 100644 --- a/src/output/__tests__/mask.test.ts +++ b/src/output/__tests__/mask.test.ts @@ -3,11 +3,10 @@ import { displayEmail } from '../mask.js'; // Lockstep contract: these expectations mirror maskEmail in the hookmyapp // backend instruction template (AIT-256). If one side changes, both must. -// The [xxxx] tag is the first 4 hex chars of sha256(trimmed lowercased email), first 8 hex. describe('displayEmail', () => { - test('masks local part after 2 chars, domain to first char + tld, and appends the discriminator', () => { - expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com [115c9dc4]'); - expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com [a52e31c0]'); + test('masks local part after 2 chars and domain to first char + tld', () => { + expect(displayEmail('info@ordvir.com')).toBe('in***@o***.com'); + expect(displayEmail('edgargov55@gmail.com')).toBe('ed***@g***.com'); }); test('never emits the raw address', () => { @@ -17,13 +16,9 @@ describe('displayEmail', () => { } }); - test('colliding masked prefixes stay distinguishable via the discriminator', () => { - expect(displayEmail('info@ordvir.com')).not.toBe(displayEmail('invoice@other.com')); - }); - test('degrades safely on malformed input without leaking it', () => { - expect(displayEmail('nodomain')).toMatch(/^\*\*\* \[[0-9a-f]{8}\]$/); - expect(displayEmail('@lead.com')).toMatch(/^\*\*\* \[[0-9a-f]{8}\]$/); - expect(displayEmail('a@b')).toMatch(/^a\*\*\*@b\*\*\* \[[0-9a-f]{8}\]$/); + expect(displayEmail('nodomain')).toBe('***'); + expect(displayEmail('@lead.com')).toBe('***'); + expect(displayEmail('a@b')).toBe('a***@b***'); }); }); diff --git a/src/output/mask.ts b/src/output/mask.ts index 96a7248..abe6563 100644 --- a/src/output/mask.ts +++ b/src/output/mask.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; - /** * Deterministic display mask for the login identity echo (AIT-256). * @@ -10,24 +8,20 @@ import { createHash } from 'node:crypto'; * sides keeps the paste-into-wrong-AI safety net working while the raw * address never appears on screen (screen-recording safety). * - * The trailing `[xxxxxxxx]` is a non-reversible discriminator (first 8 hex - * of SHA-256 of the normalized address) so two accounts sharing a masked - * prefix (jo***@g***.com is common) still render distinct echoes. It is a - * sanity check against accidental wrong-account pastes, not authentication. + * Collision resistance comes from the workspace publicId rendered next to + * the workspace name in the same echo line (random id, customer-visible, + * zero PII) — NOT from an email hash, which would leak an enumerable + * identifier onto public surfaces. * * `--json` output is exempt: machine consumers get the raw email. */ export function displayEmail(email: string): string { - const tag = createHash('sha256') - .update(email.trim().toLowerCase()) - .digest('hex') - .slice(0, 8); const at = email.indexOf('@'); - if (at <= 0) return `*** [${tag}]`; + if (at <= 0) return '***'; const local = email.slice(0, at); const domain = email.slice(at + 1); const lastDot = domain.lastIndexOf('.'); const tld = lastDot > 0 ? domain.slice(lastDot) : ''; const domainName = lastDot > 0 ? domain.slice(0, lastDot) : domain; - return `${local.slice(0, 2)}***@${domainName.charAt(0)}***${tld} [${tag}]`; + return `${local.slice(0, 2)}***@${domainName.charAt(0)}***${tld}`; }