Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,34 @@ Dar ao Zinom uma camada de **perfil curado por conta**, pequena e injetada em
toda sessão MCP, alimentada por uma escada de confiança determinística
(sinal → evidência → confirmado) e mantida por uma passada de curadoria que
opera **só na tabela nova** (nunca em `brain_chunks`), com trilha de auditoria,
owner-first e tudo atrás de flag.
e tudo atrás de flag.

## Atualização (2026-06-19): per-account + allowlist (pivô de produto)

Motivo: o uso real do Bruno (Claude.ai + portal logado) é via conta **friend**
(gmail), enquanto o `owner` (`bruno`) só é usado via Claude Code (bearer). Um v1
estritamente owner-only injetaria só no Claude Code e a UI owner-only não casaria
com a conta do portal. Decisão do Bruno (2026-06-19): **perfil por conta + UI no
portal Next.js + injeção governada por allowlist de contas** (não pelo booleano
`owner`). Mudanças vs. o desenho original:

- **Injeção (D9):** `resolveInstructions` carrega os fatos da **conta da sessão**
(não `DEFAULT_ACCOUNT_ID`) e gateia por **allowlist** (`PROFILE_INJECT_ACCOUNTS`,
CSV de account_ids): `allowed = injectEnabled && !!accountId &&
allowlist.includes(accountId)`. **Fail-closed reforçado:** cada conta só carrega
os **próprios** fatos, então accountId ausente/ambíguo não vaza (carrega vazio);
o allowlist controla o rollout. Não usar `getAccountId()` (fallback pra 'bruno').
- **Curador (D10):** loop **por conta** (contas com linha em `user_profile_facts`),
não só o owner. Continua determinístico, só na tabela nova.
- **UI (novo):** página **Perfil** no portal Next.js (`web/app/(app)/perfil/`),
por conta (a sessão cura o próprio perfil). Rotas em `src/portal/routes.ts`
scoped por `res.locals.accountId`, sem gate de owner (a injeção é que é
allowlist-gated).
- **Rollout:** ligar `PROFILE_INJECT_ENABLED` + pôr a(s) conta(s) do Bruno em
`PROFILE_INJECT_ACCOUNTS`. Friends seguem sem injeção até entrarem no allowlist.

O resto do desenho (tabela, confiança, render, orçamento, auditoria, fora do eval
gate, guarda de segredo) continua valendo.

## Decisões de design (com tradeoff)

Expand Down
177 changes: 122 additions & 55 deletions src/__tests__/profile-injection.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// src/__tests__/profile-injection.test.ts
// T6 (CRÍTICO DE SEGURANÇA): resolveInstructions injeta o perfil curado nas
// `instructions` da sessão MCP de forma FAIL-CLOSED e gated por flag. Um bug aqui
// vazaria o perfil do owner para um friend. Estes testes são PUROS (sem Postgres,
// sem Express): o carregamento de fatos é injetado (loadFacts fake/spy), então
// um friend NUNCA pode tocar os fatos do owner.
// CRÍTICO DE SEGURANÇA: resolveInstructions injeta o perfil curado nas
// `instructions` da sessão MCP de forma FAIL-CLOSED, gated por flag E por
// allowlist per-account. Um bug aqui vazaria o perfil de uma conta para outra.
// Estes testes são PUROS (sem Postgres, sem Express): o carregamento de fatos é
// injetado (loadFacts fake/spy), então uma conta fora do allowlist NUNCA pode
// tocar os fatos, e a conta A nunca recebe os fatos da conta B.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
Expand Down Expand Up @@ -35,58 +36,128 @@ function makeFact(overrides: Partial<ProfileFact> = {}): ProfileFact {
};
}

// Um spy de loadFacts que FALHA o teste se for chamado: prova que o owner=false
// (friend) curto-circuita ANTES de qualquer carregamento de fatos do owner.
// Um spy de loadFacts que FALHA o teste se for chamado: prova fail-closed (a
// conta fora do allowlist / sem accountId curto-circuita ANTES de carregar fatos).
function neverCalled(): (accountId: string) => Promise<ProfileFact[]> {
return async () => {
throw new Error("loadFacts NÃO deveria ter sido chamado (fail-closed violado)");
};
}

// Um spy que, se chamado, devolveria os fatos do OWNER — usado para provar que o
// resultado para um friend não contém nenhum byte desses fatos.
function spyThatReturnsOwnerFacts(): {
// Spy que registra COM QUAL accountId loadFacts foi chamado e devolve um fato
// marcado com esse accountId — usado para provar isolamento per-account.
function spyLoad(): {
fn: (accountId: string) => Promise<ProfileFact[]>;
called: () => boolean;
calls: () => string[];
} {
let was = false;
const fn = async () => {
was = true;
return [makeFact()];
const calls: string[] = [];
const fn = async (accountId: string) => {
calls.push(accountId);
return [makeFact({ content: `FATO_DA_CONTA_${accountId}` })];
};
return { fn, called: () => was };
return { fn, calls: () => calls };
}

// AC5 — flag off = baseline exato, e loadFacts NUNCA é chamado.
test("AC5: flag off → owner recebe OWNER_INSTRUCTIONS sem tocar loadFacts", async () => {
// --- allowlist: conta liberada injeta os PRÓPRIOS fatos --------------------
test("allowlist: accountId no allowlist + flag on → injeta os fatos DAQUELA conta", async () => {
const spy = spyLoad();
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
accountId: "acct_A",
allowlist: ["acct_A", "acct_B"],
loadFacts: spy.fn,
});
assert.deepEqual(spy.calls(), ["acct_A"], "loadFacts chamado com a conta REAL da sessão");
assert.ok(out.includes(FRIEND_INSTRUCTIONS), "mantém a base");
assert.ok(out.includes("FATO_DA_CONTA_acct_A"), "injeta os fatos da própria conta");
});

test("allowlist (owner): owner no allowlist + flag on → base owner + perfil da conta owner", async () => {
const spy = spyLoad();
const out = await resolveInstructions({
owner: true,
injectEnabled: false,
defaultAccountId: "bruno",
injectEnabled: true,
accountId: "bruno",
allowlist: ["bruno"],
loadFacts: spy.fn,
});
assert.deepEqual(spy.calls(), ["bruno"]);
assert.ok(out.includes(OWNER_INSTRUCTIONS), "mantém a base owner íntegra");
assert.ok(out.includes("FATO_DA_CONTA_bruno"));
});

// --- fail-closed: fora do allowlist = base pura, sem tocar loadFacts -------
test("fail-closed: accountId FORA do allowlist → base pura, loadFacts NÃO chamado", async () => {
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
accountId: "acct_intruso",
allowlist: ["acct_A"],
loadFacts: neverCalled(),
});
assert.equal(out, OWNER_INSTRUCTIONS);
assert.equal(out, FRIEND_INSTRUCTIONS, "fora do allowlist = base friend pura");
});

test("AC5: flag off → friend recebe FRIEND_INSTRUCTIONS sem tocar loadFacts", async () => {
test("fail-closed: accountId null → base pura, loadFacts NÃO chamado", async () => {
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
accountId: null,
allowlist: ["acct_A"],
loadFacts: neverCalled(),
});
assert.equal(out, FRIEND_INSTRUCTIONS);
});

test("fail-closed: accountId '' (vazio) → base pura, loadFacts NÃO chamado", async () => {
const out = await resolveInstructions({
owner: true,
injectEnabled: true,
accountId: "",
allowlist: ["", "bruno"], // mesmo que '' esteja no array, !!accountId barra
loadFacts: neverCalled(),
});
assert.equal(out, OWNER_INSTRUCTIONS, "string vazia nunca injeta (fail-closed)");
});

test("fail-closed: flag OFF → base pura mesmo no allowlist, loadFacts NÃO chamado", async () => {
const out = await resolveInstructions({
owner: false,
injectEnabled: false,
defaultAccountId: "bruno",
accountId: "acct_A",
allowlist: ["acct_A"],
loadFacts: neverCalled(),
});
assert.equal(out, FRIEND_INSTRUCTIONS);
});

// AC4 — fail-closed.
test("AC4: isOwnerContext é false para OAuth friend com accountId", () => {
// --- ISOLAMENTO: conta A nunca recebe os fatos da conta B ------------------
test("isolamento: a conta A só recebe os PRÓPRIOS fatos (nunca os da conta B)", async () => {
const spy = spyLoad();
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
accountId: "acct_A",
allowlist: ["acct_A", "acct_B"],
loadFacts: spy.fn,
});
// loadFacts foi chamado SÓ com acct_A; nunca com acct_B nem com um DEFAULT.
assert.deepEqual(spy.calls(), ["acct_A"]);
assert.ok(out.includes("FATO_DA_CONTA_acct_A"), "contém os fatos da própria conta");
assert.ok(!out.includes("FATO_DA_CONTA_acct_B"), "NUNCA contém os fatos de outra conta");
assert.ok(!out.includes("FATO_DA_CONTA_bruno"), "NUNCA cai num DEFAULT account");
});

// --- isOwnerContext (fail-closed do owner-gate, inalterado) ----------------
test("isOwnerContext é false para OAuth friend com accountId", () => {
assert.equal(
isOwnerContext({ authType: "oauth", scopes: ["personal"], accountId: "friend:x" }),
false,
);
});

test("AC4: isOwnerContext é false para OAuth sem accountId e sem isOperator", () => {
test("isOwnerContext é false para OAuth sem accountId e sem isOperator", () => {
assert.equal(
isOwnerContext({
authType: "oauth",
Expand All @@ -98,35 +169,31 @@ test("AC4: isOwnerContext é false para OAuth sem accountId e sem isOperator", (
);
});

test("AC4: flag on + friend → NÃO contém fatos do owner E loadFacts NÃO é chamado", async () => {
const spy = spyThatReturnsOwnerFacts();
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
defaultAccountId: "bruno",
loadFacts: spy.fn,
});
// owner=false curto-circuita: nunca carrega nem renderiza fatos do owner.
assert.equal(spy.called(), false, "loadFacts não pode ser chamado para friend");
assert.equal(out, FRIEND_INSTRUCTIONS, "friend recebe a base friend pura");
assert.ok(
!out.includes("OWNER_SECRET_FACT_marcador_unico"),
"resultado do friend não pode conter nenhum byte dos fatos do owner",
);
// --- index wiring: parse de PROFILE_INJECT_ACCOUNTS ------------------------
// Reproduz EXATAMENTE a expressão de index.ts (CSV → string[] aparado, sem
// vazios). Mantido como teste puro: index.ts arrasta Express/clients no load.
function parseInjectAccounts(raw: string | undefined): string[] {
return (raw ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}

test("PROFILE_INJECT_ACCOUNTS: CSV com espaços é aparado", () => {
assert.deepEqual(parseInjectAccounts(" bruno , acct_A ,acct_B "), [
"bruno",
"acct_A",
"acct_B",
]);
});

// owner on — perfil é injetado.
test("owner on: contém OWNER_INSTRUCTIONS E o conteúdo do fato pinned", async () => {
const pinned = makeFact({ content: "Bruno prefere respostas curtas em PT-BR." });
const out = await resolveInstructions({
owner: true,
injectEnabled: true,
defaultAccountId: "bruno",
loadFacts: async () => [pinned],
});
assert.ok(out.includes(OWNER_INSTRUCTIONS), "deve conter a base owner íntegra");
assert.ok(
out.includes("Bruno prefere respostas curtas em PT-BR."),
"deve conter o conteúdo do fato pinned",
);
test("PROFILE_INJECT_ACCOUNTS: vazio/undefined → []", () => {
assert.deepEqual(parseInjectAccounts(""), []);
assert.deepEqual(parseInjectAccounts(undefined), []);
assert.deepEqual(parseInjectAccounts(" "), []);
assert.deepEqual(parseInjectAccounts(",, ,"), [], "só separadores/vazios → []");
});

test("PROFILE_INJECT_ACCOUNTS: conta única", () => {
assert.deepEqual(parseInjectAccounts("bruno"), ["bruno"]);
});
27 changes: 19 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { createRubrixRouter } from "./rubrix/routes.js";
import { registerRubrixTools } from "./rubrix/tools.js";
import { resolveBearer, accountWorkspaces } from "./account-bearer.js";
import { isAccountActive } from "./account-status.js";
import { requestContext, getContext, getAccountId, DEFAULT_ACCOUNT_ID, type RequestContext } from "./context.js";
import { requestContext, getContext, getAccountId, type RequestContext } from "./context.js";
import { isOwnerContext, isOperatorToken, resolveInstructions } from "./mcp-account-config.js";
import { loadProfileFacts } from "./rag/profile-storage.js";
import { ALL_WORKSPACES } from "./clients.js";
Expand Down Expand Up @@ -132,10 +132,16 @@ app.use((req, _res, next) => {
// Auth middleware for /mcp — accepts static BEARER_TOKEN or OAuth access tokens
const BEARER_TOKEN = process.env.BEARER_TOKEN;

// T6 — gate (default OFF) para injetar o perfil curado do owner nas instructions
// da sessão. Fail-closed: o perfil só é carregado para o owner (ver
// resolveInstructions); um friend nunca toca os fatos do owner, flag ligada ou não.
// Gate (default OFF) para injetar o perfil curado nas instructions da sessão.
// Pivô per-account + allowlist: a injeção exige a flag ligada E a conta REAL da
// sessão estar na PROFILE_INJECT_ACCOUNTS (CSV). Fail-closed: conta ausente ou
// fora do allowlist nunca carrega fatos (ver resolveInstructions). Cada conta só
// carrega os PRÓPRIOS fatos.
const PROFILE_INJECT_ENABLED = process.env.PROFILE_INJECT_ENABLED === "true";
const PROFILE_INJECT_ACCOUNTS = (process.env.PROFILE_INJECT_ACCOUNTS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);

// OAuth routes (well-known, register, authorize, token, admin)
app.use(createOAuthRouter(BASE_URL, BEARER_TOKEN));
Expand Down Expand Up @@ -452,13 +458,18 @@ app.post("/mcp", async (req, res) => {
if (id) evictSession(id);
};

// T6 — instructions da sessão (owner/friend), opcionalmente enriquecidas com o
// perfil curado do owner quando PROFILE_INJECT_ENABLED. Fail-closed: friend nunca
// carrega os fatos do owner. Montado ANTES de instanciar o server (handler async).
// Instructions da sessão (owner/friend), opcionalmente enriquecidas com o perfil
// curado DA PRÓPRIA CONTA quando PROFILE_INJECT_ENABLED e a conta está na
// allowlist. Fail-closed per-account: passamos a conta REAL da sessão (sem o
// fallback 'bruno' de getAccountId) — null se o request não resolveu conta — e
// resolveInstructions só injeta se accountId não-vazio E na allowlist; cada conta
// carrega só os próprios fatos. Montado ANTES de instanciar o server.
const realAccountId = getContext()?.accountId ?? null;
const instructions = await resolveInstructions({
owner,
injectEnabled: PROFILE_INJECT_ENABLED,
defaultAccountId: DEFAULT_ACCOUNT_ID,
accountId: realAccountId,
allowlist: PROFILE_INJECT_ACCOUNTS,
loadFacts: loadProfileFacts,
});

Expand Down
35 changes: 22 additions & 13 deletions src/mcp-account-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,19 @@ export function composeInstructions(base: string, profileBlock: string | null):
}

/**
* T6 (CRÍTICO DE SEGURANÇA): resolve as `instructions` da sessão MCP, opcionalmente
* enriquecidas com o perfil curado do owner. FAIL-CLOSED por desenho:
* CRÍTICO DE SEGURANÇA: resolve as `instructions` da sessão MCP, opcionalmente
* enriquecidas com o perfil curado DA PRÓPRIA CONTA da sessão. FAIL-CLOSED por
* desenho (pivô per-account + allowlist):
*
* - `base` = owner ? OWNER_INSTRUCTIONS : FRIEND_INSTRUCTIONS.
* - O perfil só é carregado/renderizado quando `injectEnabled && owner` for true.
* Um friend (owner=false) NUNCA chama `loadFacts`: o perfil curado é do owner e
* carregá-lo no caminho de um friend já seria um vazamento (mesmo que o bloco
* fosse descartado depois). O curto-circuito `owner &&` garante isso.
* - O perfil só é carregado/renderizado quando TODAS as condições valem:
* `injectEnabled` (flag global) E `accountId` presente (string não-vazia) E
* `allowlist.includes(accountId)`. Qualquer condição falsa ⇒ profileBlock null.
* - FAIL-CLOSED: `accountId` ausente/vazio (null/undefined/'') OU fora do allowlist
* ⇒ NUNCA carrega fatos, NUNCA injeta. O curto-circuito `allowed &&` garante que
* `loadFacts` só é chamado para uma conta explicitamente liberada.
* - ISOLAMENTO: cada conta carrega SÓ os PRÓPRIOS fatos — `loadFacts(accountId)`,
* nunca um DEFAULT. A conta A jamais recebe os fatos da conta B.
* - O resultado SEMPRE contém `base` (composeInstructions nunca devolve '' nem a
* destrói): mesmo sem fatos elegíveis, a sessão mantém suas instructions.
*
Expand All @@ -80,7 +85,8 @@ export function composeInstructions(base: string, profileBlock: string | null):
export async function resolveInstructions(args: {
owner: boolean;
injectEnabled: boolean;
defaultAccountId: string;
accountId: string | null | undefined; // conta REAL da sessão (fail-closed)
allowlist: string[]; // PROFILE_INJECT_ACCOUNTS parseado
loadFacts: (accountId: string) => Promise<ProfileFact[]>;
render?: (facts: ProfileFact[], budget: number) => string | null;
budget?: number;
Expand All @@ -89,12 +95,15 @@ export async function resolveInstructions(args: {
const render = args.render ?? renderProfile;
const budget = args.budget ?? PROFILE_CHAR_BUDGET;

// FAIL-CLOSED: o perfil curado é do owner. Só carrega quando a flag está ligada
// E o request é do owner. Para um friend, NUNCA toca loadFacts.
const profileBlock =
args.injectEnabled && args.owner
? render(await args.loadFacts(args.defaultAccountId), budget)
: null;
// FAIL-CLOSED: injeta só com flag ligada, accountId presente E na allowlist.
// accountId vazio/ausente ou fora do allowlist ⇒ nunca toca loadFacts.
const allowed =
args.injectEnabled && !!args.accountId && args.allowlist.includes(args.accountId);

// ISOLAMENTO: cada conta carrega SÓ os próprios fatos (loadFacts(accountId)).
const profileBlock = allowed
? render(await args.loadFacts(args.accountId!), budget)
: null;

return composeInstructions(base, profileBlock);
}
Expand Down
Loading
Loading