@opengeni/contracts 0.19.0 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/contracts",
3
- "version": "0.19.0",
3
+ "version": "0.19.4",
4
4
  "description": "Shared zod schemas and wire-contract types for the OpenGeni API.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -478,8 +478,10 @@ export type ErrorCode = z.infer<typeof ErrorCode>;
478
478
 
479
479
  export const ErrorEnvelope = z.object({
480
480
  error: z.object({
481
+ status: z.number().int().min(400).max(599),
481
482
  code: ErrorCode,
482
483
  message: z.string(),
484
+ retryable: z.boolean(),
483
485
  requestId: z.string().optional(),
484
486
  details: z.record(z.string(), z.unknown()).optional(),
485
487
  }),
@@ -2176,11 +2178,22 @@ export type ConnectionCredentialsPort = {
2176
2178
 
2177
2179
  export type GitHubInstallationSummary = {
2178
2180
  installationId: number;
2181
+ accountId: number;
2179
2182
  accountLogin: string | null;
2180
2183
  accountType: string | null;
2181
2184
  suspended: boolean;
2182
2185
  };
2183
2186
 
2187
+ export type GitHubInstallationAuthorityKind = "personal_owner" | "organization_owner";
2188
+
2189
+ export interface GitHubInstallationBindingProof {
2190
+ actorId: number;
2191
+ actorLogin: string;
2192
+ authorityKind: GitHubInstallationAuthorityKind;
2193
+ installation: GitHubInstallationSummary;
2194
+ repositories: GitHubRepository[];
2195
+ }
2196
+
2184
2197
  export type GitHubRepositoryPermissions = {
2185
2198
  admin: boolean;
2186
2199
  maintain: boolean;
@@ -2198,6 +2211,18 @@ export type GitHubUserInstallationAccess = GitHubInstallationSummary & {
2198
2211
  };
2199
2212
 
2200
2213
  export type GitHubAppApiPort = {
2214
+ /**
2215
+ * Exchange one fresh GitHub user-authorization code and prove current
2216
+ * installation authority. Implementations must accept only exact personal
2217
+ * ownership or active organization ownership; installation visibility,
2218
+ * repository permission bits, and App Manager metadata are not authority.
2219
+ * Organization ownership must be revalidated after repository discovery,
2220
+ * immediately before returning the proof used by the durable bind.
2221
+ */
2222
+ authorizeInstallationBinding?: (input: {
2223
+ code: string;
2224
+ installationId: number;
2225
+ }) => Promise<GitHubInstallationBindingProof>;
2201
2226
  authorizeUser?: (input: { code: string }) => Promise<GitHubUserInstallationAccess[]>;
2202
2227
  verifyInstallationAccessForUser?: (input: {
2203
2228
  code: string;
@@ -2491,6 +2516,37 @@ export type KnowledgeSourceKind = z.infer<typeof KnowledgeSourceKind>;
2491
2516
  export const DocumentSearchMode = z.enum(["hybrid", "vector", "keyword"]);
2492
2517
  export type DocumentSearchMode = z.infer<typeof DocumentSearchMode>;
2493
2518
 
2519
+ // 'workspace' documents are readable by anyone with workspace access;
2520
+ // 'private' documents are readable only by the grant subject that created them.
2521
+ export const DocumentVisibility = z.enum(["workspace", "private"]);
2522
+ export type DocumentVisibility = z.infer<typeof DocumentVisibility>;
2523
+
2524
+ // Knowledge-drop auto-curation lifecycle. 'none' = ordinary caller-described add
2525
+ // (never auto-curated). 'pending' = dropped, curation runs during indexing.
2526
+ // 'suggested' = curated but the base move was NOT applied (low confidence or
2527
+ // conflict) — the suggestion lives in Document.curation. 'auto_filed' = curated
2528
+ // and moved into the suggested base. 'failed' = curation errored (fail-soft;
2529
+ // the document still indexes and stays searchable).
2530
+ export const DocumentCurationStatus = z.enum([
2531
+ "none",
2532
+ "pending",
2533
+ "suggested",
2534
+ "auto_filed",
2535
+ "failed",
2536
+ ]);
2537
+ export type DocumentCurationStatus = z.infer<typeof DocumentCurationStatus>;
2538
+
2539
+ // Curator audit blob persisted on the document.
2540
+ export const DocumentCuration = z.object({
2541
+ suggestedBaseId: z.string().uuid().nullable(),
2542
+ suggestedBaseName: z.string().nullable(),
2543
+ confidence: z.number().min(0).max(1),
2544
+ reason: z.string().nullable(),
2545
+ originalTitle: z.string().nullable(),
2546
+ model: z.string().nullable(),
2547
+ });
2548
+ export type DocumentCuration = z.infer<typeof DocumentCuration>;
2549
+
2494
2550
  export const DocumentBase = z.object({
2495
2551
  id: z.string().uuid(),
2496
2552
  workspaceId: z.string().uuid(),
@@ -2520,6 +2576,13 @@ export const Document = z.object({
2520
2576
  sourceUpdatedAt: z.string().nullable(),
2521
2577
  sourceVersion: z.string().nullable(),
2522
2578
  aclTags: z.array(z.string()),
2579
+ visibility: DocumentVisibility,
2580
+ createdBy: z.string().nullable(),
2581
+ agentAccess: z.boolean(),
2582
+ summary: z.string().nullable(),
2583
+ topics: z.array(z.string()),
2584
+ curationStatus: DocumentCurationStatus,
2585
+ curation: DocumentCuration.nullable(),
2523
2586
  createdAt: z.string(),
2524
2587
  updatedAt: z.string(),
2525
2588
  });
@@ -2569,9 +2632,37 @@ export const AddDocumentRequest = z.object({
2569
2632
  sourceUpdatedAt: z.string().datetime({ offset: true }).optional(),
2570
2633
  sourceVersion: z.string().min(1).optional(),
2571
2634
  aclTags: z.array(z.string().min(1)).optional(),
2635
+ visibility: DocumentVisibility.optional(),
2636
+ agentAccess: z.boolean().optional(),
2572
2637
  });
2573
2638
  export type AddDocumentRequest = z.infer<typeof AddDocumentRequest>;
2574
2639
 
2640
+ // A knowledge drop: raw text or an already-uploaded file, with no required
2641
+ // metadata. The server files it into the workspace Default base. When a
2642
+ // curation provider is enabled, it may name, summarize, categorize, and
2643
+ // (confidence permitting) move the document; provider=none leaves caller
2644
+ // metadata and Default placement unchanged.
2645
+ export const CreateKnowledgeDropRequest = z
2646
+ .object({
2647
+ text: z.string().min(1).max(2_000_000).optional(),
2648
+ fileId: z.string().uuid().optional(),
2649
+ filename: z.string().min(1).optional(),
2650
+ title: z.string().min(1).optional(),
2651
+ visibility: DocumentVisibility.optional(),
2652
+ agentAccess: z.boolean().optional(),
2653
+ })
2654
+ .refine((value) => (value.text === undefined) !== (value.fileId === undefined), {
2655
+ message: "provide exactly one of text or fileId",
2656
+ });
2657
+ export type CreateKnowledgeDropRequest = z.infer<typeof CreateKnowledgeDropRequest>;
2658
+
2659
+ // Move a document (and its indexed chunks) to another base. With no explicit
2660
+ // targetBaseId, applies the document's stored curation suggestion.
2661
+ export const MoveDocumentRequest = z.object({
2662
+ targetBaseId: z.string().uuid().optional(),
2663
+ });
2664
+ export type MoveDocumentRequest = z.infer<typeof MoveDocumentRequest>;
2665
+
2575
2666
  export const DocumentSearchRequest = z.object({
2576
2667
  query: z.string().min(1),
2577
2668
  baseIds: z.array(z.string().uuid()).optional(),
@@ -3134,6 +3225,29 @@ export const UpdateSessionRequest = z.object({
3134
3225
  });
3135
3226
  export type UpdateSessionRequest = z.infer<typeof UpdateSessionRequest>;
3136
3227
 
3228
+ /**
3229
+ * Replace an existing session's durable tool policy, or explicitly opt back in
3230
+ * to the current workspace defaults. The mode-less explicit shape is retained
3231
+ * for compatibility with clients released before workspace-default adoption
3232
+ * was supported.
3233
+ */
3234
+ export const UpdateSessionToolPolicyRequest = z.union([
3235
+ z
3236
+ .object({
3237
+ mode: z.literal("workspace_default"),
3238
+ expectedVersion: z.number().int().positive(),
3239
+ })
3240
+ .strict(),
3241
+ z
3242
+ .object({
3243
+ mode: z.literal("explicit").optional(),
3244
+ tools: z.array(ToolRef).max(64),
3245
+ expectedVersion: z.number().int().positive(),
3246
+ })
3247
+ .strict(),
3248
+ ]);
3249
+ export type UpdateSessionToolPolicyRequest = z.infer<typeof UpdateSessionToolPolicyRequest>;
3250
+
3137
3251
  /**
3138
3252
  * A member's personal pin preference for a session. `expectedVersion` is
3139
3253
  * optional: ordinary pin/unpin actions are idempotent last-write-wins, while a
@@ -3275,6 +3389,7 @@ export const SessionAuthorizationOperation = z.enum([
3275
3389
  "session.human_input.write",
3276
3390
  "session.title.write",
3277
3391
  "session.mcp.approval_policy.write",
3392
+ "session.tool_policy.write",
3278
3393
  "session.goal.read",
3279
3394
  "session.goal.write",
3280
3395
  "session.child.create",
@@ -3569,6 +3684,8 @@ export const NewSessionDraft = z.object({
3569
3684
  text: z.string(),
3570
3685
  resources: z.array(ResourceRef),
3571
3686
  tools: z.array(ToolRef),
3687
+ /** False means the workspace-default MCP policy is still inherited. */
3688
+ toolsProvided: z.boolean().default(false),
3572
3689
  model: z.string().min(1),
3573
3690
  reasoningEffort: ReasoningEffort,
3574
3691
  options: NewSessionDraftOptions,
@@ -3580,6 +3697,7 @@ export const SaveNewSessionDraftRequest = NewSessionDraft.pick({
3580
3697
  text: true,
3581
3698
  resources: true,
3582
3699
  tools: true,
3700
+ toolsProvided: true,
3583
3701
  model: true,
3584
3702
  reasoningEffort: true,
3585
3703
  options: true,
@@ -4853,6 +4971,10 @@ export const Session = z.object({
4853
4971
  // Origin of the persisted tool allow-list. Optional for rolling client
4854
4972
  // compatibility; current servers emit it and legacy rows map to `legacy`.
4855
4973
  toolPolicy: SessionToolPolicy.optional(),
4974
+ // Optimistic-concurrency fence for durable policy mutations. Optional for
4975
+ // older clients/fixtures; current servers always emit the authoritative
4976
+ // value.
4977
+ toolPolicyVersion: z.number().int().positive().optional(),
4856
4978
  // Secret-safe current resolution, computed at an API/read or execution
4857
4979
  // boundary from IDs only. Optional because internal DB readers need not load
4858
4980
  // the workspace runtime registry.
@@ -5103,6 +5225,7 @@ export const SessionEventType = z.enum([
5103
5225
  "terminal.pty.exited", // PTY session ended (exitCode/reason)
5104
5226
  "session.title_set",
5105
5227
  "session.mcp.approval_policy.updated",
5228
+ "session.tool_policy.updated",
5106
5229
  // Multi-account Codex (P1): the account a session's turn runs on changed
5107
5230
  // (manual switch in P1; failover/rotation in P3 reuse the same event). Drives
5108
5231
  // the in-session "Running on:" indicator's live flip.
@@ -5268,6 +5391,7 @@ export const SESSION_EVENT_SEMANTIC_CLASS_TYPES = {
5268
5391
  "session.queue.changed",
5269
5392
  "session.queue.prompt.cancelled",
5270
5393
  "session.mcp.approval_policy.updated",
5394
+ "session.tool_policy.updated",
5271
5395
  ],
5272
5396
  terminal: [
5273
5397
  "turn.completed",
@@ -7468,10 +7592,18 @@ export type GitHubRepository = z.infer<typeof GitHubRepository>;
7468
7592
  export const GitHubRepositoryScope = z.enum(["all", "selected"]);
7469
7593
  export type GitHubRepositoryScope = z.infer<typeof GitHubRepositoryScope>;
7470
7594
 
7595
+ export const GitHubBindingStatus = z.enum(["disabled", "unbound", "bound"]);
7596
+ export type GitHubBindingStatus = z.infer<typeof GitHubBindingStatus>;
7597
+
7598
+ export const GitHubInstallationLifecycle = z.enum(["active", "suspended", "deleted", "unverified"]);
7599
+ export type GitHubInstallationLifecycle = z.infer<typeof GitHubInstallationLifecycle>;
7600
+
7471
7601
  export const GitHubInstallationBinding = z.object({
7472
7602
  installationId: z.number().int().positive(),
7603
+ githubAccountId: z.number().int().positive().nullable(),
7473
7604
  accountLogin: z.string().nullable(),
7474
7605
  accountType: z.string().nullable(),
7606
+ lifecycle: GitHubInstallationLifecycle,
7475
7607
  repositoryScope: GitHubRepositoryScope,
7476
7608
  repositoryCount: z.number().int().nonnegative(),
7477
7609
  createdAt: z.string(),
@@ -7481,6 +7613,7 @@ export type GitHubInstallationBinding = z.infer<typeof GitHubInstallationBinding
7481
7613
 
7482
7614
  export const GitHubAppInfo = z.object({
7483
7615
  configured: z.boolean(),
7616
+ status: GitHubBindingStatus,
7484
7617
  appId: z.string().nullable(),
7485
7618
  clientId: z.string().nullable(),
7486
7619
  appSlug: z.string().nullable(),
@@ -8473,6 +8606,8 @@ export type WorkspaceModelCatalogResponse = z.infer<typeof WorkspaceModelCatalog
8473
8606
  */
8474
8607
  export const OPENGENI_API_CONTRACT_REVISION = "2026-07-turn-instructions-v1" as const;
8475
8608
  export const OPENGENI_API_CONTRACT_HEADER = "x-opengeni-api-contract" as const;
8609
+ /** Bounded request/response identifier shared by browser, ingress, and API diagnostics. */
8610
+ export const OPENGENI_CORRELATION_HEADER = "x-opengeni-correlation-id" as const;
8476
8611
 
8477
8612
  export const ClientConfig = /* @__PURE__ */ defineModelContractSchema(() =>
8478
8613
  z.object({
@@ -8594,3 +8729,4 @@ export function evaluateWorkspaceModelPolicy(
8594
8729
  }
8595
8730
 
8596
8731
  export * from "./codex-fleet-policy";
8732
+ export * from "./secret-redaction";
@@ -0,0 +1,364 @@
1
+ const MIN_REDACTABLE_VALUE_LENGTH = 6;
2
+ const REDACTED = "[redacted]";
3
+ const MAX_REDACTION_DEPTH = 64;
4
+ const CYCLE_MARKER = "[OpenGeni omitted cyclic value during secret redaction]";
5
+ const DEPTH_MARKER = "[OpenGeni omitted value beyond secret-redaction depth]";
6
+
7
+ export type SecretForRedaction = {
8
+ name: string;
9
+ value: string;
10
+ };
11
+
12
+ type PreparedSecret = {
13
+ marker: string;
14
+ value: string;
15
+ };
16
+
17
+ const SENSITIVE_FIELD_NAMES = new Set([
18
+ "authorization",
19
+ "proxyauthorization",
20
+ "cookie",
21
+ "setcookie",
22
+ "accesstoken",
23
+ "refreshtoken",
24
+ "idtoken",
25
+ "apikey",
26
+ "secret",
27
+ "clientsecret",
28
+ "password",
29
+ "passwd",
30
+ "privatekey",
31
+ "credential",
32
+ "credentials",
33
+ "credentialencrypted",
34
+ "encryptedcredential",
35
+ "headersencrypted",
36
+ "encryptedpkceverifier",
37
+ "codeverifier",
38
+ "signingkey",
39
+ ]);
40
+
41
+ const CREDENTIAL_HEADER_PATTERNS = [
42
+ /^(?:proxy-)?authorization$/i,
43
+ /^(?:set-)?cookie$/i,
44
+ /^(?:x[-_])?api[-_]?key$/i,
45
+ /^(?:x[-_])?(?:access|refresh|id)[-_]?token$/i,
46
+ /^(?:x[-_])?(?:auth|session)[-_]?(?:token|key|secret)$/i,
47
+ /^(?:x[-_])?(?:client|app|consumer)[-_]?secret$/i,
48
+ /^x-opengeni-access-key$/i,
49
+ ] as const;
50
+
51
+ const SECRET_KEY_SOURCE =
52
+ "(?:proxy[-_ ]?authorization|authorization|set[-_ ]?cookie|cookie|access[-_ ]?token|refresh[-_ ]?token|id[-_ ]?token|api[-_ ]?key|client[-_ ]?secret|secret|password|passwd|private[-_ ]?key|credential(?:s|[-_ ]?encrypted)?|encrypted[-_ ]?credential|encrypted[-_ ]?pkce[-_ ]?verifier|code[-_ ]?verifier|signing[-_ ]?key)";
53
+ const UNQUOTED_SECRET_KEY_SOURCE =
54
+ "(?:access[-_ ]?token|refresh[-_ ]?token|id[-_ ]?token|api[-_ ]?key|client[-_ ]?secret|secret|password|passwd|private[-_ ]?key|credential(?:s|[-_ ]?encrypted)?|encrypted[-_ ]?credential|encrypted[-_ ]?pkce[-_ ]?verifier|code[-_ ]?verifier|signing[-_ ]?key)";
55
+
56
+ const AUTHORIZATION_HEADER_PATTERN =
57
+ /(\b(?:proxy-)?authorization[^\S\r\n]*:[^\S\r\n]*)([^\r\n'"`]+)/gi;
58
+ const COOKIE_HEADER_PATTERN = /(\b(?:set-cookie|cookie)\s*:\s*)([^\r\n'"`]+)/gi;
59
+ const API_KEY_HEADER_PATTERN = /(\b(?:x[-_])?api[-_]?key[^\S\r\n]*:[^\S\r\n]*)([^\r\n'"`]+)/gi;
60
+ const CURL_USER_PATTERN = /((?:^|\s)(?:-u|--user)(?:=|\s+))(?:("[^"]*")|('[^']*')|([^\s]+))/gm;
61
+ const URL_USERINFO_PATTERN = /(https?:\/\/)[^\s/@]+@/gi;
62
+ const SIGNED_QUERY_PATTERN = new RegExp(
63
+ `([?&](?:sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential|access_token|refresh_token|token)=)([^&#\\s'"<>]+)`,
64
+ "gi",
65
+ );
66
+ const QUOTED_SECRET_ASSIGNMENT_PATTERN = new RegExp(
67
+ `((?:["']${SECRET_KEY_SOURCE}["']|\\b${SECRET_KEY_SOURCE})\\s*[:=]\\s*)(["'])(.*?)\\2`,
68
+ "gi",
69
+ );
70
+ const UNQUOTED_SECRET_ASSIGNMENT_PATTERN = new RegExp(
71
+ `((?:\\b${UNQUOTED_SECRET_KEY_SOURCE})\\s*[:=]\\s*)([^\\s,;}&]+)`,
72
+ "gi",
73
+ );
74
+ const SECRET_ENV_ASSIGNMENT_PATTERN =
75
+ /((?:^|[\s;])(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|CREDENTIAL|AUTHORIZATION|COOKIE)[A-Za-z0-9_]*\s*=\s*)(?:("[^"]*")|('[^']*')|([^\s;]+))/gim;
76
+
77
+ const PROVIDER_TOKEN_PATTERNS = [
78
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
79
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
80
+ /\bglpat-[A-Za-z0-9_-]{20,}\b/g,
81
+ /\bsk-[A-Za-z0-9_-]{20,}\b/g,
82
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
83
+ /\bAIza[0-9A-Za-z_-]{30,}\b/g,
84
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
85
+ /\bogd_[A-Za-z0-9._~-]{10,}\b/g,
86
+ /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g,
87
+ ] as const;
88
+
89
+ /**
90
+ * Returns true only for fields whose value is itself credential material.
91
+ * Container fields such as `headers` and URL fields are intentionally not
92
+ * included: their nested/value sanitizers retain useful names, hosts, paths,
93
+ * and non-sensitive query parameters.
94
+ */
95
+ export function isSensitiveFieldName(name: string): boolean {
96
+ return SENSITIVE_FIELD_NAMES.has(normalizeFieldName(name));
97
+ }
98
+
99
+ /**
100
+ * Return true only for header names whose values are credential material.
101
+ * Ordinary protocol metadata (`content-type`, `accept`, `user-agent`, and
102
+ * pagination/signature headers outside this allowlist) must remain intact.
103
+ */
104
+ export function isCredentialHeaderName(name: string): boolean {
105
+ return CREDENTIAL_HEADER_PATTERNS.some((pattern) => pattern.test(name));
106
+ }
107
+
108
+ /**
109
+ * Redact only exact known-secret provenance from a structured object key.
110
+ * Generic field/header heuristics intentionally do not run here: a key is
111
+ * metadata unless the caller has proved that its bytes are secret material.
112
+ */
113
+ export function redactSensitiveKey(
114
+ key: string,
115
+ knownSecrets: readonly SecretForRedaction[] = [],
116
+ ): string {
117
+ return replacePreparedSecrets(key, prepareSecrets(knownSecrets));
118
+ }
119
+
120
+ /**
121
+ * Redact known secret provenance and common credential-bearing text shapes.
122
+ * This is deliberately a conservative safety boundary, not a promise of
123
+ * general-purpose DLP. It never includes a matched value in a marker or error.
124
+ */
125
+ export function redactSensitiveText(
126
+ text: string,
127
+ knownSecrets: readonly SecretForRedaction[] = [],
128
+ ): string {
129
+ let redacted = replacePreparedSecrets(text, prepareSecrets(knownSecrets));
130
+
131
+ redacted = redacted.replace(
132
+ AUTHORIZATION_HEADER_PATTERN,
133
+ (match, prefix: string, rawValue: string) => {
134
+ const value = rawValue.trimEnd();
135
+ const trailingWhitespace = rawValue.slice(value.length);
136
+ const schemeMatch = value.match(/^([A-Za-z][A-Za-z0-9_-]*)(\s+)(.+)$/);
137
+ if (schemeMatch) {
138
+ const scheme = schemeMatch[1];
139
+ const whitespace = schemeMatch[2];
140
+ const credential = schemeMatch[3];
141
+ if (scheme && whitespace && credential) {
142
+ return isRedactionMarker(credential.trim())
143
+ ? match
144
+ : `${prefix}${scheme}${whitespace}${REDACTED}${trailingWhitespace}`;
145
+ }
146
+ }
147
+ return isRedactionMarker(value) ? match : `${prefix}${REDACTED}${trailingWhitespace}`;
148
+ },
149
+ );
150
+ redacted = redacted.replace(COOKIE_HEADER_PATTERN, `$1${REDACTED}`);
151
+ redacted = redacted.replace(API_KEY_HEADER_PATTERN, `$1${REDACTED}`);
152
+ redacted = redacted.replace(CURL_USER_PATTERN, (_match, prefix: string) => {
153
+ return `${prefix}${REDACTED}`;
154
+ });
155
+ redacted = redacted.replace(URL_USERINFO_PATTERN, `$1${REDACTED}@`);
156
+ redacted = redacted.replace(SIGNED_QUERY_PATTERN, `$1${REDACTED}`);
157
+ redacted = redacted.replace(
158
+ QUOTED_SECRET_ASSIGNMENT_PATTERN,
159
+ (match, prefix: string, quote: string, value: string) =>
160
+ isRedactionMarker(value) ? match : `${prefix}${quote}${REDACTED}${quote}`,
161
+ );
162
+ redacted = redacted.replace(
163
+ UNQUOTED_SECRET_ASSIGNMENT_PATTERN,
164
+ (match, prefix: string, value: string) =>
165
+ isRedactionMarker(value) ? match : `${prefix}${REDACTED}`,
166
+ );
167
+ redacted = redacted.replace(
168
+ SECRET_ENV_ASSIGNMENT_PATTERN,
169
+ (
170
+ match,
171
+ prefix: string,
172
+ doubleQuoted: string | undefined,
173
+ singleQuoted: string | undefined,
174
+ bare: string | undefined,
175
+ ) => {
176
+ const value = doubleQuoted ?? singleQuoted ?? bare ?? "";
177
+ return isRedactionMarker(stripMatchingQuotes(value)) ? match : `${prefix}${REDACTED}`;
178
+ },
179
+ );
180
+ for (const pattern of PROVIDER_TOKEN_PATTERNS) {
181
+ redacted = redacted.replace(pattern, REDACTED);
182
+ }
183
+ return redacted;
184
+ }
185
+
186
+ /** Deeply redact plain structured data while retaining its diagnostic shape. */
187
+ export function redactSensitiveData<T>(
188
+ value: T,
189
+ knownSecrets: readonly SecretForRedaction[] = [],
190
+ ): T {
191
+ return redactSensitiveDataDeep(value, knownSecrets, new WeakSet<object>(), 0);
192
+ }
193
+
194
+ /** Build the worker-friendly single-argument redactor used at turn boundaries. */
195
+ export function createSecretRedactor(
196
+ knownSecrets: readonly SecretForRedaction[],
197
+ ): (value: unknown) => unknown {
198
+ const prepared = prepareSecrets(knownSecrets).map(({ marker, value }) => ({
199
+ name: marker.slice("[redacted:".length, -1),
200
+ value,
201
+ }));
202
+ return (value: unknown) => redactSensitiveData(value, prepared);
203
+ }
204
+
205
+ /**
206
+ * Redact a serialized JSON checkpoint without requiring it to be valid JSON.
207
+ * Valid JSON retains structure; malformed/opaque text still receives text
208
+ * classification and exact-known-value replacement.
209
+ */
210
+ export function redactSerializedJson(
211
+ serialized: string,
212
+ knownSecrets: readonly SecretForRedaction[] = [],
213
+ ): string {
214
+ try {
215
+ return JSON.stringify(redactSensitiveData(JSON.parse(serialized), knownSecrets));
216
+ } catch {
217
+ return redactSensitiveText(serialized, knownSecrets);
218
+ }
219
+ }
220
+
221
+ export function identityRedactor<T>(value: T): T {
222
+ return value;
223
+ }
224
+
225
+ function redactSensitiveDataDeep<T>(
226
+ value: T,
227
+ knownSecrets: readonly SecretForRedaction[],
228
+ seen: WeakSet<object>,
229
+ depth: number,
230
+ ): T {
231
+ if (typeof value === "string") {
232
+ return redactSensitiveText(value, knownSecrets) as T;
233
+ }
234
+ if (!value || typeof value !== "object" || value instanceof Date) {
235
+ return value;
236
+ }
237
+ if (depth >= MAX_REDACTION_DEPTH) {
238
+ return DEPTH_MARKER as T;
239
+ }
240
+ if (seen.has(value)) {
241
+ return CYCLE_MARKER as T;
242
+ }
243
+ seen.add(value);
244
+ try {
245
+ if (Array.isArray(value)) {
246
+ return value.map((item) => redactSensitiveDataDeep(item, knownSecrets, seen, depth + 1)) as T;
247
+ }
248
+ if (!isPlainObject(value)) {
249
+ return value;
250
+ }
251
+ const usedKeys = new Set<string>();
252
+ return Object.fromEntries(
253
+ Object.entries(value).map(([key, child]) => {
254
+ const safeKey = nextUniqueKey(redactSensitiveKey(key, knownSecrets), usedKeys);
255
+ if (isSensitiveFieldName(key)) {
256
+ return [safeKey, REDACTED] as const;
257
+ }
258
+ if (normalizeFieldName(key) === "headers") {
259
+ return [safeKey, redactHeaderMap(child, knownSecrets, seen, depth + 1)] as const;
260
+ }
261
+ return [safeKey, redactSensitiveDataDeep(child, knownSecrets, seen, depth + 1)] as const;
262
+ }),
263
+ ) as T;
264
+ } finally {
265
+ seen.delete(value);
266
+ }
267
+ }
268
+
269
+ function redactHeaderMap(
270
+ value: unknown,
271
+ knownSecrets: readonly SecretForRedaction[],
272
+ seen: WeakSet<object>,
273
+ depth: number,
274
+ ): unknown {
275
+ if (!isPlainObject(value)) {
276
+ return redactSensitiveDataDeep(value, knownSecrets, seen, depth);
277
+ }
278
+ if (depth >= MAX_REDACTION_DEPTH) return DEPTH_MARKER;
279
+ if (seen.has(value)) return CYCLE_MARKER;
280
+ seen.add(value);
281
+ try {
282
+ const usedKeys = new Set<string>();
283
+ return Object.fromEntries(
284
+ Object.entries(value).map(([key, child]) => {
285
+ const safeKey = nextUniqueKey(redactSensitiveKey(key, knownSecrets), usedKeys);
286
+ return [
287
+ safeKey,
288
+ isCredentialHeaderName(key)
289
+ ? REDACTED
290
+ : redactSensitiveDataDeep(child, knownSecrets, seen, depth + 1),
291
+ ];
292
+ }),
293
+ );
294
+ } finally {
295
+ seen.delete(value);
296
+ }
297
+ }
298
+
299
+ function prepareSecrets(knownSecrets: readonly SecretForRedaction[]): PreparedSecret[] {
300
+ const unique = new Map<string, string>();
301
+ for (const secret of knownSecrets) {
302
+ if (secret.value.length < MIN_REDACTABLE_VALUE_LENGTH || unique.has(secret.value)) {
303
+ continue;
304
+ }
305
+ unique.set(secret.value, `[redacted:${safeSecretName(secret.name)}]`);
306
+ }
307
+ return [...unique]
308
+ .map(([value, marker]) => ({ marker, value }))
309
+ .sort((a, b) => b.value.length - a.value.length || a.marker.localeCompare(b.marker));
310
+ }
311
+
312
+ function replacePreparedSecrets(text: string, prepared: readonly PreparedSecret[]): string {
313
+ let redacted = text;
314
+ for (const secret of prepared) {
315
+ if (redacted.includes(secret.value)) {
316
+ redacted = redacted.split(secret.value).join(secret.marker);
317
+ }
318
+ }
319
+ return redacted;
320
+ }
321
+
322
+ function nextUniqueKey(base: string, usedKeys: Set<string>): string {
323
+ let candidate = base;
324
+ let suffix = 2;
325
+ while (usedKeys.has(candidate)) {
326
+ candidate = `${base}#${suffix}`;
327
+ suffix += 1;
328
+ }
329
+ usedKeys.add(candidate);
330
+ return candidate;
331
+ }
332
+
333
+ function safeSecretName(name: string): string {
334
+ const safe = name
335
+ .toUpperCase()
336
+ .replace(/[^A-Z0-9_]+/g, "_")
337
+ .replace(/^_+|_+$/g, "");
338
+ return safe.slice(0, 64) || "KNOWN_SECRET";
339
+ }
340
+
341
+ function normalizeFieldName(name: string): string {
342
+ return name.toLowerCase().replace(/[-_\s]/g, "");
343
+ }
344
+
345
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
346
+ if (!value || typeof value !== "object") return false;
347
+ const prototype = Object.getPrototypeOf(value);
348
+ return prototype === Object.prototype || prototype === null;
349
+ }
350
+
351
+ function isRedactionMarker(value: string): boolean {
352
+ return /^\[redacted(?::[A-Z0-9_]{1,64})?\]$/.test(value);
353
+ }
354
+
355
+ function stripMatchingQuotes(value: string): string {
356
+ if (
357
+ value.length >= 2 &&
358
+ ((value.startsWith('"') && value.endsWith('"')) ||
359
+ (value.startsWith("'") && value.endsWith("'")))
360
+ ) {
361
+ return value.slice(1, -1);
362
+ }
363
+ return value;
364
+ }