@tangle-network/agent-app 0.1.6 → 0.1.8

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.
@@ -0,0 +1,104 @@
1
+ // src/redact/index.ts
2
+ var DEFAULT_REDACTION_PATTERNS = [
3
+ { kind: "ssn", pattern: /\d{3}-\d{2}-\d{4}/ },
4
+ { kind: "ein", pattern: /\d{2}-\d{7}/ }
5
+ ];
6
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
7
+ "ssn",
8
+ "ein",
9
+ "password",
10
+ "apikey",
11
+ "token",
12
+ "secret",
13
+ "authorization",
14
+ "email",
15
+ "phone"
16
+ ]);
17
+ function redactString(value, patterns) {
18
+ for (const { kind, pattern, validate } of patterns) {
19
+ if (!validate) {
20
+ if (pattern.test(value)) return `[REDACTED:${kind}]`;
21
+ continue;
22
+ }
23
+ const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
24
+ for (const m of value.matchAll(g)) {
25
+ if (m[0].length > 0 && validate(m[0])) return `[REDACTED:${kind}]`;
26
+ }
27
+ }
28
+ return value;
29
+ }
30
+ function isPlainObject(value) {
31
+ if (value === null || typeof value !== "object") return false;
32
+ const proto = Object.getPrototypeOf(value);
33
+ return proto === Object.prototype || proto === null;
34
+ }
35
+ function redactForIngestion(value, options = {}) {
36
+ const patterns = options.extraPatterns ? [...DEFAULT_REDACTION_PATTERNS, ...options.extraPatterns] : DEFAULT_REDACTION_PATTERNS;
37
+ const sensitiveKeys = options.extraSensitiveKeys ? /* @__PURE__ */ new Set([...SENSITIVE_KEYS, ...options.extraSensitiveKeys.map((k) => k.toLowerCase())]) : SENSITIVE_KEYS;
38
+ const walk = (v) => {
39
+ if (typeof v === "string") return redactString(v, patterns);
40
+ if (Array.isArray(v)) return v.map(walk);
41
+ if (isPlainObject(v)) {
42
+ const out = {};
43
+ for (const [k, val] of Object.entries(v)) {
44
+ out[k] = sensitiveKeys.has(k.toLowerCase()) ? "[REDACTED:field]" : walk(val);
45
+ }
46
+ return out;
47
+ }
48
+ return v;
49
+ };
50
+ return walk(value);
51
+ }
52
+ function detectSpans(text, patterns = DEFAULT_REDACTION_PATTERNS) {
53
+ const raw = [];
54
+ for (const { kind, pattern, validate } of patterns) {
55
+ const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
56
+ for (const m of text.matchAll(g)) {
57
+ if (m.index === void 0 || m[0].length === 0) continue;
58
+ if (validate && !validate(m[0])) continue;
59
+ raw.push({ kind, start: m.index, end: m.index + m[0].length, text: m[0] });
60
+ }
61
+ }
62
+ raw.sort((a, b) => a.start - b.start || b.end - a.end);
63
+ const spans = [];
64
+ let cursor = -1;
65
+ let i = 0;
66
+ for (const s of raw) {
67
+ if (s.start < cursor) continue;
68
+ spans.push({ id: `span-${i++}`, ...s });
69
+ cursor = s.end;
70
+ }
71
+ return spans;
72
+ }
73
+ async function buildRedactedDocument(text, options) {
74
+ const spans = detectSpans(text, options.patterns);
75
+ const segments = [];
76
+ let pos = 0;
77
+ for (const span of spans) {
78
+ if (span.start > pos) segments.push({ type: "text", text: text.slice(pos, span.start) });
79
+ segments.push({ type: "redacted", id: span.id, kind: span.kind, cipher: await options.encrypt(span.text) });
80
+ pos = span.end;
81
+ }
82
+ if (pos < text.length) segments.push({ type: "text", text: text.slice(pos) });
83
+ return { segments };
84
+ }
85
+ async function revealSpan(doc, spanId, options) {
86
+ const seg = doc.segments.find(
87
+ (s) => s.type === "redacted" && s.id === spanId
88
+ );
89
+ if (!seg) return { ok: false, reason: "not_found" };
90
+ const allowed = await options.canReveal({ id: seg.id, kind: seg.kind });
91
+ if (!allowed) return { ok: false, reason: "forbidden" };
92
+ const value = await options.decrypt(seg.cipher);
93
+ if (options.onReveal) await options.onReveal({ id: seg.id, kind: seg.kind });
94
+ return { ok: true, value };
95
+ }
96
+
97
+ export {
98
+ DEFAULT_REDACTION_PATTERNS,
99
+ redactForIngestion,
100
+ detectSpans,
101
+ buildRedactedDocument,
102
+ revealSpan
103
+ };
104
+ //# sourceMappingURL=chunk-MSXDQ2GB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/redact/index.ts"],"sourcesContent":["/**\n * PII redaction — two complementary modes.\n *\n * 1. ONE-WAY scrub (`redactForIngestion`): for production trace payloads. Tool\n * args + results (and once, the LLM span's prompt) cross the wire into the\n * ingestion store, which also feeds the analyst-loop's LLM prompts, so\n * personal identifiers MUST be stripped before they leave the request path.\n * Destructive — the original is gone, replaced by a sentinel.\n *\n * 2. REVERSIBLE redaction (`buildRedactedDocument` / `revealSpan`): for the UI.\n * A document is split into text + redacted segments; each redacted original\n * is kept ENCRYPTED (via a caller-supplied `encrypt` seam → `agent-app/crypto`)\n * so a viewer can reveal a single span on demand, gated by an authorization\n * callback and an audit hook. The mask is presentation; the original is\n * recoverable by an authorized reveal, not lost.\n *\n * Discipline: cheap deterministic string patterns + well-known sensitive object\n * keys (value replaced, key kept, so the shape stays debuggable); recurse arrays\n * + plain objects only; NEVER throw on the one-way path.\n */\n\n/** A named PII pattern. `pattern` is matched case-insensitively at the string\n * level; keep it non-global (global instances are derived where needed). */\nexport interface RedactionPattern {\n kind: string\n pattern: RegExp\n /** Optional predicate over each match — the pattern fires only when it returns\n * true. For matches a regex alone can't decide (e.g. a Luhn check on a\n * card-number candidate). When set, the value is scanned globally and the\n * first match that passes wins; when absent, a plain `pattern.test` decides. */\n validate?: (match: string) => boolean\n}\n\n/** The default deterministic patterns. Extend via the `extraPatterns` /\n * `patterns` options rather than forking this module (the seam that lets a\n * product add e.g. a credit-card matcher without a local copy). */\nexport const DEFAULT_REDACTION_PATTERNS: readonly RedactionPattern[] = [\n { kind: 'ssn', pattern: /\\d{3}-\\d{2}-\\d{4}/ },\n { kind: 'ein', pattern: /\\d{2}-\\d{7}/ },\n]\n\nconst SENSITIVE_KEYS = new Set([\n 'ssn',\n 'ein',\n 'password',\n 'apikey',\n 'token',\n 'secret',\n 'authorization',\n 'email',\n 'phone',\n])\n\nexport interface RedactForIngestionOptions {\n /** Extra patterns appended to {@link DEFAULT_REDACTION_PATTERNS} for the\n * string-level scrub (e.g. credit-card). Additive — defaults still apply. */\n extraPatterns?: readonly RedactionPattern[]\n /** Extra sensitive object-key names (case-insensitive) added to the built-in\n * set, e.g. the snake_case `api_key` an intake form uses. Additive. */\n extraSensitiveKeys?: readonly string[]\n}\n\nfunction redactString(value: string, patterns: readonly RedactionPattern[]): string {\n for (const { kind, pattern, validate } of patterns) {\n if (!validate) {\n if (pattern.test(value)) return `[REDACTED:${kind}]`\n continue\n }\n const g = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)\n for (const m of value.matchAll(g)) {\n if (m[0].length > 0 && validate(m[0])) return `[REDACTED:${kind}]`\n }\n }\n return value\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== 'object') return false\n const proto = Object.getPrototypeOf(value)\n return proto === Object.prototype || proto === null\n}\n\n/**\n * One-way PII scrub for telemetry/ingestion. Backward-compatible: called with no\n * options it behaves exactly as before (SSN/EIN strings + sensitive object keys\n * → sentinels). `extraPatterns` lets a product add matchers (e.g. credit-card)\n * without forking this module.\n */\nexport function redactForIngestion(value: unknown, options: RedactForIngestionOptions = {}): unknown {\n const patterns = options.extraPatterns\n ? [...DEFAULT_REDACTION_PATTERNS, ...options.extraPatterns]\n : DEFAULT_REDACTION_PATTERNS\n const sensitiveKeys = options.extraSensitiveKeys\n ? new Set([...SENSITIVE_KEYS, ...options.extraSensitiveKeys.map((k) => k.toLowerCase())])\n : SENSITIVE_KEYS\n const walk = (v: unknown): unknown => {\n if (typeof v === 'string') return redactString(v, patterns)\n if (Array.isArray(v)) return v.map(walk)\n if (isPlainObject(v)) {\n const out: Record<string, unknown> = {}\n for (const [k, val] of Object.entries(v)) {\n out[k] = sensitiveKeys.has(k.toLowerCase()) ? '[REDACTED:field]' : walk(val)\n }\n return out\n }\n return v\n }\n return walk(value)\n}\n\n// ── Reversible document redaction (the UI path) ─────────────────────────────\n\n/** A detected PII span in a source string. */\nexport interface RedactionSpan {\n /** Stable within a document (index-derived) — used for reveal + audit. */\n id: string\n kind: string\n start: number\n end: number\n text: string\n}\n\n/**\n * Find non-overlapping PII spans in `text`. Matches every pattern, sorts by\n * position, and drops overlaps (first match wins). Deterministic — no ids that\n * vary per call.\n */\nexport function detectSpans(\n text: string,\n patterns: readonly RedactionPattern[] = DEFAULT_REDACTION_PATTERNS,\n): RedactionSpan[] {\n const raw: Array<{ kind: string; start: number; end: number; text: string }> = []\n for (const { kind, pattern, validate } of patterns) {\n const g = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)\n for (const m of text.matchAll(g)) {\n if (m.index === undefined || m[0].length === 0) continue\n if (validate && !validate(m[0])) continue\n raw.push({ kind, start: m.index, end: m.index + m[0].length, text: m[0] })\n }\n }\n raw.sort((a, b) => a.start - b.start || b.end - a.end)\n const spans: RedactionSpan[] = []\n let cursor = -1\n let i = 0\n for (const s of raw) {\n if (s.start < cursor) continue // overlaps an earlier (higher-priority) span\n spans.push({ id: `span-${i++}`, ...s })\n cursor = s.end\n }\n return spans\n}\n\n/** A redacted document segment: literal text, or a masked span with the\n * original kept ENCRYPTED for an authorized reveal. */\nexport type RedactedDocSegment =\n | { type: 'text'; text: string }\n | { type: 'redacted'; id: string; kind: string; cipher: string }\n\nexport interface RedactedDocument {\n segments: RedactedDocSegment[]\n}\n\nexport interface BuildRedactedDocumentOptions {\n /** Encrypt one original span value. Wire it to `agent-app/crypto`\n * (`encryptWithKey` / `createFieldCrypto`). The cipher is what's stored. */\n encrypt: (plaintext: string) => string | Promise<string>\n /** Patterns to detect (default: {@link DEFAULT_REDACTION_PATTERNS}). */\n patterns?: readonly RedactionPattern[]\n}\n\n/**\n * Split `text` into text + redacted segments, encrypting each redacted span's\n * original. The result carries NO plaintext PII — only the masked structure and\n * ciphertext — so it is safe to ship to a client; reveal happens server-side via\n * {@link revealSpan}.\n */\nexport async function buildRedactedDocument(\n text: string,\n options: BuildRedactedDocumentOptions,\n): Promise<RedactedDocument> {\n const spans = detectSpans(text, options.patterns)\n const segments: RedactedDocSegment[] = []\n let pos = 0\n for (const span of spans) {\n if (span.start > pos) segments.push({ type: 'text', text: text.slice(pos, span.start) })\n segments.push({ type: 'redacted', id: span.id, kind: span.kind, cipher: await options.encrypt(span.text) })\n pos = span.end\n }\n if (pos < text.length) segments.push({ type: 'text', text: text.slice(pos) })\n return { segments }\n}\n\nexport interface RevealSpanOptions {\n /** Decrypt a span cipher. Wire to `agent-app/crypto` (`decryptWithKey`). */\n decrypt: (cipher: string) => string | Promise<string>\n /** Authorization gate — return false to deny the reveal (fail-closed). */\n canReveal: (segment: { id: string; kind: string }) => boolean | Promise<boolean>\n /** Audit hook — invoked only on a granted reveal (the caller records who/when). */\n onReveal?: (segment: { id: string; kind: string }) => void | Promise<void>\n}\n\nexport interface RevealResult {\n ok: boolean\n value?: string\n /** `not_found` | `forbidden` when `ok` is false. */\n reason?: string\n}\n\n/**\n * Reveal one redacted span's original, gated + audited. Fail-closed: an unknown\n * id or a denied `canReveal` returns `{ ok: false }` and never decrypts; a\n * granted reveal decrypts, fires `onReveal` for the audit trail, and returns the\n * value.\n */\nexport async function revealSpan(\n doc: RedactedDocument,\n spanId: string,\n options: RevealSpanOptions,\n): Promise<RevealResult> {\n const seg = doc.segments.find((s): s is Extract<RedactedDocSegment, { type: 'redacted' }> =>\n s.type === 'redacted' && s.id === spanId,\n )\n if (!seg) return { ok: false, reason: 'not_found' }\n const allowed = await options.canReveal({ id: seg.id, kind: seg.kind })\n if (!allowed) return { ok: false, reason: 'forbidden' }\n const value = await options.decrypt(seg.cipher)\n if (options.onReveal) await options.onReveal({ id: seg.id, kind: seg.kind })\n return { ok: true, value }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { DeriveKeyOptions, createFieldCrypto, decodeHexKey, decryptAesGcm, decry
14
14
  export { JsonRecord, PersistedChatMessageForTurn, ResolvedChatTurn, StreamEvent, asRecord, asString, buildUserTextParts, encodeEvent, finalizeAssistantParts, getPartKey, mergePersistedPart, messageHasTurnId, normalizeClientTurnId, normalizePersistedPart, normalizeTime, normalizeToolEvent, resolveChatTurn, resolveToolId, resolveToolName } from './stream/index.js';
15
15
  export { HubExecClient, HubExecClientOptions, HubExecErrorCode, HubExecResult, HubInvokeDeps, HubInvokeInput, HubInvokeOutcome, ParsedIntegrationAction, invokeIntegrationHub, resolveIntegrationAction } from './integrations/index.js';
16
16
  export { JsonObject, KvLike, RateLimitResult, RequestContext, SecurityHeaderOptions, addSecurityHeaders, checkRateLimit, extractRequestContext, parseJsonObjectBody, requireString } from './web/index.js';
17
- export { redactForIngestion } from './redact/index.js';
17
+ export { BuildRedactedDocumentOptions, DEFAULT_REDACTION_PATTERNS, RedactForIngestionOptions, RedactedDocSegment, RedactedDocument, RedactionPattern, RedactionSpan, RevealResult, RevealSpanOptions, buildRedactedDocument, detectSpans, redactForIngestion, revealSpan } from './redact/index.js';
18
18
  export { CompletionRequirement, CompletionVerdict, CorrectnessChecker, ProducedState, RuntimeEventLike, SatisfiedBy, TaskGold, createLlmCorrectnessChecker, extractProducedState, verifyCompletion, weightedComposite } from '@tangle-network/agent-eval';
19
19
  export { D as DEFAULT_TANGLE_ROUTER_BASE_URL, R as ResolveModelOptions, T as TangleModelConfig, r as resolveTangleModelConfig } from './model-BOP69mVu.js';
20
20
  import '@tangle-network/agent-knowledge';
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import {
2
- redactForIngestion
3
- } from "./chunk-C5CREGT2.js";
2
+ DEFAULT_REDACTION_PATTERNS,
3
+ buildRedactedDocument,
4
+ detectSpans,
5
+ redactForIngestion,
6
+ revealSpan
7
+ } from "./chunk-MSXDQ2GB.js";
4
8
  import {
5
9
  DEFAULT_HARNESS,
6
10
  KNOWN_HARNESSES,
@@ -124,6 +128,7 @@ export {
124
128
  DEFAULT_APP_TOOL_PATHS,
125
129
  DEFAULT_HARNESS,
126
130
  DEFAULT_HEADER_NAMES,
131
+ DEFAULT_REDACTION_PATTERNS,
127
132
  DEFAULT_TANGLE_ROUTER_BASE_URL,
128
133
  DELEGATION_MCP_SERVER_KEY,
129
134
  DELEGATION_TOOLS,
@@ -143,6 +148,7 @@ export {
143
148
  buildDelegationMcpServer,
144
149
  buildHttpMcpServer,
145
150
  buildKnowledgeRequirements,
151
+ buildRedactedDocument,
146
152
  buildUserTextParts,
147
153
  checkRateLimit,
148
154
  coerceHarness,
@@ -171,6 +177,7 @@ export {
171
177
  delegationMcpForConfig,
172
178
  deriveKey,
173
179
  deriveSignals,
180
+ detectSpans,
174
181
  dispatchAppTool,
175
182
  encodeEvent,
176
183
  encryptAesGcm,
@@ -202,6 +209,7 @@ export {
202
209
  resolveTangleModelConfig,
203
210
  resolveToolId,
204
211
  resolveToolName,
212
+ revealSpan,
205
213
  reviewCandidate,
206
214
  runAppToolLoop,
207
215
  streamAppToolLoop,
@@ -1,22 +1,122 @@
1
1
  /**
2
- * PII redaction for production trace payloads.
2
+ * PII redaction two complementary modes.
3
3
  *
4
- * The chat-trace emission sites pass tool args + results — and at one
5
- * point the LLM span carried the system prompt + user message verbatim
6
- * through the wire. Anything that lands in the ingestion store is
7
- * also fair game for the analyst-loop's LLM prompts, so personal
8
- * identifiers MUST be stripped before they leave the request path.
4
+ * 1. ONE-WAY scrub (`redactForIngestion`): for production trace payloads. Tool
5
+ * args + results (and once, the LLM span's prompt) cross the wire into the
6
+ * ingestion store, which also feeds the analyst-loop's LLM prompts, so
7
+ * personal identifiers MUST be stripped before they leave the request path.
8
+ * Destructive the original is gone, replaced by a sentinel.
9
9
  *
10
- * Discipline:
11
- * - Match cheap, deterministic patterns at the string level (SSN, EIN).
12
- * - Match well-known sensitive object keys (case-insensitive) and
13
- * replace the value, never the key, so the shape of the object
14
- * remains debuggable.
15
- * - Recurse arrays + plain objects only; pass through everything else
16
- * unchanged (numbers, booleans, null, undefined, functions, etc).
17
- * - NEVER throw a redaction failure must not crash the chat handler.
18
- * Unrecognized inputs round-trip as-is.
10
+ * 2. REVERSIBLE redaction (`buildRedactedDocument` / `revealSpan`): for the UI.
11
+ * A document is split into text + redacted segments; each redacted original
12
+ * is kept ENCRYPTED (via a caller-supplied `encrypt` seam `agent-app/crypto`)
13
+ * so a viewer can reveal a single span on demand, gated by an authorization
14
+ * callback and an audit hook. The mask is presentation; the original is
15
+ * recoverable by an authorized reveal, not lost.
16
+ *
17
+ * Discipline: cheap deterministic string patterns + well-known sensitive object
18
+ * keys (value replaced, key kept, so the shape stays debuggable); recurse arrays
19
+ * + plain objects only; NEVER throw on the one-way path.
20
+ */
21
+ /** A named PII pattern. `pattern` is matched case-insensitively at the string
22
+ * level; keep it non-global (global instances are derived where needed). */
23
+ interface RedactionPattern {
24
+ kind: string;
25
+ pattern: RegExp;
26
+ /** Optional predicate over each match — the pattern fires only when it returns
27
+ * true. For matches a regex alone can't decide (e.g. a Luhn check on a
28
+ * card-number candidate). When set, the value is scanned globally and the
29
+ * first match that passes wins; when absent, a plain `pattern.test` decides. */
30
+ validate?: (match: string) => boolean;
31
+ }
32
+ /** The default deterministic patterns. Extend via the `extraPatterns` /
33
+ * `patterns` options rather than forking this module (the seam that lets a
34
+ * product add e.g. a credit-card matcher without a local copy). */
35
+ declare const DEFAULT_REDACTION_PATTERNS: readonly RedactionPattern[];
36
+ interface RedactForIngestionOptions {
37
+ /** Extra patterns appended to {@link DEFAULT_REDACTION_PATTERNS} for the
38
+ * string-level scrub (e.g. credit-card). Additive — defaults still apply. */
39
+ extraPatterns?: readonly RedactionPattern[];
40
+ /** Extra sensitive object-key names (case-insensitive) added to the built-in
41
+ * set, e.g. the snake_case `api_key` an intake form uses. Additive. */
42
+ extraSensitiveKeys?: readonly string[];
43
+ }
44
+ /**
45
+ * One-way PII scrub for telemetry/ingestion. Backward-compatible: called with no
46
+ * options it behaves exactly as before (SSN/EIN strings + sensitive object keys
47
+ * → sentinels). `extraPatterns` lets a product add matchers (e.g. credit-card)
48
+ * without forking this module.
49
+ */
50
+ declare function redactForIngestion(value: unknown, options?: RedactForIngestionOptions): unknown;
51
+ /** A detected PII span in a source string. */
52
+ interface RedactionSpan {
53
+ /** Stable within a document (index-derived) — used for reveal + audit. */
54
+ id: string;
55
+ kind: string;
56
+ start: number;
57
+ end: number;
58
+ text: string;
59
+ }
60
+ /**
61
+ * Find non-overlapping PII spans in `text`. Matches every pattern, sorts by
62
+ * position, and drops overlaps (first match wins). Deterministic — no ids that
63
+ * vary per call.
64
+ */
65
+ declare function detectSpans(text: string, patterns?: readonly RedactionPattern[]): RedactionSpan[];
66
+ /** A redacted document segment: literal text, or a masked span with the
67
+ * original kept ENCRYPTED for an authorized reveal. */
68
+ type RedactedDocSegment = {
69
+ type: 'text';
70
+ text: string;
71
+ } | {
72
+ type: 'redacted';
73
+ id: string;
74
+ kind: string;
75
+ cipher: string;
76
+ };
77
+ interface RedactedDocument {
78
+ segments: RedactedDocSegment[];
79
+ }
80
+ interface BuildRedactedDocumentOptions {
81
+ /** Encrypt one original span value. Wire it to `agent-app/crypto`
82
+ * (`encryptWithKey` / `createFieldCrypto`). The cipher is what's stored. */
83
+ encrypt: (plaintext: string) => string | Promise<string>;
84
+ /** Patterns to detect (default: {@link DEFAULT_REDACTION_PATTERNS}). */
85
+ patterns?: readonly RedactionPattern[];
86
+ }
87
+ /**
88
+ * Split `text` into text + redacted segments, encrypting each redacted span's
89
+ * original. The result carries NO plaintext PII — only the masked structure and
90
+ * ciphertext — so it is safe to ship to a client; reveal happens server-side via
91
+ * {@link revealSpan}.
92
+ */
93
+ declare function buildRedactedDocument(text: string, options: BuildRedactedDocumentOptions): Promise<RedactedDocument>;
94
+ interface RevealSpanOptions {
95
+ /** Decrypt a span cipher. Wire to `agent-app/crypto` (`decryptWithKey`). */
96
+ decrypt: (cipher: string) => string | Promise<string>;
97
+ /** Authorization gate — return false to deny the reveal (fail-closed). */
98
+ canReveal: (segment: {
99
+ id: string;
100
+ kind: string;
101
+ }) => boolean | Promise<boolean>;
102
+ /** Audit hook — invoked only on a granted reveal (the caller records who/when). */
103
+ onReveal?: (segment: {
104
+ id: string;
105
+ kind: string;
106
+ }) => void | Promise<void>;
107
+ }
108
+ interface RevealResult {
109
+ ok: boolean;
110
+ value?: string;
111
+ /** `not_found` | `forbidden` when `ok` is false. */
112
+ reason?: string;
113
+ }
114
+ /**
115
+ * Reveal one redacted span's original, gated + audited. Fail-closed: an unknown
116
+ * id or a denied `canReveal` returns `{ ok: false }` and never decrypts; a
117
+ * granted reveal decrypts, fires `onReveal` for the audit trail, and returns the
118
+ * value.
19
119
  */
20
- declare function redactForIngestion(value: unknown): unknown;
120
+ declare function revealSpan(doc: RedactedDocument, spanId: string, options: RevealSpanOptions): Promise<RevealResult>;
21
121
 
22
- export { redactForIngestion };
122
+ export { type BuildRedactedDocumentOptions, DEFAULT_REDACTION_PATTERNS, type RedactForIngestionOptions, type RedactedDocSegment, type RedactedDocument, type RedactionPattern, type RedactionSpan, type RevealResult, type RevealSpanOptions, buildRedactedDocument, detectSpans, redactForIngestion, revealSpan };
@@ -1,7 +1,15 @@
1
1
  import {
2
- redactForIngestion
3
- } from "../chunk-C5CREGT2.js";
2
+ DEFAULT_REDACTION_PATTERNS,
3
+ buildRedactedDocument,
4
+ detectSpans,
5
+ redactForIngestion,
6
+ revealSpan
7
+ } from "../chunk-MSXDQ2GB.js";
4
8
  export {
5
- redactForIngestion
9
+ DEFAULT_REDACTION_PATTERNS,
10
+ buildRedactedDocument,
11
+ detectSpans,
12
+ redactForIngestion,
13
+ revealSpan
6
14
  };
7
15
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-app",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "packageManager": "pnpm@10.33.4",
5
5
  "description": "Application-shell framework for Tangle agent products: a bounded tool loop, the structured agent→app tool side channel, integration-hub client, per-workspace billing, and crypto — composed over the Tangle agent substrate through typed seams.",
6
6
  "keywords": [
@@ -1,45 +0,0 @@
1
- // src/redact/index.ts
2
- var SSN_PATTERN = /\d{3}-\d{2}-\d{4}/;
3
- var EIN_PATTERN = /\d{2}-\d{7}/;
4
- var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
5
- "ssn",
6
- "ein",
7
- "password",
8
- "apikey",
9
- "token",
10
- "secret",
11
- "authorization",
12
- "email",
13
- "phone"
14
- ]);
15
- function redactString(value) {
16
- if (SSN_PATTERN.test(value)) return "[REDACTED:ssn]";
17
- if (EIN_PATTERN.test(value)) return "[REDACTED:ein]";
18
- return value;
19
- }
20
- function isPlainObject(value) {
21
- if (value === null || typeof value !== "object") return false;
22
- const proto = Object.getPrototypeOf(value);
23
- return proto === Object.prototype || proto === null;
24
- }
25
- function redactForIngestion(value) {
26
- if (typeof value === "string") return redactString(value);
27
- if (Array.isArray(value)) return value.map(redactForIngestion);
28
- if (isPlainObject(value)) {
29
- const out = {};
30
- for (const [k, v] of Object.entries(value)) {
31
- if (SENSITIVE_KEYS.has(k.toLowerCase())) {
32
- out[k] = "[REDACTED:field]";
33
- continue;
34
- }
35
- out[k] = redactForIngestion(v);
36
- }
37
- return out;
38
- }
39
- return value;
40
- }
41
-
42
- export {
43
- redactForIngestion
44
- };
45
- //# sourceMappingURL=chunk-C5CREGT2.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/redact/index.ts"],"sourcesContent":["/**\n * PII redaction for production trace payloads.\n *\n * The chat-trace emission sites pass tool args + results — and at one\n * point the LLM span carried the system prompt + user message verbatim\n * — through the wire. Anything that lands in the ingestion store is\n * also fair game for the analyst-loop's LLM prompts, so personal\n * identifiers MUST be stripped before they leave the request path.\n *\n * Discipline:\n * - Match cheap, deterministic patterns at the string level (SSN, EIN).\n * - Match well-known sensitive object keys (case-insensitive) and\n * replace the value, never the key, so the shape of the object\n * remains debuggable.\n * - Recurse arrays + plain objects only; pass through everything else\n * unchanged (numbers, booleans, null, undefined, functions, etc).\n * - NEVER throw — a redaction failure must not crash the chat handler.\n * Unrecognized inputs round-trip as-is.\n */\n\nconst SSN_PATTERN = /\\d{3}-\\d{2}-\\d{4}/\nconst EIN_PATTERN = /\\d{2}-\\d{7}/\n\nconst SENSITIVE_KEYS = new Set([\n 'ssn',\n 'ein',\n 'password',\n 'apikey',\n 'token',\n 'secret',\n 'authorization',\n 'email',\n 'phone',\n])\n\nfunction redactString(value: string): string {\n if (SSN_PATTERN.test(value)) return '[REDACTED:ssn]'\n if (EIN_PATTERN.test(value)) return '[REDACTED:ein]'\n return value\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (value === null || typeof value !== 'object') return false\n const proto = Object.getPrototypeOf(value)\n return proto === Object.prototype || proto === null\n}\n\nexport function redactForIngestion(value: unknown): unknown {\n if (typeof value === 'string') return redactString(value)\n if (Array.isArray(value)) return value.map(redactForIngestion)\n if (isPlainObject(value)) {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(value)) {\n if (SENSITIVE_KEYS.has(k.toLowerCase())) {\n out[k] = '[REDACTED:field]'\n continue\n }\n out[k] = redactForIngestion(v)\n }\n return out\n }\n return value\n}\n"],"mappings":";AAoBA,IAAM,cAAc;AACpB,IAAM,cAAc;AAEpB,IAAM,iBAAiB,oBAAI,IAAI;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,SAAS,aAAa,OAAuB;AAC3C,MAAI,YAAY,KAAK,KAAK,EAAG,QAAO;AACpC,MAAI,YAAY,KAAK,KAAK,EAAG,QAAO;AACpC,SAAO;AACT;AAEA,SAAS,cAAc,OAAkD;AACvE,MAAI,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO;AACxD,QAAM,QAAQ,OAAO,eAAe,KAAK;AACzC,SAAO,UAAU,OAAO,aAAa,UAAU;AACjD;AAEO,SAAS,mBAAmB,OAAyB;AAC1D,MAAI,OAAO,UAAU,SAAU,QAAO,aAAa,KAAK;AACxD,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,MAAM,IAAI,kBAAkB;AAC7D,MAAI,cAAc,KAAK,GAAG;AACxB,UAAM,MAA+B,CAAC;AACtC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,KAAK,GAAG;AAC1C,UAAI,eAAe,IAAI,EAAE,YAAY,CAAC,GAAG;AACvC,YAAI,CAAC,IAAI;AACT;AAAA,MACF;AACA,UAAI,CAAC,IAAI,mBAAmB,CAAC;AAAA,IAC/B;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;","names":[]}