feat(coding-agent): improved secret obfuscation and data protection

- Refined obfuscation logic to use granular, typed transformations instead of generic object traversal.
- Enforced an 8-character minimum for secret patterns and restricted redaction to user-authored content to prevent false positives.
- Preserved system prompts, tool schemas, and opaque remote replay data to maintain provider context and data integrity.
- Integrated protected snapshot exports with targeted redaction to safeguard sensitive information in shared sessions.
This commit is contained in:
can1357
2026-06-22 00:48:14 +02:00
parent aa1dbde498
commit 7302a7ae96
11 changed files with 796 additions and 190 deletions
+8 -1
View File
@@ -1,9 +1,16 @@
# Changelog
## [Unreleased]
### Changed
- Refined secret obfuscation to only target message roles and fields containing operator secrets
- Restricted obfuscator to ignore secrets and regex matches shorter than 8 characters
- Optimized obfuscation to skip static system prompts and tool schemas in provider contexts
- Ensured image data bytes are never modified to prevent corrupted data URL payloads
### Fixed
- Fixed secret obfuscation corrupting Codex image reads (and other provider requests) with `Invalid 'input[N].content[].image_url'. Expected a base64-encoded data URL ... but got an invalid base64-encoded value`. The obfuscator deep-walked every string in the outbound request — including inline image base64 and opaque provider replay/signature fields — so a configured secret that happened to be a substring of the base64 (or of an ordinary word like `response`) injected `#HASH#` placeholders mid-payload. Obfuscation is now opt-in and fully typed: only user messages, tool-result messages, and user-attributed developer messages (`@file` mentions) are redacted; system prompts and tool schemas pass through untouched; image bytes and signature/encrypted-reasoning fields are never rewritten; and tool-call arguments are the only JSON walked. Configured plain secrets and regex matches shorter than 8 characters are now ignored to stop false matches on short words.
- Fixed RPC/ACP startup clobbering explicit caller/project/global configuration for `task.isolation.{mode,merge,commits}`, `task.eager`, `task.batch`, `task.maxConcurrency`, `task.maxRecursionDepth`, `task.disabledAgents`, `task.agentModelOverrides`, `memory.backend`, `memories.enabled`, `advisor.{enabled,subagents,syncBacklog,immuneTurns}`, plus the RPC-only `async.{enabled,maxJobs}` and `bash.autoBackground.{enabled,thresholdMs}`. `applyDefaultSettingOverrides` re-asserted the schema default as a runtime override after settings load, regressing the `isConfigured()` guard added for #2598 and ignoring every explicit value the embedder, project, `--config` overlay, or global config had set. The guard is restored, so the host default now only fills holes ([#3207](https://github.com/can1357/oh-my-pi/issues/3207)).
## [16.1.11] - 2026-06-21
@@ -12282,4 +12289,4 @@ Initial public release.
## [0.7.6] - 2025-11-13
Previous releases did not maintain a changelog.
Previous releases did not maintain a changelog.
+198 -8
View File
@@ -19,13 +19,16 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentState } from "@oh-my-pi/pi-agent-core";
import type { AgentMessage, AgentState } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { $which, logger } from "@oh-my-pi/pi-utils";
import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
import { $ } from "bun";
import type { SecretObfuscator } from "../secrets/obfuscator";
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
import type { SessionEntry, SessionHeader } from "../session/session-entries";
import type { SessionManager } from "../session/session-manager";
import { buildSessionData, type SessionData } from "./html";
import type { OutputMeta } from "../tools/output-meta";
import { buildSessionData, type SessionData, type SubSession } from "./html";
export { DEFAULT_SHARE_URL };
@@ -53,10 +56,15 @@ export interface ShareSessionOptions {
/** Agent state for system prompt + tool descriptions in the snapshot. */
state?: AgentState;
/**
* Redacts the snapshot before sealing: deep-walks every string (entries,
* header, system prompt, tool descriptions) through the obfuscator, so
* secrets that landed in persisted entries (tool outputs reading .env,
* etc.) never leave the machine. Pass undefined to skip.
* Redacts the snapshot before sealing via a typed, per-field walk over the
* session (header title/cwd, system prompt, tool descriptions, entry summaries,
* labels, and message text — including tool-result output and `@file` mentions),
* so secrets that landed in persisted entries (tool outputs reading .env, etc.)
* never leave the machine. Inline image bytes are preserved (size-trimmed
* separately); opaque provider-replay blobs (`providerPayload`,
* `redactedThinking`, `compaction.preserveData`) and untyped extension payloads
* (`details`/`data`/`outputSchema`) are dropped rather than walked. Pass
* undefined to skip redaction entirely.
*/
obfuscator?: SecretObfuscator;
}
@@ -75,7 +83,189 @@ export interface ShareSessionResult {
/** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */
export function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData {
const data = buildSessionData(sm, options?.state);
return options?.obfuscator?.hasSecrets() ? options.obfuscator.obfuscateObject(data) : data;
return options?.obfuscator?.hasSecrets() ? redactSessionDataForShare(options.obfuscator, data) : data;
}
/**
* Redact secrets from a share snapshot. A share blob leaves the machine, so
* every text-bearing field is rewritten through the obfuscator. The walk is
* typed end-to-end (no generic object traversal): inline image bytes are left
* intact (size-trimmed later by {@link stripImagePayloads}) and opaque,
* untyped payloads we cannot redact field-by-field (`compaction.preserveData`,
* extension `details`/`data`, `mode_change.data`, structured output schemas)
* are dropped so they cannot leak.
*/
function redactShareHeader(o: SecretObfuscator, header: SessionHeader | null): SessionHeader | null {
if (!header) return header;
return {
...header,
title: header.title === undefined ? undefined : o.obfuscate(header.title),
cwd: o.obfuscate(header.cwd),
};
}
function redactSessionDataForShare(o: SecretObfuscator, data: SessionData): SessionData {
return {
...data,
header: redactShareHeader(o, data.header),
systemPrompt: data.systemPrompt === undefined ? undefined : o.obfuscate(data.systemPrompt),
tools: data.tools?.map(tool => ({ ...tool, description: o.obfuscate(tool.description) })),
entries: data.entries.map(entry => redactShareEntry(o, entry)),
subSessions: data.subSessions
? Object.fromEntries(
Object.entries(data.subSessions).map(([key, sub]) => [key, redactShareSubSession(o, sub)]),
)
: data.subSessions,
};
}
function redactShareSubSession(o: SecretObfuscator, sub: SubSession): SubSession {
return {
...sub,
header: redactShareHeader(o, sub.header),
entries: sub.entries.map(entry => redactShareEntry(o, entry)),
};
}
function redactShareEntry(o: SecretObfuscator, entry: SessionEntry): SessionEntry {
switch (entry.type) {
case "message":
return { ...entry, message: redactShareMessage(o, entry.message) };
case "compaction":
return {
...entry,
summary: o.obfuscate(entry.summary),
shortSummary: entry.shortSummary === undefined ? undefined : o.obfuscate(entry.shortSummary),
details: undefined,
preserveData: undefined,
};
case "branch_summary":
return { ...entry, summary: o.obfuscate(entry.summary), details: undefined };
case "custom_message":
return { ...entry, content: redactShareContent(o, entry.content), details: undefined };
case "custom":
return { ...entry, data: undefined };
case "mode_change":
return { ...entry, data: undefined };
case "session_init":
return {
...entry,
systemPrompt: o.obfuscate(entry.systemPrompt),
task: o.obfuscate(entry.task),
outputSchema: undefined,
};
case "label":
return { ...entry, label: entry.label === undefined ? undefined : o.obfuscate(entry.label) };
default:
return entry;
}
}
function redactShareContent(
o: SecretObfuscator,
content: string | (TextContent | ImageContent)[],
): string | (TextContent | ImageContent)[] {
if (typeof content === "string") return o.obfuscate(content);
return content.map(block => (block.type === "text" ? { ...block, text: o.obfuscate(block.text) } : block));
}
/** Redact freeform strings in tool output metadata (source path/URL, diagnostics); numeric truncation info is preserved. */
function redactShareOutputMeta(o: SecretObfuscator, meta: OutputMeta | undefined): OutputMeta | undefined {
if (!meta) return meta;
return {
...meta,
source: meta.source ? { ...meta.source, value: o.obfuscate(meta.source.value) } : meta.source,
diagnostics: meta.diagnostics
? {
summary: o.obfuscate(meta.diagnostics.summary),
messages: meta.diagnostics.messages.map(message => o.obfuscate(message)),
}
: meta.diagnostics,
};
}
function redactShareMessage(o: SecretObfuscator, message: AgentMessage): AgentMessage {
switch (message.role) {
case "user":
case "developer":
return {
...message,
providerPayload: undefined,
content: redactShareContent(o, message.content),
} as AgentMessage;
case "custom":
case "hookMessage":
return { ...message, details: undefined, content: redactShareContent(o, message.content) } as AgentMessage;
case "toolResult":
return {
...message,
details: undefined,
content: redactShareContent(o, message.content) as (TextContent | ImageContent)[],
};
case "assistant":
// Drop opaque provider-replay state (encrypted reasoning / native history) the viewer
// never reads and we cannot redact field-by-field: `providerPayload` and any
// `redactedThinking` blocks.
return {
...message,
providerPayload: undefined,
errorMessage: message.errorMessage === undefined ? undefined : o.obfuscate(message.errorMessage),
content: message.content.flatMap((block): AssistantMessage["content"] => {
if (block.type === "redactedThinking") return [];
if (block.type === "text") return [{ ...block, text: o.obfuscate(block.text) }];
if (block.type === "thinking") return [{ ...block, thinking: o.obfuscate(block.thinking) }];
if (block.type === "toolCall") {
return [
{
...block,
arguments: obfuscateToolArguments(o, block.arguments),
intent: block.intent === undefined ? undefined : o.obfuscate(block.intent),
rawBlock: block.rawBlock === undefined ? undefined : o.obfuscate(block.rawBlock),
},
];
}
return [block];
}),
};
case "bashExecution":
return {
...message,
command: o.obfuscate(message.command),
output: o.obfuscate(message.output),
meta: redactShareOutputMeta(o, message.meta),
};
case "pythonExecution":
return {
...message,
code: o.obfuscate(message.code),
output: o.obfuscate(message.output),
meta: redactShareOutputMeta(o, message.meta),
};
case "branchSummary":
return { ...message, summary: o.obfuscate(message.summary) };
case "compactionSummary":
return {
...message,
providerPayload: undefined,
summary: o.obfuscate(message.summary),
shortSummary: message.shortSummary === undefined ? undefined : o.obfuscate(message.shortSummary),
blocks:
message.blocks === undefined
? undefined
: (redactShareContent(o, message.blocks) as (TextContent | ImageContent)[]),
};
case "fileMention":
return {
...message,
files: message.files.map(file => ({
...file,
path: o.obfuscate(file.path),
content: o.obfuscate(file.content),
})),
};
default:
return message;
}
}
/** Share the session; tries a secret gist first, then the share server. */
+2 -1
View File
@@ -97,6 +97,7 @@ import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
import {
collectEnvSecrets,
deobfuscateSessionContext,
deobfuscateToolArguments,
loadSecrets,
obfuscateMessages,
obfuscateProviderContext,
@@ -2535,7 +2536,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
result = { ...result, timeout: Math.min(result.timeout, maxTimeout) };
}
if (obfuscator?.hasSecrets()) {
result = obfuscator.deobfuscateObject(result);
result = deobfuscateToolArguments(obfuscator, result);
}
return result;
},
+1 -1
View File
@@ -6,9 +6,9 @@ import { compileSecretRegex } from "./regex";
export {
deobfuscateSessionContext,
deobfuscateToolArguments,
obfuscateMessages,
obfuscateProviderContext,
obfuscateProviderTools,
type SecretEntry,
SecretObfuscator,
} from "./obfuscator";
+224 -75
View File
@@ -1,5 +1,5 @@
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Context, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
import type { SessionContext } from "../session/session-context";
import { compileSecretRegex } from "./regex";
@@ -15,6 +15,9 @@ export interface SecretEntry {
flags?: string;
}
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined };
export type JsonRecord = { [key: string]: JsonValue | undefined };
// ═══════════════════════════════════════════════════════════════════════════
// Deterministic replacement generation
// ═══════════════════════════════════════════════════════════════════════════
@@ -85,26 +88,34 @@ export class SecretObfuscator {
constructor(entries: SecretEntry[]) {
let index = 0;
let hasRealSec = false;
for (const entry of entries) {
const mode = entry.mode ?? "obfuscate";
if (entry.type === "plain") {
if (mode === "obfuscate") {
if (entry.content.length < 8) {
// Tone down short plain secret obfuscation to avoid false matches on small words like "esp"
continue;
}
const placeholder = buildPlaceholder(index);
this.#plainMappings.set(entry.content, index);
this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
this.#deobfuscateMap.set(placeholder, entry.content);
index++;
hasRealSec = true;
} else {
// replace mode
const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content);
this.#replaceMappings.set(entry.content, replacement);
hasRealSec = true;
}
} else {
// regex type — compiled here, matches discovered during obfuscate()
try {
const regex = compileSecretRegex(entry.content, entry.flags);
this.#regexEntries.push({ regex, mode, replacement: entry.replacement });
hasRealSec = true;
} catch {
// Invalid regex — skip silently (validation happens at load time)
}
@@ -112,7 +123,7 @@ export class SecretObfuscator {
}
this.#nextIndex = index;
this.#hasAny = entries.length > 0;
this.#hasAny = hasRealSec;
}
hasSecrets(): boolean {
@@ -154,6 +165,10 @@ export class SecretObfuscator {
const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue);
result = replaceAll(result, matchValue, replacement);
} else {
if (matchValue.length < 8) {
// Tone down short regex match obfuscation to avoid false matches on small words/fragments
continue;
}
// obfuscate mode — get or create stable index
let index = this.#findObfuscateIndex(matchValue);
if (index === undefined) {
@@ -174,30 +189,13 @@ export class SecretObfuscator {
/** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */
deobfuscate(text: string): string {
if (!this.#hasAny || !text.includes("#")) return text;
return text.replace(PLACEHOLDER_RE, match => {
return this.#deobfuscateMap.get(match) ?? match;
});
return text.replace(PLACEHOLDER_RE, match => this.#deobfuscateMap.get(match) ?? match);
}
/** Deep-walk an object, deobfuscating all string values. */
deobfuscateObject<T>(obj: T): T {
if (!this.#hasAny) return obj;
return deepWalkStrings(obj, s => this.deobfuscate(s));
}
/** Deep-walk an object, obfuscating all string values. */
obfuscateObject<T>(obj: T): T {
if (!this.#hasAny) return obj;
return deepWalkStrings(obj, s => this.obfuscate(s));
}
/** Find the obfuscate index for a known secret value. */
#findObfuscateIndex(secret: string): number | undefined {
// Check plain mappings first
const plainIndex = this.#plainMappings.get(secret);
if (plainIndex !== undefined) return plainIndex;
// Check regex-discovered mappings
for (const [index, mapping] of this.#obfuscateMappings) {
if (mapping.secret === secret) return index;
}
@@ -205,47 +203,200 @@ export class SecretObfuscator {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Display restore (inbound, persisted/provider → local display)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Restore secret placeholders for local display. Only message kinds the model
* itself authored from obfuscated context carry placeholders — assistant
* content and the LLM-written branch/compaction summaries. User, developer, and
* tool-result messages are persisted with their literal text, so a literal
* `#ABCD#` the operator typed must survive untouched; those roles are never
* walked.
*/
export function deobfuscateSessionContext(
sessionContext: SessionContext,
obfuscator: SecretObfuscator | undefined,
): SessionContext {
if (!obfuscator?.hasSecrets()) return sessionContext;
const messages = obfuscator.deobfuscateObject(sessionContext.messages);
const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages);
return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages };
}
// ═══════════════════════════════════════════════════════════════════════════
// Message obfuscation (outbound to LLM)
// ═══════════════════════════════════════════════════════════════════════════
/** Obfuscate all string content in LLM messages (for outbound interception). */
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
return obfuscator.obfuscateObject(messages);
export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
let changed = false;
const result = messages.map((message): AgentMessage => {
switch (message.role) {
case "assistant": {
const content = deobfuscateAssistantContent(obfuscator, message.content);
if (content === message.content) return message;
changed = true;
return { ...message, content };
}
case "branchSummary": {
const summary = obfuscator.deobfuscate(message.summary);
if (summary === message.summary) return message;
changed = true;
return { ...message, summary };
}
case "compactionSummary": {
const summary = obfuscator.deobfuscate(message.summary);
const shortSummary =
message.shortSummary === undefined ? undefined : obfuscator.deobfuscate(message.shortSummary);
const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks);
if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
return message;
}
changed = true;
return { ...message, summary, shortSummary, blocks };
}
default:
return message;
}
});
return changed ? result : messages;
}
/** Obfuscate provider request context without walking live tool schema instances. */
/**
* Restore placeholders in assistant content: visible text, thinking text, and
* tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
* are opaque provider-replay data and pass through byte-identical.
*/
export function deobfuscateAssistantContent(
obfuscator: SecretObfuscator,
content: AssistantMessage["content"],
): AssistantMessage["content"] {
if (!obfuscator.hasSecrets()) return content;
let changed = false;
const result = content.map((block): AssistantMessage["content"][number] => {
if (block.type === "text") {
const text = obfuscator.deobfuscate(block.text);
if (text === block.text) return block;
changed = true;
return { ...block, text };
}
if (block.type === "thinking") {
const thinking = obfuscator.deobfuscate(block.thinking);
if (thinking === block.thinking) return block;
changed = true;
return { ...block, thinking };
}
if (block.type === "toolCall") {
const args = deobfuscateToolArguments(obfuscator, block.arguments);
const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent);
const rawBlock = block.rawBlock === undefined ? undefined : obfuscator.deobfuscate(block.rawBlock);
if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
changed = true;
return { ...block, arguments: args, intent, rawBlock };
}
return block;
});
return changed ? result : content;
}
/**
* Restore placeholders inside a tool call's arguments. Arguments are arbitrary
* model-authored JSON, so tool-call arguments are the ONLY place a recursive
* JSON walk runs.
*/
export function deobfuscateToolArguments(
obfuscator: SecretObfuscator,
args: Record<string, unknown>,
): Record<string, unknown> {
if (!obfuscator.hasSecrets()) return args;
return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
}
/** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
export function obfuscateToolArguments(
obfuscator: SecretObfuscator,
args: Record<string, unknown>,
): Record<string, unknown> {
if (!obfuscator.hasSecrets()) return args;
return mapJsonStrings(args as JsonValue, s => obfuscator.obfuscate(s)) as Record<string, unknown>;
}
// ═══════════════════════════════════════════════════════════════════════════
// Outbound obfuscation (local → provider)
// ═══════════════════════════════════════════════════════════════════════════
type UserFacingMessage = Extract<Message, { role: "user" | "developer" | "toolResult" }>;
/** Obfuscate `text` blocks of a content array; image and other blocks pass through. */
function obfuscateTextBlocks(
obfuscator: SecretObfuscator,
content: (TextContent | ImageContent)[],
): (TextContent | ImageContent)[] {
let changed = false;
const result = content.map((block): TextContent | ImageContent => {
if (block.type !== "text") return block;
const text = obfuscator.obfuscate(block.text);
if (text === block.text) return block;
changed = true;
return { ...block, text };
});
return changed ? result : content;
}
/** Restore placeholders in `text` blocks of a content array; image and other blocks pass through. */
function deobfuscateTextBlocks(
obfuscator: SecretObfuscator,
content: (TextContent | ImageContent)[],
): (TextContent | ImageContent)[] {
let changed = false;
const result = content.map((block): TextContent | ImageContent => {
if (block.type !== "text") return block;
const text = obfuscator.deobfuscate(block.text);
if (text === block.text) return block;
changed = true;
return { ...block, text };
});
return changed ? result : content;
}
/**
* Redact secrets from outbound messages. Opt-in by origin: only user messages,
* tool results, and user-authored developer messages (e.g. `@file` mentions)
* can carry operator secrets. System prompts, tool schemas, and assistant
* output are author-controlled or model-generated and pass through untouched.
* Within a targeted message only `text` blocks are rewritten — inline image
* bytes are never walked.
*/
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
if (!obfuscator.hasSecrets()) return messages;
let changed = false;
const result = messages.map((message): Message => {
if (
message.role !== "user" &&
message.role !== "toolResult" &&
!(message.role === "developer" && message.attribution === "user")
) {
return message;
}
const target = message as UserFacingMessage;
if (typeof target.content === "string") {
const content = obfuscator.obfuscate(target.content);
if (content === target.content) return message;
changed = true;
return { ...target, content } as Message;
}
const content = obfuscateTextBlocks(obfuscator, target.content);
if (content === target.content) return message;
changed = true;
return { ...target, content } as Message;
});
return changed ? result : messages;
}
/**
* Redact outbound provider context. Only conversation messages are rewritten;
* the static system prompt and tool schemas pass through unchanged.
*/
export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
if (!obfuscator?.hasSecrets()) return context;
return {
...context,
systemPrompt: obfuscator.obfuscateObject(context.systemPrompt),
messages: obfuscator.obfuscateObject(context.messages),
tools: obfuscateProviderTools(obfuscator, context.tools),
};
}
/** Convert tool schemas to wire JSON Schema before obfuscating provider-visible strings. */
export function obfuscateProviderTools(
obfuscator: SecretObfuscator | undefined,
tools: Tool[] | undefined,
): Tool[] | undefined {
if (!tools || !obfuscator?.hasSecrets()) return tools;
return tools.map(tool => ({
...tool,
description: obfuscator.obfuscate(tool.description),
parameters: obfuscator.obfuscateObject(toolWireSchema(tool)),
customFormat: tool.customFormat ? obfuscator.obfuscateObject(tool.customFormat) : undefined,
}));
const messages = obfuscateMessages(obfuscator, context.messages);
return messages === context.messages ? context : { ...context, messages };
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -264,35 +415,33 @@ function replaceAll(text: string, search: string, replacement: string): string {
return result;
}
/** Deep-walk an object, transforming all string values. */
function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
if (typeof obj === "string") {
return transform(obj) as unknown as T;
}
if (Array.isArray(obj)) {
/**
* Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose
* shape is model-authored and not known ahead of time. No other caller may walk
* untyped data: every message/content path is handled by a typed transformer.
*/
function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue {
if (typeof value === "string") return fn(value);
if (Array.isArray(value)) {
let changed = false;
const result = obj.map(item => {
const transformed = deepWalkStrings(item, transform);
if (transformed !== item) changed = true;
return transformed;
const out = value.map(item => {
const next = mapJsonStrings(item, fn);
if (next !== item) changed = true;
return next;
});
return (changed ? result : obj) as unknown as T;
return changed ? out : value;
}
if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
if (value !== null && typeof value === "object") {
let changed = false;
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const value = (obj as Record<string, unknown>)[key];
const transformed = deepWalkStrings(value, transform);
if (transformed !== value) changed = true;
result[key] = transformed;
const out: JsonRecord = {};
for (const key of Object.keys(value)) {
const item = value[key];
if (item === undefined) continue;
const next = mapJsonStrings(item, fn);
if (next !== item) changed = true;
out[key] = next;
}
return (changed ? result : obj) as T;
return changed ? out : value;
}
return obj;
}
function isPlainRecord(obj: object): obj is Record<string, unknown> {
const prototype = Object.getPrototypeOf(obj);
return prototype === Object.prototype || prototype === null;
return value;
}
@@ -234,9 +234,10 @@ import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { t
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
import {
deobfuscateAssistantContent,
deobfuscateSessionContext,
obfuscateMessages,
obfuscateProviderContext,
obfuscateProviderTools,
type SecretObfuscator,
} from "../secrets/obfuscator";
import { invalidateHostMetadata } from "../ssh/connection-manager";
@@ -2500,7 +2501,7 @@ export class AgentSession {
const obfuscator = this.#obfuscator;
if (obfuscator && event.type === "message_end" && event.message.role === "assistant") {
const message = event.message;
const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
const deobfuscatedContent = deobfuscateAssistantContent(obfuscator, message.content);
if (deobfuscatedContent !== message.content) {
displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
}
@@ -5232,28 +5233,17 @@ export class AgentSession {
return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
}
#obfuscateForProvider<T>(value: T): T {
if (!this.#obfuscator?.hasSecrets()) return value;
return this.#obfuscator.obfuscateObject(value);
}
#obfuscateTextForProvider(text: string | undefined): string | undefined {
if (!text || !this.#obfuscator?.hasSecrets()) return text;
return this.#obfuscator.obfuscate(text);
}
#obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
if (!this.#obfuscator?.hasSecrets()) return preparation;
if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
return {
...preparation,
previousSummary: preparation.previousSummary
? this.#obfuscator.obfuscate(preparation.previousSummary)
: preparation.previousSummary,
previousPreserveData: preparation.previousPreserveData
? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
: preparation.previousPreserveData,
};
if (!this.#obfuscator?.hasSecrets() || !preparation.previousSummary) return preparation;
// `previousPreserveData` is opaque provider-replay state (e.g. OpenAI remote-compaction
// `encrypted_content`); rewriting it would corrupt replay, so only the plaintext summary
// is redacted.
return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) };
}
#deobfuscateFromProvider(text: string): string {
@@ -5270,7 +5260,8 @@ export class AgentSession {
}
#convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
return this.#obfuscateForProvider(convertToLlm(messages));
const converted = convertToLlm(messages);
return this.#obfuscator?.hasSecrets() ? obfuscateMessages(this.#obfuscator, converted) : converted;
}
/** Convert session messages using the same pre-LLM pipeline as the active session. */
@@ -7865,8 +7856,8 @@ export class AgentSession {
compactionAbortController.signal,
{
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
},
);
@@ -8060,11 +8051,10 @@ export class AgentSession {
model,
this.#modelRegistry.resolver(model, this.sessionId),
{
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
tools: obfuscateProviderTools(
this.#obfuscator,
this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools,
),
systemPrompt: this.#baseSystemPrompt,
tools: this.#pruneToolDescriptions
? stripToolDescriptions(this.agent.state.tools)
: this.agent.state.tools,
customInstructions: this.#obfuscateTextForProvider(customInstructions),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
initiatorOverride: "agent",
@@ -9752,8 +9742,8 @@ export class AgentSession {
autoCompactionSignal,
{
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
@@ -11379,7 +11369,7 @@ export class AgentSession {
}
if (event.type === "done") {
assistantMessage = this.#obfuscator?.hasSecrets()
? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) }
? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, event.message.content) }
: event.message;
break;
}
@@ -264,7 +264,7 @@ describe("AgentSession handoff", () => {
expect(result?.document).not.toContain(placeholder);
});
it("obfuscates previous compaction summary and preserve data before forwarding to compact()", async () => {
it("obfuscates the previous compaction summary but preserves opaque replay data", async () => {
session.settings.set("compaction.strategy", "context-full");
const placeholder = obfuscator.obfuscate(HANDOFF_SECRET);
const entries = sessionManager.getBranch();
@@ -302,9 +302,9 @@ describe("AgentSession handoff", () => {
if (!call) throw new Error("Expected compact call");
expect(call[0].previousSummary).toBe(`summary ${placeholder}`);
expect(call[0].previousSummary).not.toContain(HANDOFF_SECRET);
const preserveData = JSON.stringify(call[0].previousPreserveData);
expect(preserveData).toContain(placeholder);
expect(preserveData).not.toContain(HANDOFF_SECRET);
// Opaque provider-replay state (encrypted_content / replacementHistory) must pass through
// byte-identical — rewriting it would corrupt OpenAI remote-compaction replay.
expect(call[0].previousPreserveData).toBe(fixedPreparation.previousPreserveData);
});
it("runs context maintenance before sending an oversized pending prompt", async () => {
@@ -396,7 +396,7 @@ describe("AgentSession message pipeline", () => {
expect(capturedOptions?.openrouterVariant).toBe("nitro");
});
it("obfuscates the system prompt and messages on ephemeral side-channel requests", async () => {
it("obfuscates user messages on ephemeral side-channel requests", async () => {
const api = "test-ephemeral-secret-redaction";
const secret = "EPHEMERAL_SECRET_TOKEN_12345";
let capturedContext: Context | undefined;
@@ -427,7 +427,7 @@ describe("AgentSession message pipeline", () => {
agent: new Agent({
initialState: {
model,
systemPrompt: [`system prompt with ${secret}`],
systemPrompt: ["system prompt"],
messages: [],
tools: [],
},
@@ -443,6 +443,7 @@ describe("AgentSession message pipeline", () => {
expect(result.replyText).toBe("Answer");
expect(capturedContext).toBeDefined();
// The secret entered only via the user prompt, which the opt-in obfuscator redacts.
expect(JSON.stringify(capturedContext)).not.toContain(secret);
});
@@ -516,10 +517,12 @@ describe("AgentSession message pipeline", () => {
await agent.prompt("Main Question?");
await session.runEphemeralTurn({ promptText: `Side Question ${secret}?` });
// The static prefix (system prompt + tools) is left untouched, so it stays byte-identical
// between the main turn and the side turn and the prompt cache prefix survives.
expect(JSON.stringify(mainContext?.systemPrompt)).toBe(JSON.stringify(sideContext?.systemPrompt));
expect(JSON.stringify(mainContext?.tools)).toBe(JSON.stringify(sideContext?.tools));
expect(JSON.stringify(sideContext?.systemPrompt)).not.toContain(secret);
expect(JSON.stringify(sideContext?.tools)).not.toContain(secret);
// The side turn's user prompt secret is redacted from the outbound messages.
expect(JSON.stringify(sideContext?.messages)).not.toContain(secret);
});
});
@@ -157,7 +157,7 @@ describe("AgentSession silent-abort marker stamping", () => {
});
it("A4: marker is stamped on event.message BEFORE the obfuscator's displayEvent copy", async () => {
// Build a real obfuscator with a `plain` secret so `deobfuscateObject(content)`
// Build a real obfuscator with a `plain` secret so `deobfuscateAssistantContent(content)`
// returns a NEW content array — that's the only path that triggers the
// `displayEvent = { ...event, message: { ...message, content } }` spread copy
// in `#handleAgentEvent`. The marker must be stamped BEFORE that spread so
@@ -3,8 +3,11 @@
*/
import { describe, expect, it } from "bun:test";
import type { Context, Message } from "@oh-my-pi/pi-ai";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Context, Message } from "@oh-my-pi/pi-ai";
import {
deobfuscateAgentMessages,
deobfuscateToolArguments,
obfuscateMessages,
obfuscateProviderContext,
SecretObfuscator,
@@ -49,48 +52,36 @@ describe("SecretObfuscator regex behavior", () => {
expect(obfuscated).not.toEqual(text);
expect(obfuscator.deobfuscate(obfuscated)).toEqual(text);
});
it("deobfuscates placeholders through object payloads", () => {
it("deobfuscates placeholders through tool-call arguments", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+", flags: "i" }]);
const original = {
cmd: "API_KEY=abc and api-key=def",
status: "ok",
};
const original = { cmd: "API_KEY=abc and api-key=def", status: "ok", nested: { note: "API_KEY=zzz" } };
const obfuscated = {
cmd: obfuscator.obfuscate(original.cmd),
status: original.status,
nested: { note: obfuscator.obfuscate(original.nested.note) },
};
expect(obfuscator.deobfuscateObject(obfuscated)).toEqual({
cmd: original.cmd,
status: original.status,
});
expect(JSON.stringify(obfuscated)).not.toContain("API_KEY=abc");
expect(deobfuscateToolArguments(obfuscator, obfuscated)).toEqual(original);
});
it("obfuscates nested provider request payloads", () => {
it("obfuscates conversation messages but leaves the system prompt untouched", () => {
const secret = "SUPER_SECRET_TOKEN_12345";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const payload = {
const context: Context = {
systemPrompt: [`workspace contains ${secret}`],
messages: [],
tools: [
{
name: "handoff",
description: `preserve ${secret}`,
parameters: {
type: "object",
properties: { note: { type: "string", description: `write ${secret}` } },
},
},
],
messages: [{ role: "user", content: `use ${secret}`, timestamp: 1 }],
};
const obfuscated = obfuscateProviderContext(obfuscator, payload);
const serialized = JSON.stringify(obfuscated);
const obfuscated = obfuscateProviderContext(obfuscator, context);
expect(serialized).not.toContain(secret);
expect(obfuscator.deobfuscateObject(obfuscated).tools?.[0]?.description).toEqual(payload.tools[0]?.description);
// Conversation messages are redacted (and round-trip back to the secret)...
expect(JSON.stringify(obfuscated.messages)).not.toContain(secret);
expect(obfuscator.deobfuscate(JSON.stringify(obfuscated.messages))).toContain(secret);
// ...but the author-controlled system prompt passes through by reference.
expect(obfuscated.systemPrompt).toBe(context.systemPrompt);
});
it("redacts arktype tool schemas without cloning the live schema instance", () => {
it("leaves tool schemas untouched in provider context (no clone, no redaction)", () => {
const secret = "SUPER_SECRET_TOKEN_12345";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const parameters = type({
@@ -109,48 +100,109 @@ describe("SecretObfuscator regex behavior", () => {
const obfuscated = obfuscateProviderContext(obfuscator, context);
expect(obfuscator.obfuscateObject(parameters)).toBe(parameters);
expect(context.tools?.[0]?.parameters).toBe(parameters);
expect(obfuscated.tools?.[0]?.parameters).not.toBe(parameters);
expect(JSON.stringify(obfuscated)).not.toContain(secret);
expect(obfuscated.tools).toBe(context.tools);
expect(obfuscated.tools?.[0]?.parameters).toBe(parameters);
});
it("obfuscates system reminders and assistant tool calls in messages", () => {
it("redacts only user, tool-result, and user-attributed developer messages", () => {
const secret = "SUPER_SECRET_TOKEN_12345";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const messages: Message[] = [
{ role: "developer", content: `system reminder ${secret}`, timestamp: 1 },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "handoff",
arguments: { note: secret },
intent: `handoff ${secret}`,
},
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
const userMsg: Message = { role: "user", content: `user says ${secret}`, timestamp: 1 };
const systemDeveloperMsg: Message = { role: "developer", content: `system reminder ${secret}`, timestamp: 1 };
const fileMentionMsg: Message = {
role: "developer",
content: `<file>${secret}</file>`,
attribution: "user",
timestamp: 1,
};
const assistantMsg: Message = {
role: "assistant",
content: [
{
type: "toolCall",
id: "call_1",
name: "handoff",
arguments: { note: secret },
intent: `handoff ${secret}`,
},
stopReason: "toolUse",
timestamp: 1,
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
];
stopReason: "toolUse",
timestamp: 1,
};
const toolResultMsg: Message = {
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: `tool output ${secret}` }],
isError: false,
timestamp: 1,
};
const obfuscated = obfuscateMessages(obfuscator, messages);
const obfuscated = obfuscateMessages(obfuscator, [
userMsg,
systemDeveloperMsg,
fileMentionMsg,
assistantMsg,
toolResultMsg,
]);
expect(JSON.stringify(obfuscated)).not.toContain(secret);
expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages);
// User, user-attributed developer, and tool results are redacted.
expect(JSON.stringify(obfuscated[0])).not.toContain(secret);
expect(JSON.stringify(obfuscated[2])).not.toContain(secret);
expect(JSON.stringify(obfuscated[4])).not.toContain(secret);
// System developer reminders and assistant output pass through untouched (same reference).
expect(obfuscated[1]).toBe(systemDeveloperMsg);
expect(obfuscated[3]).toBe(assistantMsg);
});
it("never rewrites inline image bytes", () => {
const secret = "SUPER_SECRET_TOKEN_12345";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
// A base64 payload that literally contains the secret substring must survive byte-identical;
// rewriting it would corrupt the data URL (the Codex "invalid base64" failure).
const imageData = `iVBORw0KGgo${secret}AAAASUVORK5CYII=`;
const message: Message = {
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [
{ type: "text", text: `read ${secret}` },
{ type: "image", data: imageData, mimeType: "image/png" },
],
isError: false,
timestamp: 1,
};
const [obfuscated] = obfuscateMessages(obfuscator, [message]) as [typeof message];
const blocks = obfuscated.content;
const image = blocks[1];
const text = blocks[0];
// Image bytes untouched...
expect(image.type === "image" && image.data).toBe(imageData);
// ...while the adjacent text is redacted.
expect(text.type === "text" && text.text.includes(secret)).toBe(false);
});
it("ignores configured plain secrets shorter than 8 characters", () => {
const obfuscator = new SecretObfuscator([{ type: "plain", content: "esp" }]);
expect(obfuscator.hasSecrets()).toBe(false);
expect(obfuscator.obfuscate("the response despite whitespace")).toBe("the response despite whitespace");
});
it("ignores regex matches shorter than 8 characters", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "esp" }]);
expect(obfuscator.obfuscate("the response despite whitespace")).toBe("the response despite whitespace");
});
});
@@ -180,17 +232,17 @@ describe("SecretObfuscator cross-turn cache stability", () => {
it("keeps earlier message placeholders stable when a later message reveals a new regex secret", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "tok_[a-z0-9]+" }]);
const early: Message[] = [{ role: "user", content: "first uses tok_aaa", timestamp: 1 }];
const early: Message[] = [{ role: "user", content: "first uses tok_aaaa", timestamp: 1 }];
// Turn N: only the early message exists; tok_aaa mints a fresh placeholder.
const earlyTurnN = JSON.stringify(obfuscateMessages(obfuscator, early));
expect(earlyTurnN).not.toContain("tok_aaa");
expect(earlyTurnN).not.toContain("tok_aaaa");
// A later turn reveals a brand-new secret. Lazy regex discovery assigns it a fresh
// index — this MUST NOT shift the placeholder already minted for tok_aaa.
const later: Message[] = [{ role: "user", content: "later uses tok_bbb", timestamp: 2 }];
const later: Message[] = [{ role: "user", content: "later uses tok_bbbb", timestamp: 2 }];
const laterOut = JSON.stringify(obfuscateMessages(obfuscator, later));
expect(laterOut).not.toContain("tok_bbb");
expect(laterOut).not.toContain("tok_bbbb");
// Re-obfuscate the early message after the new discovery: identical bytes → the
// already-cached prefix for the early message stays valid.
@@ -198,3 +250,95 @@ describe("SecretObfuscator cross-turn cache stability", () => {
expect(earlyTurnNPlus1).toEqual(earlyTurnN);
});
});
describe("deobfuscateAgentMessages (display restore)", () => {
it("restores assistant content and model-generated summaries, leaving raw user text untouched", () => {
const secret = "DISPLAY_SECRET_TOKEN_123";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const placeholder = obfuscator.obfuscate(secret);
expect(placeholder).not.toBe(secret);
const userMsg: AgentMessage = { role: "user", content: `literal ${placeholder} token`, timestamp: 1 };
const assistantMsg: AgentMessage = {
role: "assistant",
content: [
{ type: "text", text: `answer ${placeholder}` },
{ type: "thinking", thinking: `reason ${placeholder}` },
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: `path ${placeholder}` },
intent: `intent ${placeholder}`,
},
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
};
const branchSummary: AgentMessage = {
role: "branchSummary",
summary: `branch ${placeholder}`,
fromId: "x",
timestamp: 3,
};
const compactionSummary: AgentMessage = {
role: "compactionSummary",
summary: `compact ${placeholder}`,
shortSummary: `short ${placeholder}`,
tokensBefore: 0,
timestamp: 4,
};
const restored = deobfuscateAgentMessages(obfuscator, [userMsg, assistantMsg, branchSummary, compactionSummary]);
// Assistant text, thinking, and tool-call args/intent are restored to the real secret.
const restoredAssistant = restored[1] as AssistantMessage;
const assistantJson = JSON.stringify(restoredAssistant.content);
expect(assistantJson).toContain(secret);
expect(assistantJson).not.toContain(placeholder);
// Model-generated summaries are restored.
expect((restored[2] as { summary: string }).summary).toBe(`branch ${secret}`);
expect((restored[3] as { summary: string; shortSummary?: string }).summary).toBe(`compact ${secret}`);
expect((restored[3] as { summary: string; shortSummary?: string }).shortSummary).toBe(`short ${secret}`);
// The user message is persisted raw and never walked: a literal placeholder-shaped token
// survives byte-identical (same reference) rather than being turned into the secret.
expect(restored[0]).toBe(userMsg);
});
it("restores compactionSummary block text while leaving snapcompact image bytes intact", () => {
const secret = "BLOCKS_SECRET_TOKEN_456";
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const placeholder = obfuscator.obfuscate(secret);
const imageData = `frame${secret}bytes==`;
const message: AgentMessage = {
role: "compactionSummary",
summary: `summary ${placeholder}`,
tokensBefore: 0,
blocks: [
{ type: "text", text: `archived ${placeholder}` },
{ type: "image", data: imageData, mimeType: "image/png" },
],
timestamp: 1,
};
const [restored] = deobfuscateAgentMessages(obfuscator, [message]) as [typeof message];
const blocks = restored.blocks ?? [];
const text = blocks[0];
const image = blocks[1];
// Archived text is restored to the real secret...
expect(text.type === "text" && text.text).toBe(`archived ${secret}`);
// ...while the snapcompact image bytes pass through untouched.
expect(image.type === "image" && image.data).toBe(imageData);
});
});
+122
View File
@@ -121,6 +121,128 @@ describe("buildShareSnapshot", () => {
const plain = buildShareSnapshot(sm, {});
expect(JSON.stringify(plain)).toContain("hunter2-XYZZY");
});
test("redacts header cwd, bookmark labels, and file-mention paths", () => {
const secret = "shareleak-ABCDE";
const ts = "2026-06-12T00:00:00.000Z";
const entries: SessionEntry[] = [
{
type: "label",
id: "l1",
parentId: null,
timestamp: ts,
targetId: "e1",
label: `bookmark ${secret}`,
} as SessionEntry,
{
type: "message",
id: "e1",
parentId: null,
timestamp: ts,
message: {
role: "fileMention",
files: [{ path: `/home/${secret}/.env`, content: `KEY=${secret}` }],
timestamp: 1,
},
} as unknown as SessionEntry,
];
const header = { type: "session", version: 3, id: "t", timestamp: ts, cwd: `/home/${secret}/proj` };
const sm = {
getHeader: () => header,
getEntries: () => entries,
getLeafId: () => "e1",
} as unknown as SessionManager;
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const snapshot = buildShareSnapshot(sm, { obfuscator });
const flat = JSON.stringify(snapshot);
// cwd, label, file path, and file content are all redacted...
expect(flat).not.toContain(secret);
// ...while surrounding structure (the path shape) survives.
expect(flat).toContain("/.env");
// Source entries keep the real values; redaction is share-only.
expect(JSON.stringify(entries)).toContain(secret);
});
test("redacts assistant tool calls / error messages and bash meta, and drops provider replay payloads", () => {
const secret = "asst-secret-ABCDE";
const replaySentinel = "REPLAY_BLOB_SENTINEL_XYZ";
const ts = "2026-06-12T00:00:00.000Z";
const usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const entries: SessionEntry[] = [
{
type: "message",
id: "a1",
parentId: null,
timestamp: ts,
message: {
role: "assistant",
content: [
{ type: "text", text: `answer ${secret}` },
{
type: "toolCall",
id: "c1",
name: "read",
arguments: { path: `/x/${secret}` },
intent: `intent ${secret}`,
rawBlock: `raw ${secret}`,
},
],
api: "test",
provider: "test",
model: "test",
usage,
stopReason: "toolUse",
errorMessage: `boom ${secret}`,
providerPayload: { type: "openaiResponsesHistory", items: [{ note: replaySentinel }] },
timestamp: 1,
},
} as unknown as SessionEntry,
{
type: "message",
id: "b1",
parentId: "a1",
timestamp: ts,
message: {
role: "bashExecution",
command: `echo ${secret}`,
output: `out ${secret}`,
exitCode: 0,
cancelled: false,
truncated: false,
meta: {
source: { type: "path", value: `/home/${secret}/log` },
diagnostics: { summary: `diag ${secret}`, messages: [`msg ${secret}`] },
},
timestamp: 2,
},
} as unknown as SessionEntry,
];
const sm = {
getHeader: () => sessionData([], "x").header,
getEntries: () => entries,
getLeafId: () => "b1",
} as unknown as SessionManager;
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
const flat = JSON.stringify(buildShareSnapshot(sm, { obfuscator }));
// Every freeform occurrence (text, tool-call args/intent/rawBlock, errorMessage, bash output + meta) is redacted.
expect(flat).not.toContain(secret);
// Opaque provider-replay payload is dropped wholesale — the sentinel is NOT a configured secret,
// so its absence proves the subtree was removed rather than merely obfuscated.
expect(flat).not.toContain(replaySentinel);
// Source entries keep the real values; redaction is share-only.
expect(JSON.stringify(entries)).toContain(secret);
});
});
describe("normalizeShareServerUrl", () => {