@senad-d/observme 0.1.0

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.
Files changed (111) hide show
  1. package/.env.example +57 -0
  2. package/CHANGELOG.md +52 -0
  3. package/LICENSE +21 -0
  4. package/ObservMe-Production-Docs/00-README.md +79 -0
  5. package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
  6. package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
  7. package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
  8. package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
  9. package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
  10. package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
  11. package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
  12. package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
  13. package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
  14. package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
  15. package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
  16. package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
  17. package/ObservMe-Production-Docs/13-source-notes.md +33 -0
  18. package/ObservMe-Production-Docs/pi-session-format.md +427 -0
  19. package/README.md +356 -0
  20. package/SECURITY.md +45 -0
  21. package/dashboards/observme-agent-node-graphs.json +250 -0
  22. package/dashboards/observme-agents.json +1880 -0
  23. package/dashboards/observme-alerts.yaml +113 -0
  24. package/dashboards/observme-branches-compactions.json +1042 -0
  25. package/dashboards/observme-cost.json +1254 -0
  26. package/dashboards/observme-errors.json +1659 -0
  27. package/dashboards/observme-export-health.json +1802 -0
  28. package/dashboards/observme-latency.json +1494 -0
  29. package/dashboards/observme-llm-conversations.json +730 -0
  30. package/dashboards/observme-logs-llm.json +644 -0
  31. package/dashboards/observme-models.json +933 -0
  32. package/dashboards/observme-overview.json +2129 -0
  33. package/dashboards/observme-slo-health.json +737 -0
  34. package/dashboards/observme-slos.yaml +56 -0
  35. package/dashboards/observme-tools.json +902 -0
  36. package/dashboards/observme-trace-journey.json +1793 -0
  37. package/docs/STRUCTURE.md +49 -0
  38. package/docs/agent-subagent-observability-requirements.md +997 -0
  39. package/docs/compatibility-matrix.md +39 -0
  40. package/docs/configuration-tui-design-standard.md +767 -0
  41. package/docs/configuration.md +38 -0
  42. package/docs/review-validation.md +115 -0
  43. package/docs/validation-flow.md +117 -0
  44. package/examples/collector.yaml +123 -0
  45. package/examples/observme.yaml +131 -0
  46. package/img/demo.gif +0 -0
  47. package/img/icon.svg +47 -0
  48. package/package.json +103 -0
  49. package/src/commands/obs-agents-runtime.ts +150 -0
  50. package/src/commands/obs-agents.ts +491 -0
  51. package/src/commands/obs-args.ts +63 -0
  52. package/src/commands/obs-backfill.ts +1334 -0
  53. package/src/commands/obs-command-support.ts +43 -0
  54. package/src/commands/obs-cost.ts +228 -0
  55. package/src/commands/obs-diagnostics.ts +22 -0
  56. package/src/commands/obs-errors.ts +156 -0
  57. package/src/commands/obs-health.ts +301 -0
  58. package/src/commands/obs-link.ts +90 -0
  59. package/src/commands/obs-logs.ts +194 -0
  60. package/src/commands/obs-loki-summary.ts +184 -0
  61. package/src/commands/obs-session.ts +259 -0
  62. package/src/commands/obs-status.ts +359 -0
  63. package/src/commands/obs-tools.ts +274 -0
  64. package/src/commands/obs-trace.ts +411 -0
  65. package/src/commands/obs.ts +211 -0
  66. package/src/config/bootstrap-project-config.ts +300 -0
  67. package/src/config/defaults.ts +143 -0
  68. package/src/config/load-config.ts +631 -0
  69. package/src/config/project-paths.ts +61 -0
  70. package/src/config/schema.ts +405 -0
  71. package/src/config/validate.ts +456 -0
  72. package/src/constants.ts +4 -0
  73. package/src/diagnostics/sanitize.ts +6 -0
  74. package/src/extension.ts +38 -0
  75. package/src/otel/logs.ts +160 -0
  76. package/src/otel/metrics.ts +165 -0
  77. package/src/otel/otlp-endpoint.ts +10 -0
  78. package/src/otel/sdk.ts +114 -0
  79. package/src/otel/shutdown.ts +102 -0
  80. package/src/otel/traces.ts +166 -0
  81. package/src/pi/agent-lineage.ts +378 -0
  82. package/src/pi/agent-tree-tracker.ts +258 -0
  83. package/src/pi/event-handlers/agent-turn.ts +155 -0
  84. package/src/pi/event-handlers/lifecycle.ts +642 -0
  85. package/src/pi/event-handlers/llm.ts +115 -0
  86. package/src/pi/event-handlers/session-events.ts +159 -0
  87. package/src/pi/event-handlers/tool-bash.ts +275 -0
  88. package/src/pi/handler-internals.ts +2154 -0
  89. package/src/pi/handler-runtime.ts +633 -0
  90. package/src/pi/handler-types.ts +261 -0
  91. package/src/pi/handlers.ts +75 -0
  92. package/src/pi/subagent-spawn.ts +975 -0
  93. package/src/pi/subagent-types.ts +29 -0
  94. package/src/privacy/content-capture.ts +104 -0
  95. package/src/privacy/hash.ts +93 -0
  96. package/src/privacy/redact.ts +619 -0
  97. package/src/privacy/secret-patterns.ts +185 -0
  98. package/src/privacy/truncate.ts +69 -0
  99. package/src/query/grafana-readiness.ts +164 -0
  100. package/src/query/grafana-transport.ts +481 -0
  101. package/src/query/grafana.ts +371 -0
  102. package/src/query/loki.ts +332 -0
  103. package/src/query/prometheus.ts +388 -0
  104. package/src/query/tempo.ts +332 -0
  105. package/src/safety/sensitive-input.ts +208 -0
  106. package/src/semconv/attributes.ts +279 -0
  107. package/src/semconv/metrics.ts +146 -0
  108. package/src/semconv/spans.ts +19 -0
  109. package/src/semconv/values.ts +13 -0
  110. package/src/util/bounded-map.ts +97 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,619 @@
1
+ import {
2
+ basename as posixBasename,
3
+ dirname as posixDirname,
4
+ isAbsolute as isPosixAbsolute,
5
+ } from "node:path/posix";
6
+ import {
7
+ basename as windowsBasename,
8
+ dirname as windowsDirname,
9
+ isAbsolute as isWindowsAbsolute,
10
+ } from "node:path/win32";
11
+ import { sha256, type TenantSaltSource } from "./hash.ts";
12
+ import { matchAllSecretPatterns, type SecretMatch } from "./secret-patterns.ts";
13
+ import type { CustomRedactionPatternConfig, PrivacyPathMode } from "../config/schema.ts";
14
+
15
+ export type RedactionStage =
16
+ | "size_guard"
17
+ | "secret_detector"
18
+ | "pii_detector"
19
+ | "path_scrubber"
20
+ | "custom_regex_redactors"
21
+ | "truncation"
22
+ | "hashing"
23
+ | "export";
24
+
25
+ export interface PiiMatch {
26
+ readonly type: string;
27
+ readonly value: string;
28
+ readonly start: number;
29
+ readonly end: number;
30
+ }
31
+
32
+ export interface RedactionOptions {
33
+ readonly pathMode: PrivacyPathMode;
34
+ readonly customRedactionPatterns?: readonly CustomRedactionPatternConfig[];
35
+ readonly maxInputChars?: number;
36
+ readonly maxOutputChars?: number;
37
+ readonly piiEnabled?: boolean;
38
+ readonly piiDetector?: (value: string) => readonly PiiMatch[];
39
+ readonly secretMatcher?: (value: string) => readonly SecretMatch[];
40
+ readonly onStage?: (stage: RedactionStage) => void;
41
+ readonly tenantSaltSource?: TenantSaltSource;
42
+ }
43
+
44
+ export interface RedactionFailureMetrics {
45
+ readonly redactionFailures: number;
46
+ }
47
+
48
+ export interface RedactionResult {
49
+ readonly value?: string;
50
+ readonly hash?: string;
51
+ readonly dropped: boolean;
52
+ readonly truncated: boolean;
53
+ readonly originalLength?: number;
54
+ readonly failureMetrics: RedactionFailureMetrics;
55
+ readonly errors: readonly string[];
56
+ readonly stages: readonly RedactionStage[];
57
+ }
58
+
59
+ export interface ReplacementMatch {
60
+ readonly type: string;
61
+ readonly value: string;
62
+ readonly start: number;
63
+ readonly end: number;
64
+ }
65
+
66
+ const DEFAULT_MAX_INPUT_CHARS = 1_000_000;
67
+ const DEFAULT_MAX_OUTPUT_CHARS = 32_000;
68
+ const HASH_PREFIX_LENGTH = 12;
69
+ const ABSOLUTE_PATH_CANDIDATE_PATTERN = /(?:[a-zA-Z]:[\\/][^\s'"`<>|]*|\\\\[^\s'"`<>|]*|\/[^\s'"`<>|]*)/gu;
70
+ const PATH_PREFIX_CONTINUATION_PATTERN = /[\p{L}\p{N}._~:/\\-]/u;
71
+ const TRAILING_PATH_PUNCTUATION = new Set([".", ",", ";", "!", "?", ")", "]", "}"]);
72
+ const REDACTED_PATH_PLACEHOLDER = "[REDACTED:path]";
73
+
74
+ export type AbsolutePathStyle = "posix" | "windows";
75
+
76
+ export interface AbsolutePathMatch {
77
+ readonly value: string;
78
+ readonly start: number;
79
+ readonly end: number;
80
+ readonly style: AbsolutePathStyle;
81
+ }
82
+
83
+ export const MAX_CUSTOM_REDACTION_PATTERNS = 16;
84
+ export const MAX_CUSTOM_REDACTION_PATTERN_CHARS = 256;
85
+
86
+ export interface CustomRedactionPatternSafetyIssue {
87
+ readonly code:
88
+ | "custom_redaction_pattern_limit"
89
+ | "custom_redaction_pattern_too_long"
90
+ | "custom_redaction_pattern_unsupported_construct"
91
+ | "custom_redaction_pattern_nested_quantifier"
92
+ | "custom_redaction_pattern_empty_match"
93
+ | "invalid_custom_redaction_pattern";
94
+ readonly message: string;
95
+ }
96
+
97
+ interface RegexGroupScanState {
98
+ hasQuantifier: boolean;
99
+ }
100
+
101
+ interface RegexGroupScanContext {
102
+ readonly groupStack: RegexGroupScanState[];
103
+ escaped: boolean;
104
+ inCharacterClass: boolean;
105
+ lastClosedGroupHadQuantifier: boolean;
106
+ }
107
+
108
+ export function redactValue(rawValue: string, options: RedactionOptions): RedactionResult {
109
+ const stages: RedactionStage[] = [];
110
+ try {
111
+ const guardedValue = runSizeGuard(rawValue, options, stages);
112
+ const secretRedactedValue = runSecretDetector(guardedValue, options, stages);
113
+ const piiRedactedValue = runPiiDetector(secretRedactedValue, options, stages);
114
+ const pathRedactedValue = runPathScrubber(piiRedactedValue, options, stages);
115
+ const customRedactedValue = runCustomRegexRedactors(pathRedactedValue, options, stages);
116
+ const truncated = runTruncation(customRedactedValue, options, stages);
117
+ const hash = runHashing(truncated.value, options, stages);
118
+ runExportStage(options, stages);
119
+
120
+ return {
121
+ value: truncated.value,
122
+ hash,
123
+ dropped: false,
124
+ truncated: truncated.truncated,
125
+ originalLength: truncated.originalLength,
126
+ failureMetrics: { redactionFailures: 0 },
127
+ errors: [],
128
+ stages,
129
+ };
130
+ } catch (error) {
131
+ return redactionFailureResult(error, stages);
132
+ }
133
+ }
134
+
135
+ export function redactPath(path: string, pathMode: PrivacyPathMode, tenantSaltSource?: TenantSaltSource): string | undefined {
136
+ if (pathMode === "full") return path;
137
+ if (pathMode === "drop") return undefined;
138
+
139
+ const style = detectAbsolutePathStyle(path) ?? "posix";
140
+ if (pathMode === "basename") return pathBasename(path, style);
141
+ return hashPath(path, tenantSaltSource, style);
142
+ }
143
+
144
+ export function runSizeGuard(rawValue: string, options: RedactionOptions, stages: RedactionStage[]): string {
145
+ recordStage("size_guard", options, stages);
146
+ const maxInputChars = options.maxInputChars ?? DEFAULT_MAX_INPUT_CHARS;
147
+ if (rawValue.length > maxInputChars) throw new Error("redaction input exceeds size guard");
148
+ return rawValue;
149
+ }
150
+
151
+ export function runSecretDetector(value: string, options: RedactionOptions, stages: RedactionStage[]): string {
152
+ recordStage("secret_detector", options, stages);
153
+ const secretMatcher = options.secretMatcher ?? matchAllSecretPatterns;
154
+ const matches = secretMatcher(value);
155
+ return replaceMatches(value, matches.map(toReplacementMatch), options);
156
+ }
157
+
158
+ export function runPiiDetector(value: string, options: RedactionOptions, stages: RedactionStage[]): string {
159
+ recordStage("pii_detector", options, stages);
160
+ if (options.piiEnabled !== true) return value;
161
+ if (!options.piiDetector) return value;
162
+ const matches = options.piiDetector(value);
163
+ return replaceMatches(value, matches.map(toPiiReplacementMatch), options);
164
+ }
165
+
166
+ export function runPathScrubber(value: string, options: RedactionOptions, stages: RedactionStage[]): string {
167
+ recordStage("path_scrubber", options, stages);
168
+ if (options.pathMode === "full") return value;
169
+ return replaceAbsolutePaths(value, findAbsolutePaths(value), options.pathMode, options.tenantSaltSource);
170
+ }
171
+
172
+ export function runCustomRegexRedactors(value: string, options: RedactionOptions, stages: RedactionStage[]): string {
173
+ recordStage("custom_regex_redactors", options, stages);
174
+ const patterns = options.customRedactionPatterns ?? [];
175
+ const issues = validateCustomRedactionPatterns(patterns);
176
+ if (issues.length > 0) throw new Error(issues[0].message);
177
+
178
+ let redactedValue = value;
179
+ for (const pattern of patterns) redactedValue = applyCustomRedactionPattern(redactedValue, pattern, options.tenantSaltSource);
180
+ return redactedValue;
181
+ }
182
+
183
+ export function runTruncation(
184
+ value: string,
185
+ options: RedactionOptions,
186
+ stages: RedactionStage[],
187
+ ): { readonly value: string; readonly truncated: boolean; readonly originalLength?: number } {
188
+ recordStage("truncation", options, stages);
189
+ const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
190
+ if (value.length <= maxOutputChars) return { value, truncated: false };
191
+ return { value: value.slice(0, maxOutputChars), truncated: true, originalLength: value.length };
192
+ }
193
+
194
+ export function runHashing(value: string, options: RedactionOptions, stages: RedactionStage[]): string {
195
+ recordStage("hashing", options, stages);
196
+ if (!options.tenantSaltSource) throw new Error("tenant salt source is required for redaction hashing");
197
+ return sha256(value, options.tenantSaltSource);
198
+ }
199
+
200
+ export function runExportStage(options: RedactionOptions, stages: RedactionStage[]): void {
201
+ recordStage("export", options, stages);
202
+ }
203
+
204
+ export function recordStage(stage: RedactionStage, options: RedactionOptions, stages: RedactionStage[]): void {
205
+ stages.push(stage);
206
+ options.onStage?.(stage);
207
+ }
208
+
209
+ export function redactionFailureResult(error: unknown, stages: readonly RedactionStage[]): RedactionResult {
210
+ return {
211
+ value: undefined,
212
+ dropped: true,
213
+ truncated: false,
214
+ failureMetrics: { redactionFailures: 1 },
215
+ errors: [error instanceof Error ? error.message : String(error)],
216
+ stages,
217
+ };
218
+ }
219
+
220
+ export function replaceMatches(value: string, matches: readonly ReplacementMatch[], options: RedactionOptions): string {
221
+ const selectedMatches = selectNonOverlappingMatches(matches);
222
+ let result = "";
223
+ let cursor = 0;
224
+
225
+ for (const match of selectedMatches) {
226
+ result += value.slice(cursor, match.start);
227
+ result += formatReplacement(match, options);
228
+ cursor = match.end;
229
+ }
230
+
231
+ return `${result}${value.slice(cursor)}`;
232
+ }
233
+
234
+ export function selectNonOverlappingMatches(matches: readonly ReplacementMatch[]): ReplacementMatch[] {
235
+ const sortedMatches = [...matches].sort(compareReplacementMatches);
236
+ const selectedMatches: ReplacementMatch[] = [];
237
+ let cursor = 0;
238
+
239
+ for (const match of sortedMatches) {
240
+ if (match.start < cursor) continue;
241
+ selectedMatches.push(match);
242
+ cursor = match.end;
243
+ }
244
+
245
+ return selectedMatches;
246
+ }
247
+
248
+ export function compareReplacementMatches(left: ReplacementMatch, right: ReplacementMatch): number {
249
+ if (left.start !== right.start) return left.start - right.start;
250
+ if (left.end !== right.end) return right.end - left.end;
251
+ return left.type.localeCompare(right.type);
252
+ }
253
+
254
+ export function toReplacementMatch(match: SecretMatch): ReplacementMatch {
255
+ return {
256
+ type: match.type,
257
+ value: match.value,
258
+ start: match.start,
259
+ end: match.end,
260
+ };
261
+ }
262
+
263
+ export function toPiiReplacementMatch(match: PiiMatch): ReplacementMatch {
264
+ return {
265
+ type: normalizeCustomType(match.type),
266
+ value: match.value,
267
+ start: match.start,
268
+ end: match.end,
269
+ };
270
+ }
271
+
272
+ export function formatReplacement(match: ReplacementMatch, options: RedactionOptions): string {
273
+ return `[REDACTED:${match.type}:${sha256Prefix(match.value, options.tenantSaltSource)}]`;
274
+ }
275
+
276
+ export function findAbsolutePaths(value: string): AbsolutePathMatch[] {
277
+ const matches: AbsolutePathMatch[] = [];
278
+
279
+ for (const candidateMatch of value.matchAll(ABSOLUTE_PATH_CANDIDATE_PATTERN)) {
280
+ const candidateStart = candidateMatch.index;
281
+ if (!isAbsolutePathStartBoundary(value, candidateStart)) continue;
282
+
283
+ const candidate = trimTrailingPathPunctuation(candidateMatch[0]);
284
+ const style = detectAbsolutePathStyle(candidate);
285
+ if (!style) continue;
286
+
287
+ matches.push({ value: candidate, start: candidateStart, end: candidateStart + candidate.length, style });
288
+ }
289
+
290
+ return matches;
291
+ }
292
+
293
+ export function isAbsolutePathStartBoundary(value: string, start: number): boolean {
294
+ if (start === 0) return true;
295
+ return !PATH_PREFIX_CONTINUATION_PATTERN.test(value[start - 1]);
296
+ }
297
+
298
+ export function trimTrailingPathPunctuation(value: string): string {
299
+ let end = value.length;
300
+ while (end > 0 && TRAILING_PATH_PUNCTUATION.has(value[end - 1])) end -= 1;
301
+ return value.slice(0, end);
302
+ }
303
+
304
+ export function detectAbsolutePathStyle(path: string): AbsolutePathStyle | undefined {
305
+ if (isWindowsDrivePath(path) || isUncPath(path)) return "windows";
306
+ if (isPosixAbsolute(path) && !path.startsWith("//")) return "posix";
307
+ return undefined;
308
+ }
309
+
310
+ export function isWindowsDrivePath(path: string): boolean {
311
+ return /^[a-zA-Z]:[\\/]/u.test(path) && isWindowsAbsolute(path);
312
+ }
313
+
314
+ export function isUncPath(path: string): boolean {
315
+ if (!path.startsWith("\\\\") || !isWindowsAbsolute(path)) return false;
316
+ const segments = path.slice(2).split(/[\\/]+/u);
317
+ return segments.length >= 2 && segments[0].length > 0 && segments[1].length > 0;
318
+ }
319
+
320
+ export function replaceAbsolutePaths(
321
+ value: string,
322
+ matches: readonly AbsolutePathMatch[],
323
+ pathMode: PrivacyPathMode,
324
+ tenantSaltSource?: TenantSaltSource,
325
+ ): string {
326
+ let result = "";
327
+ let cursor = 0;
328
+
329
+ for (const match of matches) {
330
+ result += value.slice(cursor, match.start);
331
+ result += redactEmbeddedPath(match.value, pathMode, tenantSaltSource, match.style);
332
+ cursor = match.end;
333
+ }
334
+
335
+ return `${result}${value.slice(cursor)}`;
336
+ }
337
+
338
+ export function redactEmbeddedPath(
339
+ path: string,
340
+ pathMode: PrivacyPathMode,
341
+ tenantSaltSource?: TenantSaltSource,
342
+ style: AbsolutePathStyle = detectAbsolutePathStyle(path) ?? "posix",
343
+ ): string {
344
+ if (pathMode === "full") return path;
345
+ if (pathMode === "drop") return REDACTED_PATH_PLACEHOLDER;
346
+ if (pathMode === "basename") return pathBasename(path, style);
347
+ return hashPath(path, tenantSaltSource, style);
348
+ }
349
+
350
+ export function hashPath(
351
+ path: string,
352
+ tenantSaltSource?: TenantSaltSource,
353
+ style: AbsolutePathStyle = detectAbsolutePathStyle(path) ?? "posix",
354
+ ): string {
355
+ return `/<home>/${sha256Prefix(pathDirname(path, style), tenantSaltSource)}/${pathBasename(path, style)}`;
356
+ }
357
+
358
+ export function pathBasename(path: string, style: AbsolutePathStyle): string {
359
+ return style === "windows" ? windowsBasename(path) : posixBasename(path);
360
+ }
361
+
362
+ export function pathDirname(path: string, style: AbsolutePathStyle): string {
363
+ return style === "windows" ? windowsDirname(path) : posixDirname(path);
364
+ }
365
+
366
+ export function applyCustomRedactionPattern(
367
+ value: string,
368
+ pattern: CustomRedactionPatternConfig,
369
+ tenantSaltSource?: TenantSaltSource,
370
+ ): string {
371
+ const expression = compileCustomRedactionPattern(pattern.pattern);
372
+ return value.replace(expression, match => formatCustomReplacement(pattern.name, match, tenantSaltSource));
373
+ }
374
+
375
+ export function compileCustomRedactionPattern(pattern: string): RegExp {
376
+ const issues = validateCustomRedactionPattern(pattern);
377
+ if (issues.length > 0) throw new Error(issues[0].message);
378
+ if (pattern.startsWith("(?i)")) return new RegExp(pattern.slice(4), "giu");
379
+ return new RegExp(pattern, "gu");
380
+ }
381
+
382
+ export function validateCustomRedactionPatterns(
383
+ patterns: readonly CustomRedactionPatternConfig[],
384
+ ): CustomRedactionPatternSafetyIssue[] {
385
+ const issues: CustomRedactionPatternSafetyIssue[] = [];
386
+ if (patterns.length > MAX_CUSTOM_REDACTION_PATTERNS) issues.push(createCustomRedactionPatternLimitIssue());
387
+
388
+ for (const [index, pattern] of patterns.entries()) {
389
+ issues.push(...validateCustomRedactionPattern(pattern.pattern).map(issue => addCustomRedactionPatternIndex(issue, index)));
390
+ }
391
+
392
+ return issues;
393
+ }
394
+
395
+ export function validateCustomRedactionPattern(pattern: string): CustomRedactionPatternSafetyIssue[] {
396
+ if (pattern.length > MAX_CUSTOM_REDACTION_PATTERN_CHARS) return [createCustomRedactionPatternTooLongIssue()];
397
+
398
+ const source = normalizeCustomRedactionPatternSource(pattern);
399
+ const unsafeIssue = detectUnsafeCustomRedactionPattern(source);
400
+ if (unsafeIssue) return [unsafeIssue];
401
+
402
+ return validateCustomRedactionPatternSyntax(source);
403
+ }
404
+
405
+ export function normalizeCustomRedactionPatternSource(pattern: string): string {
406
+ if (pattern.startsWith("(?i)")) return pattern.slice(4);
407
+ return pattern;
408
+ }
409
+
410
+ export function detectUnsafeCustomRedactionPattern(source: string): CustomRedactionPatternSafetyIssue | undefined {
411
+ if (hasUnsupportedCustomRedactionConstruct(source)) return createCustomRedactionPatternUnsupportedConstructIssue();
412
+ if (hasNestedQuantifiedGroup(source)) return createCustomRedactionPatternNestedQuantifierIssue();
413
+ return undefined;
414
+ }
415
+
416
+ export function hasUnsupportedCustomRedactionConstruct(source: string): boolean {
417
+ let escaped = false;
418
+ let inCharacterClass = false;
419
+
420
+ for (let index = 0; index < source.length; index += 1) {
421
+ const char = source[index];
422
+ if (escaped) {
423
+ if (isBackreferenceEscape(source, index)) return true;
424
+ escaped = false;
425
+ continue;
426
+ }
427
+ if (char === "\\") {
428
+ escaped = true;
429
+ continue;
430
+ }
431
+ if (char === "[") inCharacterClass = true;
432
+ if (char === "]") inCharacterClass = false;
433
+ if (!inCharacterClass && char === "(" && source[index + 1] === "?" && source[index + 2] !== ":") return true;
434
+ }
435
+
436
+ return false;
437
+ }
438
+
439
+ export function hasNestedQuantifiedGroup(source: string): boolean {
440
+ const context = createRegexGroupScanContext();
441
+
442
+ for (let index = 0; index < source.length; index += 1) {
443
+ if (scanRegexEscape(source[index], context)) continue;
444
+ if (scanRegexCharacterClass(source[index], context)) continue;
445
+ if (context.inCharacterClass) continue;
446
+ if (scanRegexGroupBoundary(source[index], context)) continue;
447
+ if (scanRegexQuantifier(source, index, context)) return true;
448
+ context.lastClosedGroupHadQuantifier = false;
449
+ }
450
+
451
+ return false;
452
+ }
453
+
454
+ function createRegexGroupScanContext(): RegexGroupScanContext {
455
+ return { groupStack: [], escaped: false, inCharacterClass: false, lastClosedGroupHadQuantifier: false };
456
+ }
457
+
458
+ function scanRegexEscape(char: string, context: RegexGroupScanContext): boolean {
459
+ if (context.escaped) {
460
+ context.escaped = false;
461
+ context.lastClosedGroupHadQuantifier = false;
462
+ return true;
463
+ }
464
+
465
+ if (char !== "\\") return false;
466
+ context.escaped = true;
467
+ context.lastClosedGroupHadQuantifier = false;
468
+ return true;
469
+ }
470
+
471
+ function scanRegexCharacterClass(char: string, context: RegexGroupScanContext): boolean {
472
+ if (char === "[" && !context.inCharacterClass) {
473
+ context.inCharacterClass = true;
474
+ context.lastClosedGroupHadQuantifier = false;
475
+ return true;
476
+ }
477
+
478
+ if (char !== "]" || !context.inCharacterClass) return false;
479
+ context.inCharacterClass = false;
480
+ return true;
481
+ }
482
+
483
+ function scanRegexGroupBoundary(char: string, context: RegexGroupScanContext): boolean {
484
+ if (char === "(") {
485
+ context.groupStack.push({ hasQuantifier: false });
486
+ context.lastClosedGroupHadQuantifier = false;
487
+ return true;
488
+ }
489
+
490
+ if (char !== ")") return false;
491
+ context.lastClosedGroupHadQuantifier = context.groupStack.pop()?.hasQuantifier === true;
492
+ return true;
493
+ }
494
+
495
+ function scanRegexQuantifier(source: string, index: number, context: RegexGroupScanContext): boolean {
496
+ if (!isRegexQuantifierStart(source, index)) return false;
497
+ if (context.lastClosedGroupHadQuantifier) return true;
498
+
499
+ markCurrentRegexGroupQuantified(context.groupStack);
500
+ context.lastClosedGroupHadQuantifier = false;
501
+ return false;
502
+ }
503
+
504
+ export function validateCustomRedactionPatternSyntax(source: string): CustomRedactionPatternSafetyIssue[] {
505
+ try {
506
+ const expression = new RegExp(source, "u");
507
+ if (expression.test("")) return [createCustomRedactionPatternEmptyMatchIssue()];
508
+ return [];
509
+ } catch (error) {
510
+ return [createInvalidCustomRedactionPatternIssue(error)];
511
+ }
512
+ }
513
+
514
+ export function isBackreferenceEscape(source: string, index: number): boolean {
515
+ const char = source[index];
516
+ return /[1-9]/u.test(char) || (char === "k" && source[index + 1] === "<");
517
+ }
518
+
519
+ export function isRegexQuantifierStart(source: string, index: number): boolean {
520
+ const char = source[index];
521
+ if (char === "?" && source[index - 1] === "(") return false;
522
+ return char === "*" || char === "+" || char === "?" || isBraceQuantifierStart(source, index);
523
+ }
524
+
525
+ export function isBraceQuantifierStart(source: string, index: number): boolean {
526
+ if (source[index] !== "{") return false;
527
+ const closeIndex = source.indexOf("}", index + 1);
528
+ if (closeIndex === -1) return false;
529
+ return /^\d+(?:,\d*)?$/u.test(source.slice(index + 1, closeIndex));
530
+ }
531
+
532
+ export function markCurrentRegexGroupQuantified(groupStack: RegexGroupScanState[]): void {
533
+ const currentGroup = groupStack.at(-1);
534
+ if (currentGroup) currentGroup.hasQuantifier = true;
535
+ }
536
+
537
+ export function addCustomRedactionPatternIndex(
538
+ issue: CustomRedactionPatternSafetyIssue,
539
+ index: number,
540
+ ): CustomRedactionPatternSafetyIssue {
541
+ return {
542
+ code: issue.code,
543
+ message: `Custom redaction pattern at index ${index} was rejected: ${issue.message}`,
544
+ };
545
+ }
546
+
547
+ export function createCustomRedactionPatternLimitIssue(): CustomRedactionPatternSafetyIssue {
548
+ return {
549
+ code: "custom_redaction_pattern_limit",
550
+ message: `Custom redaction patterns are limited to ${MAX_CUSTOM_REDACTION_PATTERNS} entries.`,
551
+ };
552
+ }
553
+
554
+ export function createCustomRedactionPatternTooLongIssue(): CustomRedactionPatternSafetyIssue {
555
+ return {
556
+ code: "custom_redaction_pattern_too_long",
557
+ message: `Custom redaction patterns are limited to ${MAX_CUSTOM_REDACTION_PATTERN_CHARS} characters.`,
558
+ };
559
+ }
560
+
561
+ export function createCustomRedactionPatternUnsupportedConstructIssue(): CustomRedactionPatternSafetyIssue {
562
+ return {
563
+ code: "custom_redaction_pattern_unsupported_construct",
564
+ message: "Custom redaction patterns do not support lookaround, inline flag groups, named groups, or backreferences.",
565
+ };
566
+ }
567
+
568
+ export function createCustomRedactionPatternNestedQuantifierIssue(): CustomRedactionPatternSafetyIssue {
569
+ return {
570
+ code: "custom_redaction_pattern_nested_quantifier",
571
+ message: "Custom redaction patterns must not repeat a group that already contains a quantifier.",
572
+ };
573
+ }
574
+
575
+ export function createCustomRedactionPatternEmptyMatchIssue(): CustomRedactionPatternSafetyIssue {
576
+ return {
577
+ code: "custom_redaction_pattern_empty_match",
578
+ message: "Custom redaction patterns must not match an empty string.",
579
+ };
580
+ }
581
+
582
+ export function createInvalidCustomRedactionPatternIssue(error?: unknown): CustomRedactionPatternSafetyIssue {
583
+ return {
584
+ code: "invalid_custom_redaction_pattern",
585
+ message: `Custom redaction pattern is not a valid regular expression${formatRegexSyntaxError(error)}.`,
586
+ };
587
+ }
588
+
589
+ function formatRegexSyntaxError(error: unknown): string {
590
+ if (error === undefined) return "";
591
+ if (error instanceof Error) return ` (${error.name})`;
592
+ return " (unknown parser failure)";
593
+ }
594
+
595
+ export function formatCustomReplacement(name: string, value: string, tenantSaltSource?: TenantSaltSource): string {
596
+ return `[REDACTED:${normalizeCustomType(name)}:${sha256Prefix(value, tenantSaltSource)}]`;
597
+ }
598
+
599
+ export function normalizeCustomType(value: string): string {
600
+ const normalized = trimUnderscores(value.toLowerCase().replace(/[^a-z0-9_]+/gu, "_"));
601
+ return normalized || "custom";
602
+ }
603
+
604
+ function trimUnderscores(value: string): string {
605
+ let start = 0;
606
+ let end = value.length;
607
+ while (start < end && value[start] === "_") start += 1;
608
+ while (end > start && value[end - 1] === "_") end -= 1;
609
+ return value.slice(start, end);
610
+ }
611
+
612
+ export function sha256Prefix(value: string, tenantSaltSource?: TenantSaltSource): string {
613
+ if (!tenantSaltSource) throw new Error("tenant salt source is required for redaction hashing");
614
+ return sha256Hex(value, tenantSaltSource).slice(0, HASH_PREFIX_LENGTH);
615
+ }
616
+
617
+ export function sha256Hex(value: string, tenantSaltSource: TenantSaltSource): string {
618
+ return sha256(value, tenantSaltSource);
619
+ }