Skip to content

Commit 10bbf1e

Browse files
authored
[codex] stage sensitive facts as encrypted reflex candidates
Adds secure_fact Reflex candidates for explicit sensitive fact-shaped run text, staging raw values through encrypted secret storage while keeping candidate payloads redacted.\n\nVerification: PR CI run 26735015796 and PR Real Green Gate run 26735488212 passed.
2 parents 52fc3fe + e654ca6 commit 10bbf1e

13 files changed

Lines changed: 585 additions & 9 deletions

File tree

context/MEMORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@
1818
- Tool call summary (`src/agent/services/friday-tool-call-summary.ts`) captures privacy-safe tool execution metadata (arg keys only, no values) for observability and world model training data.
1919
- Warn-once pattern is used across 7+ modules (hub-bootstrap, auth, memory, system, http-server, workspace-context, unix-socket-bridge) to deduplicate runtime warnings without losing critical signals.
2020
- OpenAI Responses API (`openai-responses`) streaming is now supported alongside `openai-completions` in the agent LLM client.
21-
- Database migration count: 91 (latest: v091-preference-fact-metadata).
21+
- Database migration count: 92 (latest: v092-reflex-secure-fact-candidates).

src/agent/tools/friday-agent-reflex-tools.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const CANDIDATE_STATUSES = new Set<FridayReflexCandidateStatus>([
2323

2424
const CANDIDATE_KINDS = new Set<FridayReflexCandidateKind>([
2525
"memory",
26+
"secure_fact",
2627
"learned_fact",
2728
"preference",
2829
"recipe",
@@ -213,7 +214,7 @@ function evaluateAgentReflexApproval(candidate: FridayReflexCandidate): {
213214

214215
return {
215216
allowed: false,
216-
reason: "Skill, workflow, fix, recipe, and test-policy candidates require Review Center confirmation.",
217+
reason: "Secure-fact, learned-fact, skill, workflow, fix, recipe, and test-policy candidates require Review Center confirmation.",
217218
};
218219
}
219220

src/api/http/routes/friday-reflex-routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const CANDIDATE_STATUSES = new Set<FridayReflexCandidateStatus>([
2222

2323
const CANDIDATE_KINDS = new Set<FridayReflexCandidateKind>([
2424
"memory",
25+
"secure_fact",
2526
"learned_fact",
2627
"preference",
2728
"recipe",

src/hub/friday-hub-bootstrap.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
createFridayProviderCostCalculator,
5151
createFridayProviderPricingCatalog,
5252
createFridayProviderService,
53+
createFridaySecretAdminService,
5354
createFridaySecretRepository,
5455
decryptSecret,
5556
getFridayProviderPreset,
@@ -5977,6 +5978,11 @@ export async function createFridayHub(
59775978

59785979
const reflexCandidateRepository = createFridayReflexCandidateRepository();
59795980
const reflexOnboardingRepository = createFridayReflexOnboardingRepository();
5981+
const reflexSecretAdminService = createFridaySecretAdminService({
5982+
db: stateRuntime.sqlite,
5983+
idGenerator,
5984+
nowIso,
5985+
});
59805986
reflexService = createFridayReflexService({
59815987
db: stateRuntime.sqlite,
59825988
candidateRepo: reflexCandidateRepository,
@@ -6033,6 +6039,23 @@ export async function createFridayHub(
60336039
lastConfirmedAt: fact.lastConfirmedAt,
60346040
};
60356041
},
6042+
secureFactStager: (input) => {
6043+
const secret = reflexSecretAdminService.createSecret({
6044+
scope: "learned_fact",
6045+
refKey: `${input.userId}:${input.candidateId}`,
6046+
value: input.value,
6047+
});
6048+
return {
6049+
secretId: secret.id,
6050+
scope: secret.scope,
6051+
refKey: secret.refKey,
6052+
createdAt: secret.createdAt,
6053+
updatedAt: secret.updatedAt,
6054+
};
6055+
},
6056+
secureFactRejecter: (input) => ({
6057+
deleted: reflexSecretAdminService.deleteSecret(input.secretId),
6058+
}),
60366059
skillGenerator,
60376060
workflowGenerator,
60386061
learningEventWriter,

src/reflex/model/friday-reflex.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { JsonValue } from "../../uix/model/friday-uix.types.js";
22

33
export type FridayReflexCandidateKind =
44
| "memory"
5+
| "secure_fact"
56
| "learned_fact"
67
| "preference"
78
| "recipe"

src/reflex/services/friday-reflex-service.ts

Lines changed: 206 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,27 @@ export interface FridayReflexLearnedFactApprovalResult {
172172
lastConfirmedAt: string;
173173
}
174174

175+
export interface FridayReflexSecureFactStageResult {
176+
secretId: string;
177+
scope: string;
178+
refKey: string;
179+
createdAt?: string;
180+
updatedAt?: string;
181+
}
182+
183+
export interface FridayReflexSecureFactStagerInput {
184+
userId: string;
185+
key: string;
186+
subject: string;
187+
value: string;
188+
candidateId: string;
189+
origin: FridayReflexCandidate["origin"];
190+
sourceRunId?: string;
191+
sessionKey?: string;
192+
nowIso: string;
193+
evidence: Record<string, JsonValue>;
194+
}
195+
175196
export interface CreateFridayReflexServiceDeps {
176197
db: FridaySqliteLayer;
177198
candidateRepo: FridayReflexCandidateRepository;
@@ -190,6 +211,15 @@ export interface CreateFridayReflexServiceDeps {
190211
nowIso: string;
191212
evidence: Record<string, JsonValue>;
192213
}) => FridayReflexLearnedFactApprovalResult;
214+
secureFactStager?: (input: FridayReflexSecureFactStagerInput) => FridayReflexSecureFactStageResult;
215+
secureFactRejecter?: (input: {
216+
userId: string;
217+
candidateId: string;
218+
secretId: string;
219+
scope: string;
220+
refKey: string;
221+
nowIso: string;
222+
}) => { deleted: boolean };
193223
skillGenerator?: FridaySkillGeneratorService;
194224
workflowGenerator?: FridayWorkflowGeneratorService;
195225
learningEventWriter?: (events: FridayLearningEventAppendInput[]) => void;
@@ -261,7 +291,7 @@ function learnedFactSubjectSlug(subject: string): string | null {
261291

262292
function extractLearnedFactFromRunTask(task: string | undefined): {
263293
key: string;
264-
value: JsonValue;
294+
value: string;
265295
statement: string;
266296
subject: string;
267297
} | null {
@@ -288,6 +318,32 @@ function extractLearnedFactFromRunTask(task: string | undefined): {
288318
};
289319
}
290320

321+
function extractSecureFactFromRunTask(task: string | undefined): {
322+
key: string;
323+
value: string;
324+
subject: string;
325+
} | null {
326+
const text = task?.trim().slice(0, LEARNED_FACT_TASK_MAX_LENGTH) ?? "";
327+
if (!text) return null;
328+
if (/(?:do not|don't|dont|never||||).{0,40}(?:remember|learn|record|store|||)/iu.test(text)) {
329+
return null;
330+
}
331+
332+
const english = /(?:remember|learn|note|record|for future reference)[,:]?\s+(?:that\s+)?((?:my|our|the|this|a|an)\s+[^.!?;\n:=]{2,80}?)\s+(?:is|are|=|:)\s+([^.!?;\n]{1,160})/iu.exec(text);
333+
const chinese = /(?:||||)[:]?\s*((?:||||)?[^\n:=]{2,50}?)(?:||=||:)\s*([^\n]{1,120})/u.exec(text);
334+
const subject = (english?.[1] ?? chinese?.[1] ?? "").trim();
335+
const value = cleanLearnedFactValue(english?.[2] ?? chinese?.[2] ?? "");
336+
if (!subject || !value) return null;
337+
if (!isFridaySensitiveLearningCandidate(text) && !isFridaySensitiveLearningCandidate(subject, value)) return null;
338+
const slug = learnedFactSubjectSlug(subject);
339+
if (!slug) return null;
340+
return {
341+
key: `secure.${slug}`,
342+
value,
343+
subject,
344+
};
345+
}
346+
291347
function buildCandidateContent(candidate: FridayReflexCandidate): string {
292348
const content = readPayloadString(candidate.payload, "content")
293349
?? readPayloadString(candidate.payload, "text")
@@ -611,6 +667,20 @@ export function createFridayReflexService(
611667
) ?? null;
612668
}
613669

670+
function findPendingSecureFactCandidate(db: FridayReflexDb, input: {
671+
userId: string;
672+
key: string;
673+
}): FridayReflexCandidate | null {
674+
return deps.candidateRepo.list(db, {
675+
userId: input.userId,
676+
kind: "secure_fact",
677+
limit: 200,
678+
}).find((candidate) =>
679+
(candidate.status === "proposed" || candidate.status === "ready_for_review" || candidate.status === "testing")
680+
&& candidate.payload.key === input.key,
681+
) ?? null;
682+
}
683+
614684
function createLearnedFactCandidateFromRunTask(input: {
615685
userId: string;
616686
runId?: string;
@@ -659,6 +729,88 @@ export function createFridayReflexService(
659729
});
660730
}
661731

732+
function createSecureFactCandidateFromRunTask(input: {
733+
userId: string;
734+
runId?: string;
735+
sessionKey?: string;
736+
channelKind?: string;
737+
channelUserId?: string;
738+
task?: string;
739+
}): FridayReflexCandidate | null {
740+
if (!deps.secureFactStager) return null;
741+
const extracted = extractSecureFactFromRunTask(input.task);
742+
if (!extracted) return null;
743+
const existing = deps.db.withReadConnection((db) =>
744+
findPendingSecureFactCandidate(db, {
745+
userId: input.userId,
746+
key: extracted.key,
747+
}));
748+
if (existing) return existing;
749+
750+
const candidateId = deps.idGenerator();
751+
const now = deps.nowIso();
752+
const origin: FridayReflexCandidate["origin"] = input.channelKind ? "channel" : "post_run";
753+
const redactedEvidence: Record<string, JsonValue> = {
754+
requiresExplicitConfirmation: true,
755+
source: "post_run_task_text",
756+
extractionMode: "explicit_sensitive_fact_pattern",
757+
statement: `${extracted.subject} = [encrypted secret pending review]`,
758+
valueRedacted: true,
759+
safetyBoundary: "encrypted_secret_staged_pending_review",
760+
};
761+
let staged: FridayReflexSecureFactStageResult;
762+
try {
763+
staged = deps.secureFactStager({
764+
userId: input.userId,
765+
key: extracted.key,
766+
subject: extracted.subject,
767+
value: extracted.value,
768+
candidateId,
769+
origin,
770+
...(input.runId ? { sourceRunId: input.runId } : {}),
771+
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
772+
nowIso: now,
773+
evidence: redactedEvidence,
774+
});
775+
} catch {
776+
return null;
777+
}
778+
779+
return deps.db.withWriteTransaction((db) =>
780+
deps.candidateRepo.insert(db, {
781+
id: candidateId,
782+
nowIso: now,
783+
userId: input.userId,
784+
kind: "secure_fact",
785+
origin,
786+
status: "ready_for_review",
787+
sourceRunId: input.runId,
788+
sessionKey: input.sessionKey,
789+
channelKind: input.channelKind,
790+
channelUserId: input.channelUserId,
791+
title: `Review encrypted fact: ${extracted.subject.slice(0, 80)}`,
792+
summary: "Friday detected an explicit sensitive fact request. The value was staged in encrypted secret storage and stays redacted until you review it.",
793+
payload: {
794+
key: extracted.key,
795+
valueRedacted: true,
796+
confidence: 0.84,
797+
secretId: staged.secretId,
798+
secretScope: staged.scope,
799+
secretRefKey: staged.refKey,
800+
},
801+
evidence: {
802+
...redactedEvidence,
803+
secretId: staged.secretId,
804+
secretScope: staged.scope,
805+
secretRefKey: staged.refKey,
806+
stagedAt: now,
807+
stagedSecretCreatedAt: staged.createdAt ?? null,
808+
},
809+
confidence: 0.84,
810+
riskTier: 3,
811+
}));
812+
}
813+
662814
function createPreferenceConfirmationCandidate(db: FridayReflexDb, input: {
663815
userId: string;
664816
category: FridayUserPreferenceCategory;
@@ -1300,6 +1452,28 @@ export function createFridayReflexService(
13001452
learnedFactLastConfirmedAt: result.lastConfirmedAt,
13011453
...(result.factId ? { learnedFactId: result.factId } : {}),
13021454
};
1455+
} else if (candidate.kind === "secure_fact") {
1456+
const key = readPayloadString(candidate.payload, "key");
1457+
const secretId = readPayloadString(candidate.payload, "secretId");
1458+
const secretScope = readPayloadString(candidate.payload, "secretScope");
1459+
const secretRefKey = readPayloadString(candidate.payload, "secretRefKey");
1460+
if (!key || !secretId || !secretScope || !secretRefKey) {
1461+
throw new FridayDomainError(
1462+
"REFLEX_SECURE_FACT_INVALID",
1463+
"Secure-fact candidate payload requires key and encrypted secret reference metadata.",
1464+
{ httpStatus: 400 },
1465+
);
1466+
}
1467+
evidence = {
1468+
...evidence,
1469+
secureFactConfirmed: true,
1470+
secureFactConfirmedAt: deps.nowIso(),
1471+
secureFactKey: key,
1472+
secretId,
1473+
secretScope,
1474+
secretRefKey,
1475+
valueRedacted: true,
1476+
};
13031477
} else if (candidate.kind === "preference") {
13041478
const category = readPayloadString(candidate.payload, "category") as FridayUserPreferenceCategory | undefined;
13051479
const key = readPayloadString(candidate.payload, "key");
@@ -1361,15 +1535,39 @@ export function createFridayReflexService(
13611535
},
13621536

13631537
rejectCandidate(input) {
1538+
const candidate = this.getCandidate({ userId: input.userId, candidateId: input.candidateId });
1539+
const evidence: Record<string, JsonValue> = {
1540+
rejectedBy: input.userId,
1541+
rejectedAt: deps.nowIso(),
1542+
...(input.reason ? { reason: input.reason } : {}),
1543+
};
1544+
if (candidate.kind === "secure_fact") {
1545+
const secretId = readPayloadString(candidate.payload, "secretId");
1546+
const secretScope = readPayloadString(candidate.payload, "secretScope");
1547+
const secretRefKey = readPayloadString(candidate.payload, "secretRefKey");
1548+
if (secretId && secretScope && secretRefKey && deps.secureFactRejecter) {
1549+
const result = deps.secureFactRejecter({
1550+
userId: input.userId,
1551+
candidateId: input.candidateId,
1552+
secretId,
1553+
scope: secretScope,
1554+
refKey: secretRefKey,
1555+
nowIso: deps.nowIso(),
1556+
});
1557+
evidence.stagedSecretDeleted = result.deleted;
1558+
evidence.secretId = secretId;
1559+
evidence.secretScope = secretScope;
1560+
evidence.secretRefKey = secretRefKey;
1561+
} else {
1562+
evidence.stagedSecretDeleted = false;
1563+
evidence.secureFactRejectionCleanup = "unavailable_or_missing_secret_ref";
1564+
}
1565+
}
13641566
return updateCandidateStatus({
13651567
userId: input.userId,
13661568
candidateId: input.candidateId,
13671569
status: "rejected",
1368-
evidence: {
1369-
rejectedBy: input.userId,
1370-
rejectedAt: deps.nowIso(),
1371-
...(input.reason ? { reason: input.reason } : {}),
1372-
},
1570+
evidence,
13731571
});
13741572
},
13751573

@@ -1488,6 +1686,8 @@ export function createFridayReflexService(
14881686
const created: FridayReflexCandidate[] = [];
14891687
const toolSequence = input.toolSequence ?? [];
14901688
if (input.outcome === "success") {
1689+
const secureFactCandidate = createSecureFactCandidateFromRunTask(input);
1690+
if (secureFactCandidate) created.push(secureFactCandidate);
14911691
const learnedFactCandidate = createLearnedFactCandidateFromRunTask(input);
14921692
if (learnedFactCandidate) created.push(learnedFactCandidate);
14931693
}

src/state/sqlite/migrations/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import { V088_AUTO_FIX_ROLLBACK_RECEIPT_MIGRATION } from "./v088-auto-fix-rollba
9090
import { V089_WORKFLOW_COMPLETION_VERIFICATION_MIGRATION } from "./v089-workflow-completion-verification.js";
9191
import { V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION } from "./v090-reflex-learned-fact-candidates.js";
9292
import { V091_PREFERENCE_FACT_METADATA_MIGRATION } from "./v091-preference-fact-metadata.js";
93+
import { V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION } from "./v092-reflex-secure-fact-candidates.js";
9394

9495
/**
9596
* Ordered migration list, always ascending by version.
@@ -186,6 +187,7 @@ export const FRIDAY_SQLITE_MIGRATIONS: readonly FridaySqliteMigration[] = [
186187
V089_WORKFLOW_COMPLETION_VERIFICATION_MIGRATION,
187188
V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION,
188189
V091_PREFERENCE_FACT_METADATA_MIGRATION,
190+
V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION,
189191
];
190192

191193
export { V003_PROVIDER_USAGE_COST_ROUTING_MIGRATION };
@@ -276,3 +278,4 @@ export { V087_ROLLBACK_MATRIX_CLOSEOUT_RECEIPT_MIGRATION };
276278
export { V088_AUTO_FIX_ROLLBACK_RECEIPT_MIGRATION };
277279
export { V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION };
278280
export { V091_PREFERENCE_FACT_METADATA_MIGRATION };
281+
export { V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION };

0 commit comments

Comments
 (0)