diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 9c20d3a..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 info@ordvir\.com, 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,11 +399,14 @@ 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" \(ws_/, ); + // 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 info@ordvir\.com/); + expect(out).toMatch(/Logged in as in\*\*\*@o\*\*\*\.com, workspace/); + 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 info@ordvir\.com, 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 d106601..f4aadcd 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}" (${data.workspace.id})`, ); 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..86436f0 --- /dev/null +++ b/src/output/__tests__/mask.test.ts @@ -0,0 +1,24 @@ +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('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('degrades safely on malformed input without leaking it', () => { + 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..abe6563 --- /dev/null +++ b/src/output/mask.ts @@ -0,0 +1,27 @@ +/** + * 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 while the raw + * address never appears on screen (screen-recording safety). + * + * 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 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}`; +}