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:
@@ -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.
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -6,9 +6,9 @@ import { compileSecretRegex } from "./regex";
|
||||
|
||||
export {
|
||||
deobfuscateSessionContext,
|
||||
deobfuscateToolArguments,
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
obfuscateProviderTools,
|
||||
type SecretEntry,
|
||||
SecretObfuscator,
|
||||
} from "./obfuscator";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user