From 309223a8bca0a8b348ed6f11a9cf673b7fad75d3 Mon Sep 17 00:00:00 2001
From: Rodrigo Barona
Date: Fri, 16 Oct 2026 14:52:40 +0100
Subject: [PATCH 1/7] Dato Agent: redact credentials from copied failure
diagnostics
Provider API keys, OAuth access/refresh tokens, PKCE verifiers and similar
fields are replaced with [Redacted] (empty values are kept), and sk-* keys or
Bearer tokens embedded in strings are scrubbed. Diagnostics are commonly
pasted into issues and chats, so they must not carry live credentials.
Co-authored-by: Cursor
---
.../src/entrypoints/AgentFrame.test.tsx | 10 ++-
dato-agent/src/lib/diagnostics.test.ts | 57 ++++++++++++++++-
dato-agent/src/lib/diagnostics.ts | 61 +++++++++++++++++--
3 files changed, 118 insertions(+), 10 deletions(-)
diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx
index 03f8cf89..1aaffb91 100644
--- a/dato-agent/src/entrypoints/AgentFrame.test.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx
@@ -6708,11 +6708,15 @@ describe('AgentFrame', () => {
expect(writeText).toHaveBeenCalledOnce();
const diagnostics = JSON.parse(String(writeText.mock.calls[0]?.[0]));
- expect(diagnostics.configuration.openAiApiKey).toBe('sk-diagnostic-secret');
- expect(diagnostics.configuration.activeApiKey).toBe('sk-diagnostic-secret');
- expect(diagnostics.oauthCredentials.token.accessToken).toBe(
+ expect(diagnostics.configuration.openAiApiKey).toBe('[Redacted]');
+ expect(diagnostics.configuration.activeApiKey).toBe('[Redacted]');
+ expect(diagnostics.oauthCredentials.token.accessToken).toBe('[Redacted]');
+ expect(String(writeText.mock.calls[0]?.[0])).not.toContain(
'diagnostic-oauth-secret',
);
+ expect(String(writeText.mock.calls[0]?.[0])).not.toContain(
+ 'sk-diagnostic-secret',
+ );
expect(diagnostics.turn.message).toBe(
'Find the private record with all of its content',
);
diff --git a/dato-agent/src/lib/diagnostics.test.ts b/dato-agent/src/lib/diagnostics.test.ts
index 6617bcf6..a425b346 100644
--- a/dato-agent/src/lib/diagnostics.test.ts
+++ b/dato-agent/src/lib/diagnostics.test.ts
@@ -28,7 +28,7 @@ afterEach(() => {
});
describe('serializeDiagnostics', () => {
- it('produces deterministic pretty JSON while preserving raw diagnostic values', () => {
+ it('produces deterministic pretty JSON with credentials redacted', () => {
const first = {
token: 'dato_oauth_secret',
schemaVersion: DIAGNOSTICS_SCHEMA_VERSION,
@@ -46,15 +46,66 @@ describe('serializeDiagnostics', () => {
expect(serializeDiagnostics(first)).toBe(`{
"arguments": {
"a": false,
- "apiKey": "sk-secret",
+ "apiKey": "[Redacted]",
"z": true
},
"prompt": "Change record 178178740",
"schemaVersion": 1,
- "token": "dato_oauth_secret"
+ "token": "[Redacted]"
}`);
});
+ it('redacts provider keys and OAuth credentials while keeping non-secret settings', () => {
+ const serialized = serializeDiagnostics({
+ configuration: {
+ anthropicApiKey: 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
+ openaiApiKey: '',
+ activeApiKey: 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
+ anthropicModelMaxOutputTokens: 128_000,
+ oauthCredentials: {
+ client: { clientId: 'client-123' },
+ token: {
+ accessToken: 'dato-access-token-value',
+ refreshToken: 'dato-refresh-token-value',
+ expiresAt: 1_800_000_000,
+ },
+ pending: { codeVerifier: 'pkce-verifier-value', state: 'abc' },
+ },
+ },
+ turn: { mcpAccessToken: 'dato-access-token-value' },
+ error: {
+ message:
+ 'Request failed: Authorization: Bearer dato-access-token-value with key sk-proj-abcdefghijklmnopqrstuvwxyz',
+ },
+ });
+
+ expect(serialized).not.toContain('abcdefghijklmnopqrstuvwxyz');
+ expect(serialized).not.toContain('dato-access-token-value');
+ expect(serialized).not.toContain('dato-refresh-token-value');
+ expect(serialized).not.toContain('pkce-verifier-value');
+ expect(JSON.parse(serialized)).toMatchObject({
+ configuration: {
+ anthropicApiKey: '[Redacted]',
+ openaiApiKey: '',
+ activeApiKey: '[Redacted]',
+ anthropicModelMaxOutputTokens: 128_000,
+ oauthCredentials: {
+ client: { clientId: 'client-123' },
+ token: {
+ accessToken: '[Redacted]',
+ refreshToken: '[Redacted]',
+ expiresAt: 1_800_000_000,
+ },
+ pending: { codeVerifier: '[Redacted]', state: 'abc' },
+ },
+ },
+ error: {
+ message:
+ 'Request failed: Authorization: Bearer [Redacted] with key [Redacted]',
+ },
+ });
+ });
+
it('handles errors, BigInt, Map, Set, circular references, and unsupported values', () => {
const cause = new Error('Provider request failed');
const error = new Error('Turn failed', { cause });
diff --git a/dato-agent/src/lib/diagnostics.ts b/dato-agent/src/lib/diagnostics.ts
index 6ec9610d..6d7b2313 100644
--- a/dato-agent/src/lib/diagnostics.ts
+++ b/dato-agent/src/lib/diagnostics.ts
@@ -50,6 +50,45 @@ function describeSymbol(value: symbol): string {
}
}
+const REDACTED = '[Redacted]';
+
+const SENSITIVE_EXACT_KEYS = new Set([
+ 'authorization',
+ 'bearer',
+ 'codeverifier',
+ 'cookie',
+ 'idtoken',
+ 'token',
+]);
+
+const SENSITIVE_KEY_SUFFIXES = [
+ 'accesstoken',
+ 'apikey',
+ 'password',
+ 'refreshtoken',
+ 'secret',
+];
+
+const SENSITIVE_VALUE_PATTERNS: ReadonlyArray<[RegExp, string]> = [
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, REDACTED],
+ [/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`],
+];
+
+function isSensitiveKey(key: string): boolean {
+ const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
+ return (
+ SENSITIVE_EXACT_KEYS.has(normalized) ||
+ SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
+ );
+}
+
+function redactSensitiveSubstrings(value: string): string {
+ return SENSITIVE_VALUE_PATTERNS.reduce(
+ (current, [pattern, replacement]) => current.replace(pattern, replacement),
+ value,
+ );
+}
+
function propertyPath(parentPath: string, key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key)
? `${parentPath}.${key}`
@@ -59,6 +98,17 @@ function propertyPath(parentPath: string, key: string): string {
function normalizeDiagnostics(value: unknown): JsonSafeValue {
const seen = new WeakMap
)}
+ {review && (
+
+ )}
>
);
}
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index fa2610dd..ed95e95b 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -9,6 +9,7 @@ import {
type AgentRecordResultViewModel,
AgentSurface,
type AgentTranscriptEntry,
+ type CopyDiagnosticsResult,
type UnsafeApprovalViewModel,
} from '../components/AgentSurface';
import {
@@ -126,6 +127,7 @@ import {
revokeToken,
waitForOAuthCallback,
} from '../lib/oauth';
+import { findSecretCandidates, redactValues } from '../lib/secretCandidates';
import { type AgentSurfaceKind, buildSystemPrompt } from '../lib/systemPrompt';
import {
createUnsafeDispatchJournalStore,
@@ -4450,13 +4452,23 @@ export default function AgentFrame(props: AgentFrameProps) {
await submit(candidate.submission, candidate);
};
- const copyFailureDiagnostics = async (failureId: string): Promise => {
+ const copyFailureDiagnostics = async (
+ failureId: string,
+ redact?: readonly string[],
+ ): Promise => {
const diagnostics = failureDiagnosticsRef.current.get(failureId);
if (!diagnostics) {
throw new Error('Diagnostics are no longer available.');
}
- await copyTextToClipboard(diagnostics);
+ // Known secrets are already masked; ask about anything else that looks like one.
+ if (redact === undefined) {
+ const candidates = findSecretCandidates(diagnostics);
+ if (candidates.length > 0) return { status: 'review', candidates };
+ }
+
+ await copyTextToClipboard(redactValues(diagnostics, redact ?? []));
+ return { status: 'copied' };
};
const stopActiveTurn = (): void => {
diff --git a/dato-agent/src/lib/secretCandidates.test.ts b/dato-agent/src/lib/secretCandidates.test.ts
new file mode 100644
index 00000000..23ca3da1
--- /dev/null
+++ b/dato-agent/src/lib/secretCandidates.test.ts
@@ -0,0 +1,80 @@
+import { generateId } from '@datocms/cma-client-browser';
+import { describe, expect, it } from 'vitest';
+import { findSecretCandidates, redactValues } from './secretCandidates';
+
+// Fake, random-looking values; none is a real credential.
+const PASTED_KEY = 'xK9mQ2vL7pR4tZ8wN3bY6cF1hJ5dS0gA';
+const OTHER_TOKEN = 'aB3dE5fG7hJ9kL1mN2pQ4rS6tU8vW0xY';
+
+const serialize = (value: unknown) => JSON.stringify(value, null, 2);
+
+describe('findSecretCandidates', () => {
+ it('flags long random-looking tokens anywhere, with a safe preview and path', () => {
+ const candidates = findSecretCandidates(
+ serialize({
+ conversation: [{ text: `here is my key ${PASTED_KEY} please use it` }],
+ turn: {
+ events: [
+ { output: `token=${OTHER_TOKEN}` },
+ { output: `again ${PASTED_KEY}` },
+ ],
+ },
+ }),
+ );
+
+ expect(candidates.map((candidate) => candidate.value).sort()).toEqual(
+ [PASTED_KEY, OTHER_TOKEN].sort(),
+ );
+ const pasted = candidates.find(
+ (candidate) => candidate.value === PASTED_KEY,
+ );
+ expect(pasted).toMatchObject({
+ preview: `…${PASTED_KEY.slice(-4)} · ${PASTED_KEY.length} chars`,
+ path: 'conversation[0].text',
+ occurrences: 2,
+ });
+ expect(pasted?.preview).not.toContain(PASTED_KEY.slice(0, 8));
+ });
+
+ it('skips DatoCMS IDs, identifier keys, words, paths and URLs', () => {
+ const recordId = generateId();
+ const candidates = findSecretCandidates(
+ serialize({
+ mention: `see record ${recordId}`,
+ script:
+ 'script://dato-agent/238763/primary/conversation%3A2ae6507d-2d04-4751-a5f3-7b809c9cfe92/x.ts',
+ siteId: PASTED_KEY,
+ itemIds: [OTHER_TOKEN],
+ currentRecord: { id: PASTED_KEY },
+ prose: 'Internationalization considerations notwithstanding everything',
+ stack: 'at runTurn (dato-agent/src/lib/agentRuntime.ts:4873:21)',
+ url: 'https://site-api.datocms.com/items?filter[type]=article&page[limit]=100',
+ timestamp: '2026-11-03T22:05:03.968Z',
+ }),
+ );
+
+ expect(candidates).toEqual([]);
+ });
+
+ it('still flags secrets under keys that merely end in "id"-like words', () => {
+ const candidates = findSecretCandidates(
+ serialize({ valid: PASTED_KEY, paid: OTHER_TOKEN }),
+ );
+ expect(candidates).toHaveLength(2);
+ });
+});
+
+describe('redactValues', () => {
+ it('replaces every occurrence of the chosen values only', () => {
+ const serialized = serialize({
+ a: PASTED_KEY,
+ b: `x ${PASTED_KEY}`,
+ c: OTHER_TOKEN,
+ });
+ const redacted = redactValues(serialized, [PASTED_KEY]);
+
+ expect(redacted).not.toContain(PASTED_KEY);
+ expect(redacted).toContain(OTHER_TOKEN);
+ expect(redactValues(serialized, [])).toBe(serialized);
+ });
+});
diff --git a/dato-agent/src/lib/secretCandidates.ts b/dato-agent/src/lib/secretCandidates.ts
new file mode 100644
index 00000000..7621c542
--- /dev/null
+++ b/dato-agent/src/lib/secretCandidates.ts
@@ -0,0 +1,168 @@
+import { isValidId } from '@datocms/cma-client-browser';
+
+/** A value in the diagnostics that looks like a credential the plugin doesn't know about. */
+export type SecretCandidate = {
+ value: string;
+ /** Safe to show: the last 4 characters and the length, never the value itself. */
+ preview: string;
+ /** Where it first appears in the payload, e.g. `turn.events[4].output`. */
+ path: string;
+ occurrences: number;
+};
+
+const MIN_TOKEN_LENGTH = 20;
+/** Bits per character. English words and file paths sit well below; keys and tokens above. */
+const MIN_ENTROPY = 3.5;
+/** Characters that can belong to a token. Anything else (spaces, quotes, `/`, `.`, `:`) splits it. */
+const TOKEN_CHARACTERS = new Set(
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_~+'.split(
+ '',
+ ),
+);
+
+const HEX_DIGITS = new Set('0123456789abcdefABCDEF'.split(''));
+
+function tokens(text: string): string[] {
+ const found: string[] = [];
+ let current = '';
+ for (let index = 0; index < text.length; index++) {
+ const character = text[index] ?? '';
+ if (TOKEN_CHARACTERS.has(character)) {
+ current += character;
+ continue;
+ }
+ if (current.length >= MIN_TOKEN_LENGTH) found.push(current);
+ current = '';
+ // A URL escape such as %3A is a separator too, so `id%3A` yields the UUID alone.
+ if (
+ character === '%' &&
+ HEX_DIGITS.has(text[index + 1] ?? '') &&
+ HEX_DIGITS.has(text[index + 2] ?? '')
+ )
+ index += 2;
+ }
+ if (current.length >= MIN_TOKEN_LENGTH) found.push(current);
+ return found;
+}
+
+function shannonEntropy(text: string): number {
+ const counts = new Map();
+ for (const character of text)
+ counts.set(character, (counts.get(character) ?? 0) + 1);
+ let entropy = 0;
+ for (const count of counts.values()) {
+ const probability = count / text.length;
+ entropy -= probability * Math.log2(probability);
+ }
+ return entropy;
+}
+
+function isRandomLooking(token: string): boolean {
+ let hasLetter = false;
+ let hasDigit = false;
+ for (const character of token) {
+ if (character >= '0' && character <= '9') hasDigit = true;
+ else if (
+ (character >= 'a' && character <= 'z') ||
+ (character >= 'A' && character <= 'Z')
+ )
+ hasLetter = true;
+ }
+ return hasLetter && hasDigit && shannonEntropy(token) >= MIN_ENTROPY;
+}
+
+/** Canonical 8-4-4-4-12 hex UUIDs (conversation, request and response IDs), checked by position. */
+function isUuid(token: string): boolean {
+ if (token.length !== 36) return false;
+ for (let index = 0; index < token.length; index++) {
+ const isDashPosition =
+ index === 8 || index === 13 || index === 18 || index === 23;
+ if (
+ isDashPosition
+ ? token[index] !== '-'
+ : !HEX_DIGITS.has(token[index] ?? '')
+ )
+ return false;
+ }
+ return true;
+}
+
+/** `id`, `siteId`, `itemIds`, `item_ids`, `ID`…: record and entity identifiers, not secrets. */
+function isIdentifierKey(key: string | undefined): boolean {
+ if (key === undefined) return false;
+ if (['id', 'ids', 'ID', 'IDs'].includes(key)) return true;
+ return ['Id', 'Ids', 'ID', 'IDs', '_id', '_ids'].some((suffix) =>
+ key.endsWith(suffix),
+ );
+}
+
+function collectCandidates(
+ text: string,
+ path: string,
+ candidates: Map,
+): void {
+ for (const token of tokens(text)) {
+ if (isValidId(token) || isUuid(token) || !isRandomLooking(token)) continue;
+ const existing = candidates.get(token);
+ if (existing) {
+ existing.occurrences += 1;
+ continue;
+ }
+ candidates.set(token, {
+ value: token,
+ preview: `…${token.slice(-4)} · ${token.length} chars`,
+ path,
+ occurrences: 1,
+ });
+ }
+}
+
+function visitValue(
+ value: unknown,
+ path: string,
+ key: string | undefined,
+ candidates: Map,
+): void {
+ if (typeof value === 'string') {
+ if (!isIdentifierKey(key)) collectCandidates(value, path, candidates);
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const [index, entry] of value.entries())
+ visitValue(entry, `${path}[${index}]`, key, candidates);
+ return;
+ }
+ if (value && typeof value === 'object')
+ for (const [childKey, child] of Object.entries(value))
+ visitValue(
+ child,
+ path ? `${path}.${childKey}` : childKey,
+ childKey,
+ candidates,
+ );
+}
+
+/**
+ * Finds values in serialized diagnostics that look like secrets the plugin doesn't hold, such as
+ * a key a user pasted into chat, so the user can choose to redact them before copying. Detection is
+ * deliberately generic (long, mixed, high-entropy tokens) because the user reviews every hit.
+ * DatoCMS IDs (`isValidId`), UUIDs and values under identifier keys are skipped.
+ */
+export function findSecretCandidates(serialized: string): SecretCandidate[] {
+ const candidates = new Map();
+ visitValue(JSON.parse(serialized), '', undefined, candidates);
+ return [...candidates.values()];
+}
+
+/** Replaces every occurrence of the chosen values with the diagnostics' redaction marker. */
+export function redactValues(
+ serialized: string,
+ values: readonly string[],
+): string {
+ return [...values]
+ .sort((left, right) => right.length - left.length)
+ .reduce(
+ (current, value) => current.replaceAll(value, '[redacted]'),
+ serialized,
+ );
+}
From e7c81df21049fc5d26f18b5f54135faebfeffbe1 Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Tue, 3 Nov 2026 14:49:54 -0800
Subject: [PATCH 5/7] Dato Agent: mask the OAuth token a turn actually used
registerTerminalFailure is a per-render closure, so its oauthCredentials
could predate a token rotation while the turn used the newer token from
oauthCredentialsRef. An error message echoing that token reached the
clipboard unmasked. Also mask the live ref and the turn's
mcpAccessToken/repairOauthAccessToken, with a regression test.
Bump DIAGNOSTICS_SCHEMA_VERSION to 2: configuration and OAuth state now
have an allowlisted shape.
Co-Authored-By: Claude Opus 5.5
---
.../src/entrypoints/AgentFrame.test.tsx | 94 +++++++++++++++++++
dato-agent/src/entrypoints/AgentFrame.tsx | 6 +-
dato-agent/src/lib/diagnostics.test.ts | 2 +-
dato-agent/src/lib/diagnostics.ts | 3 +-
4 files changed, 102 insertions(+), 3 deletions(-)
diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx
index c5797f1d..95601e24 100644
--- a/dato-agent/src/entrypoints/AgentFrame.test.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx
@@ -6759,6 +6759,100 @@ describe('AgentFrame', () => {
);
});
+ it('masks the OAuth token a turn actually used, not only the one its render closed over', async () => {
+ const frameProps = props({
+ currentUserId: `diagnostics-rotated-user-${testUser}`,
+ });
+ const credentialStore = createCredentialStore({
+ siteId: frameProps.siteId,
+ currentUserId: frameProps.currentUserId,
+ });
+ const saveToken = (accessToken: string) =>
+ credentialStore.save(
+ createOAuthCredentials(
+ {
+ clientId: 'diagnostic-client',
+ clientIdIssuedAt: 1_700_000_000,
+ redirectUri: 'https://example.test/oauth/callback',
+ },
+ { accessToken, tokenType: 'Bearer', obtainedAt: 1_700_000_000 },
+ ),
+ { remember: true },
+ );
+ saveToken('stale-oauth-secret-value');
+ const failure = {
+ code: 'api_error' as const,
+ message:
+ 'MCP rejected Bearer rotated-oauth-secret-value after stale-oauth-secret-value expired.',
+ retryable: true,
+ };
+ mocks.runtime = {
+ runTurn: vi.fn(
+ async (
+ _args: unknown,
+ onEvent?: (event: AgentRuntimeEvent) => void | Promise,
+ ) => {
+ const failed = completedResult({
+ status: 'failed',
+ responseId: 'resp_rotated',
+ error: failure,
+ });
+ await onEvent?.({
+ type: 'error',
+ responseId: 'resp_rotated',
+ error: failure,
+ });
+ await onEvent?.({ type: 'turn_completed', result: failed });
+ return failed;
+ },
+ ),
+ } as unknown as AgentRuntime;
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render();
+ // A submit handler from before the token rotation: its render still holds the stale token.
+ const staleSubmit = mocks.surfaceProps?.onSubmit;
+ saveToken('rotated-oauth-secret-value');
+ act(() => {
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: credentialStore.key,
+ newValue: localStorage.getItem(credentialStore.key),
+ storageArea: localStorage,
+ }),
+ );
+ });
+ act(() => {
+ staleSubmit?.('Describe the project');
+ });
+ await waitFor(() => expect(mocks.surfaceProps?.isRunning).toBe(false));
+
+ const failedEntry = mocks.surfaceProps?.entries.find(
+ (entry) =>
+ entry.kind === 'message' && entry.role === 'assistant' && entry.failure,
+ );
+ if (failedEntry?.kind !== 'message' || !failedEntry.failure) {
+ throw new Error('Expected terminal failure diagnostics.');
+ }
+ await act(async () => {
+ await mocks.surfaceProps?.onCopyFailureDiagnostics?.(
+ failedEntry.failure?.id ?? '',
+ );
+ });
+
+ expect(writeText).toHaveBeenCalledOnce();
+ const serialized = String(writeText.mock.calls[0]?.[0]);
+ expect(serialized).not.toContain('rotated-oauth-secret-value');
+ expect(serialized).not.toContain('stale-oauth-secret-value');
+ expect(serialized).toContain(
+ 'MCP rejected Bearer [redacted] after [redacted] expired.',
+ );
+ });
+
it('offers diagnostics but never retry when the runtime throws without an explicit retryable result', async () => {
mocks.runtime = {
runTurn: vi
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index ed95e95b..9d648c8b 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -3292,11 +3292,15 @@ export default function AgentFrame(props: AgentFrameProps) {
pendingNavigation: pendingNavigationRef.current,
},
{
- // Every live secret this frame holds; masked wherever it appears in the payload.
+ // Every live secret this frame holds; masked wherever it appears in the payload. This
+ // closure can predate a token rotation, so read the live ref and the tokens the turn used.
secrets: [
props.config.openAiApiKey,
props.config.anthropicApiKey,
oauthCredentials?.token?.accessToken,
+ oauthCredentialsRef.current?.token?.accessToken,
+ turn.mcpAccessToken,
+ turn.repairOauthAccessToken,
],
},
),
diff --git a/dato-agent/src/lib/diagnostics.test.ts b/dato-agent/src/lib/diagnostics.test.ts
index f4508a9a..f54c99a1 100644
--- a/dato-agent/src/lib/diagnostics.test.ts
+++ b/dato-agent/src/lib/diagnostics.test.ts
@@ -50,7 +50,7 @@ describe('serializeDiagnostics', () => {
"z": true
},
"prompt": "Change record 178178740",
- "schemaVersion": 1,
+ "schemaVersion": 2,
"token": "[redacted]"
}`);
});
diff --git a/dato-agent/src/lib/diagnostics.ts b/dato-agent/src/lib/diagnostics.ts
index 213218bb..a892a04b 100644
--- a/dato-agent/src/lib/diagnostics.ts
+++ b/dato-agent/src/lib/diagnostics.ts
@@ -1,4 +1,5 @@
-export const DIAGNOSTICS_SCHEMA_VERSION = 1;
+/** 2: configuration and OAuth state are allowlisted; secrets are never included. */
+export const DIAGNOSTICS_SCHEMA_VERSION = 2;
type JsonSafeValue =
| null
From d41d3187a19091023d68bd1e5e1c5948c0e46993 Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Tue, 3 Nov 2026 14:58:44 -0800
Subject: [PATCH 6/7] Dato Agent: fewer false positives in the diagnostics
secret review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The live review flagged the project name (claude-…-2026-11-03) and a host
context fingerprint. Flag a token only if one of its -/_ segments mixes
letters and digits, as keys do (sk-ant-api03-…) and slugs don't; treat
fingerprint/hash/checksum/digest keys as identifiers. Keep each preview
on one line.
Co-Authored-By: Claude Opus 5.5
---
.../src/components/AgentSurface.module.css | 11 ++++-
dato-agent/src/lib/secretCandidates.test.ts | 2 +
dato-agent/src/lib/secretCandidates.ts | 45 +++++++++++++++----
3 files changed, 49 insertions(+), 9 deletions(-)
diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index d17c8a0f..5f963454 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -666,7 +666,16 @@
align-items: baseline;
gap: var(--spacing-xs);
color: var(--color--ink-subtle);
- word-break: break-all;
+}
+
+.diagnosticsReviewItem code {
+ flex: none;
+ white-space: nowrap;
+}
+
+.diagnosticsReviewItem span {
+ min-width: 0;
+ overflow-wrap: anywhere;
}
.activityDisclosure {
diff --git a/dato-agent/src/lib/secretCandidates.test.ts b/dato-agent/src/lib/secretCandidates.test.ts
index 23ca3da1..ba8be672 100644
--- a/dato-agent/src/lib/secretCandidates.test.ts
+++ b/dato-agent/src/lib/secretCandidates.test.ts
@@ -50,6 +50,8 @@ describe('findSecretCandidates', () => {
stack: 'at runTurn (dato-agent/src/lib/agentRuntime.ts:4873:21)',
url: 'https://site-api.datocms.com/items?filter[type]=article&page[limit]=100',
timestamp: '2026-11-03T22:05:03.968Z',
+ siteName: 'claude-prtest-recordbin-2026-11-03',
+ hostContexts: [{ fingerprint: 'a1b2c3d4e5f6a7b8c9d0-12d' }],
}),
);
diff --git a/dato-agent/src/lib/secretCandidates.ts b/dato-agent/src/lib/secretCandidates.ts
index 7621c542..f31712cf 100644
--- a/dato-agent/src/lib/secretCandidates.ts
+++ b/dato-agent/src/lib/secretCandidates.ts
@@ -57,10 +57,10 @@ function shannonEntropy(text: string): number {
return entropy;
}
-function isRandomLooking(token: string): boolean {
+function mixesLettersAndDigits(text: string): boolean {
let hasLetter = false;
let hasDigit = false;
- for (const character of token) {
+ for (const character of text) {
if (character >= '0' && character <= '9') hasDigit = true;
else if (
(character >= 'a' && character <= 'z') ||
@@ -68,7 +68,18 @@ function isRandomLooking(token: string): boolean {
)
hasLetter = true;
}
- return hasLetter && hasDigit && shannonEntropy(token) >= MIN_ENTROPY;
+ return hasLetter && hasDigit;
+}
+
+/**
+ * Keys and tokens have at least one segment (between `-` / `_`) that mixes letters and digits, like
+ * `sk-ant-api03-…`. Slugs and names such as `my-site-2026-11-03` only join words and numbers.
+ */
+function isRandomLooking(token: string): boolean {
+ const segments = token.split('-').flatMap((part) => part.split('_'));
+ return (
+ segments.some(mixesLettersAndDigits) && shannonEntropy(token) >= MIN_ENTROPY
+ );
}
/** Canonical 8-4-4-4-12 hex UUIDs (conversation, request and response IDs), checked by position. */
@@ -87,13 +98,31 @@ function isUuid(token: string): boolean {
return true;
}
-/** `id`, `siteId`, `itemIds`, `item_ids`, `ID`…: record and entity identifiers, not secrets. */
+/** Identifiers and digests (`id`, `siteId`, `item_ids`, `fingerprint`, `contentHash`…), not secrets. */
function isIdentifierKey(key: string | undefined): boolean {
if (key === undefined) return false;
- if (['id', 'ids', 'ID', 'IDs'].includes(key)) return true;
- return ['Id', 'Ids', 'ID', 'IDs', '_id', '_ids'].some((suffix) =>
- key.endsWith(suffix),
- );
+ const exact = [
+ 'id',
+ 'ids',
+ 'ID',
+ 'IDs',
+ 'fingerprint',
+ 'hash',
+ 'checksum',
+ 'digest',
+ ];
+ if (exact.includes(key)) return true;
+ return [
+ 'Id',
+ 'Ids',
+ 'ID',
+ 'IDs',
+ '_id',
+ '_ids',
+ 'Hash',
+ 'Fingerprint',
+ '_hash',
+ ].some((suffix) => key.endsWith(suffix));
}
function collectCandidates(
From 46614c0da52249267b01d4734d8a634f3de17914 Mon Sep 17 00:00:00 2001
From: "Roger (DatoCMS)"
Date: Tue, 3 Nov 2026 15:09:32 -0800
Subject: [PATCH 7/7] Dato Agent: say what ticking a value in the diagnostics
review does
Each candidate now has a visible "Redact" label, and a hint under the
heading says ticked values are replaced with [redacted] in the copy.
Co-Authored-By: Claude Opus 5.5
---
dato-agent/src/components/AgentSurface.module.css | 11 +++++++++++
dato-agent/src/components/AgentSurface.test.tsx | 6 ++++++
dato-agent/src/components/AgentSurface.tsx | 7 +++++--
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index 5f963454..f98dd120 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -668,6 +668,17 @@
color: var(--color--ink-subtle);
}
+.diagnosticsReviewHint {
+ margin: 0;
+ color: var(--color--ink-subtle);
+}
+
+.diagnosticsReviewItem .diagnosticsReviewAction {
+ flex: none;
+ color: var(--color--ink);
+ font-weight: 600;
+}
+
.diagnosticsReviewItem code {
flex: none;
white-space: nowrap;
diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx
index 2bb1930e..fd0c5fc5 100644
--- a/dato-agent/src/components/AgentSurface.test.tsx
+++ b/dato-agent/src/components/AgentSurface.test.tsx
@@ -1430,6 +1430,12 @@ describe('AgentSurface', () => {
screen.getByText(/2 values in the diagnostics look like secrets/),
).toBeInTheDocument();
expect(screen.getByText('…0002 · 23 chars')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Ticked values are replaced with [redacted] in the copy.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getAllByText('Redact')).toHaveLength(2);
expect(
screen.queryByText('second-secret-value-002'),
).not.toBeInTheDocument();
diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx
index d5f53c08..0ab660d9 100644
--- a/dato-agent/src/components/AgentSurface.tsx
+++ b/dato-agent/src/components/AgentSurface.tsx
@@ -826,9 +826,11 @@ function FailureActions({
+
+ Ticked values are replaced with [redacted] in the copy.
+
{review.map((candidate) => (
+ Redact
{candidate.preview}
in {candidate.path || 'diagnostics'}