@tangle-network/agent-app 0.1.5 → 0.1.7

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,95 @@
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 } of patterns) {
19
+ if (pattern.test(value)) return `[REDACTED:${kind}]`;
20
+ }
21
+ return value;
22
+ }
23
+ function isPlainObject(value) {
24
+ if (value === null || typeof value !== "object") return false;
25
+ const proto = Object.getPrototypeOf(value);
26
+ return proto === Object.prototype || proto === null;
27
+ }
28
+ function redactForIngestion(value, options = {}) {
29
+ const patterns = options.extraPatterns ? [...DEFAULT_REDACTION_PATTERNS, ...options.extraPatterns] : DEFAULT_REDACTION_PATTERNS;
30
+ const walk = (v) => {
31
+ if (typeof v === "string") return redactString(v, patterns);
32
+ if (Array.isArray(v)) return v.map(walk);
33
+ if (isPlainObject(v)) {
34
+ const out = {};
35
+ for (const [k, val] of Object.entries(v)) {
36
+ out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? "[REDACTED:field]" : walk(val);
37
+ }
38
+ return out;
39
+ }
40
+ return v;
41
+ };
42
+ return walk(value);
43
+ }
44
+ function detectSpans(text, patterns = DEFAULT_REDACTION_PATTERNS) {
45
+ const raw = [];
46
+ for (const { kind, pattern } of patterns) {
47
+ const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
48
+ for (const m of text.matchAll(g)) {
49
+ if (m.index === void 0 || m[0].length === 0) continue;
50
+ raw.push({ kind, start: m.index, end: m.index + m[0].length, text: m[0] });
51
+ }
52
+ }
53
+ raw.sort((a, b) => a.start - b.start || b.end - a.end);
54
+ const spans = [];
55
+ let cursor = -1;
56
+ let i = 0;
57
+ for (const s of raw) {
58
+ if (s.start < cursor) continue;
59
+ spans.push({ id: `span-${i++}`, ...s });
60
+ cursor = s.end;
61
+ }
62
+ return spans;
63
+ }
64
+ async function buildRedactedDocument(text, options) {
65
+ const spans = detectSpans(text, options.patterns);
66
+ const segments = [];
67
+ let pos = 0;
68
+ for (const span of spans) {
69
+ if (span.start > pos) segments.push({ type: "text", text: text.slice(pos, span.start) });
70
+ segments.push({ type: "redacted", id: span.id, kind: span.kind, cipher: await options.encrypt(span.text) });
71
+ pos = span.end;
72
+ }
73
+ if (pos < text.length) segments.push({ type: "text", text: text.slice(pos) });
74
+ return { segments };
75
+ }
76
+ async function revealSpan(doc, spanId, options) {
77
+ const seg = doc.segments.find(
78
+ (s) => s.type === "redacted" && s.id === spanId
79
+ );
80
+ if (!seg) return { ok: false, reason: "not_found" };
81
+ const allowed = await options.canReveal({ id: seg.id, kind: seg.kind });
82
+ if (!allowed) return { ok: false, reason: "forbidden" };
83
+ const value = await options.decrypt(seg.cipher);
84
+ if (options.onReveal) await options.onReveal({ id: seg.id, kind: seg.kind });
85
+ return { ok: true, value };
86
+ }
87
+
88
+ export {
89
+ DEFAULT_REDACTION_PATTERNS,
90
+ redactForIngestion,
91
+ detectSpans,
92
+ buildRedactedDocument,
93
+ revealSpan
94
+ };
95
+ //# sourceMappingURL=chunk-KWHLTXLE.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}\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}\n\nfunction redactString(value: string, patterns: readonly RedactionPattern[]): string {\n for (const { kind, pattern } of patterns) {\n if (pattern.test(value)) return `[REDACTED:${kind}]`\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 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] = SENSITIVE_KEYS.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 } 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 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":[]}
@@ -0,0 +1,43 @@
1
+ // src/harness/index.ts
2
+ var KNOWN_HARNESSES = [
3
+ "opencode",
4
+ "claude-code",
5
+ "kimi-code",
6
+ "codex",
7
+ "amp",
8
+ "factory-droids",
9
+ "pi",
10
+ "hermes",
11
+ "forge",
12
+ "openclaw",
13
+ "acp",
14
+ "cursor",
15
+ "cli-base"
16
+ ];
17
+ var DEFAULT_HARNESS = "opencode";
18
+ var HARNESS_SET = new Set(KNOWN_HARNESSES);
19
+ function isHarness(value) {
20
+ return typeof value === "string" && HARNESS_SET.has(value);
21
+ }
22
+ function coerceHarness(value, fallback = DEFAULT_HARNESS) {
23
+ return isHarness(value) ? value : fallback;
24
+ }
25
+ function resolveSessionHarness(input = {}) {
26
+ const fallback = input.fallback ?? DEFAULT_HARNESS;
27
+ if (isHarness(input.sessionHarness)) {
28
+ const locked = input.sessionHarness;
29
+ const swapAttempted = isHarness(input.requested) && input.requested !== locked;
30
+ return { harness: locked, locked: true, swapAttempted };
31
+ }
32
+ const harness = coerceHarness(input.requested, coerceHarness(input.workspaceDefault, fallback));
33
+ return { harness, locked: false, swapAttempted: false };
34
+ }
35
+
36
+ export {
37
+ KNOWN_HARNESSES,
38
+ DEFAULT_HARNESS,
39
+ isHarness,
40
+ coerceHarness,
41
+ resolveSessionHarness
42
+ };
43
+ //# sourceMappingURL=chunk-SD2H4FWY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/harness/index.ts"],"sourcesContent":["/**\n * Coding-agent harness selection — taxonomy, coercion, and the session-lock invariant.\n *\n * A \"harness\" is the coding-agent CLI a sandbox drives (opencode / codex /\n * claude-code / …). The shell governs WHICH harness a chat session uses and\n * enforces that a session is LOCKED to the harness it started with — the model\n * may change mid-session, the harness may not (swapping it mid-session would\n * orphan the session's running agent state). Every product otherwise hand-rolls\n * this and hard-codes a single harness; this is the one place the rule lives.\n *\n * Substrate-free: the harness list mirrors the sandbox SDK's `BackendType` as a\n * plain string union (no sandbox dependency). The consumer owns storage — which\n * harness a workspace defaults to, which one a session locked — and maps the\n * resolved value onto the SDK's `backend.type`.\n */\n\n/** The known coding-agent backends. Mirrors `@tangle-network/sandbox`'s\n * `BackendType`; kept structural so this module needs no sandbox dependency. */\nexport const KNOWN_HARNESSES = [\n 'opencode',\n 'claude-code',\n 'kimi-code',\n 'codex',\n 'amp',\n 'factory-droids',\n 'pi',\n 'hermes',\n 'forge',\n 'openclaw',\n 'acp',\n 'cursor',\n 'cli-base',\n] as const\n\nexport type Harness = (typeof KNOWN_HARNESSES)[number]\n\nexport const DEFAULT_HARNESS: Harness = 'opencode'\n\nconst HARNESS_SET: ReadonlySet<string> = new Set(KNOWN_HARNESSES)\n\nexport function isHarness(value: unknown): value is Harness {\n return typeof value === 'string' && HARNESS_SET.has(value)\n}\n\n/** Coerce an arbitrary value to a known harness, falling back (default `opencode`). */\nexport function coerceHarness(value: unknown, fallback: Harness = DEFAULT_HARNESS): Harness {\n return isHarness(value) ? value : fallback\n}\n\nexport interface ResolveSessionHarnessInput {\n /** The harness already locked to this session (recorded at its first turn). */\n sessionHarness?: unknown\n /** The harness requested now — a new session's choice, or a turn's attempt to switch. */\n requested?: unknown\n /** The workspace's default harness, used only when starting a fresh session. */\n workspaceDefault?: unknown\n /** Final fallback when nothing else resolves (default `opencode`). */\n fallback?: Harness\n}\n\nexport interface ResolvedSessionHarness {\n /** The harness to actually run — the locked one when the session already has it. */\n harness: Harness\n /** True when the session already had a locked harness (this turn did not pick it). */\n locked: boolean\n /** True when `requested` differs from the locked harness — a forbidden mid-session\n * swap the caller should reject or warn on. The lock always wins regardless. */\n swapAttempted: boolean\n}\n\n/**\n * Resolve the harness for a turn, enforcing the session lock.\n *\n * - **Session already started** (`sessionHarness` is a known harness): that harness\n * wins (`locked: true`); a differing `requested` sets `swapAttempted` so the caller\n * can reject the swap. The model is a separate per-turn concern and is unaffected.\n * - **Fresh session**: pick `requested → workspaceDefault → fallback`. The caller\n * persists the result as the session's lock for every subsequent turn.\n */\nexport function resolveSessionHarness(input: ResolveSessionHarnessInput = {}): ResolvedSessionHarness {\n const fallback = input.fallback ?? DEFAULT_HARNESS\n if (isHarness(input.sessionHarness)) {\n const locked = input.sessionHarness\n const swapAttempted = isHarness(input.requested) && input.requested !== locked\n return { harness: locked, locked: true, swapAttempted }\n }\n const harness = coerceHarness(input.requested, coerceHarness(input.workspaceDefault, fallback))\n return { harness, locked: false, swapAttempted: false }\n}\n"],"mappings":";AAkBO,IAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,IAAM,kBAA2B;AAExC,IAAM,cAAmC,IAAI,IAAI,eAAe;AAEzD,SAAS,UAAU,OAAkC;AAC1D,SAAO,OAAO,UAAU,YAAY,YAAY,IAAI,KAAK;AAC3D;AAGO,SAAS,cAAc,OAAgB,WAAoB,iBAA0B;AAC1F,SAAO,UAAU,KAAK,IAAI,QAAQ;AACpC;AAgCO,SAAS,sBAAsB,QAAoC,CAAC,GAA2B;AACpG,QAAM,WAAW,MAAM,YAAY;AACnC,MAAI,UAAU,MAAM,cAAc,GAAG;AACnC,UAAM,SAAS,MAAM;AACrB,UAAM,gBAAgB,UAAU,MAAM,SAAS,KAAK,MAAM,cAAc;AACxE,WAAO,EAAE,SAAS,QAAQ,QAAQ,MAAM,cAAc;AAAA,EACxD;AACA,QAAM,UAAU,cAAc,MAAM,WAAW,cAAc,MAAM,kBAAkB,QAAQ,CAAC;AAC9F,SAAO,EAAE,SAAS,QAAQ,OAAO,eAAe,MAAM;AACxD;","names":[]}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Coding-agent harness selection — taxonomy, coercion, and the session-lock invariant.
3
+ *
4
+ * A "harness" is the coding-agent CLI a sandbox drives (opencode / codex /
5
+ * claude-code / …). The shell governs WHICH harness a chat session uses and
6
+ * enforces that a session is LOCKED to the harness it started with — the model
7
+ * may change mid-session, the harness may not (swapping it mid-session would
8
+ * orphan the session's running agent state). Every product otherwise hand-rolls
9
+ * this and hard-codes a single harness; this is the one place the rule lives.
10
+ *
11
+ * Substrate-free: the harness list mirrors the sandbox SDK's `BackendType` as a
12
+ * plain string union (no sandbox dependency). The consumer owns storage — which
13
+ * harness a workspace defaults to, which one a session locked — and maps the
14
+ * resolved value onto the SDK's `backend.type`.
15
+ */
16
+ /** The known coding-agent backends. Mirrors `@tangle-network/sandbox`'s
17
+ * `BackendType`; kept structural so this module needs no sandbox dependency. */
18
+ declare const KNOWN_HARNESSES: readonly ["opencode", "claude-code", "kimi-code", "codex", "amp", "factory-droids", "pi", "hermes", "forge", "openclaw", "acp", "cursor", "cli-base"];
19
+ type Harness = (typeof KNOWN_HARNESSES)[number];
20
+ declare const DEFAULT_HARNESS: Harness;
21
+ declare function isHarness(value: unknown): value is Harness;
22
+ /** Coerce an arbitrary value to a known harness, falling back (default `opencode`). */
23
+ declare function coerceHarness(value: unknown, fallback?: Harness): Harness;
24
+ interface ResolveSessionHarnessInput {
25
+ /** The harness already locked to this session (recorded at its first turn). */
26
+ sessionHarness?: unknown;
27
+ /** The harness requested now — a new session's choice, or a turn's attempt to switch. */
28
+ requested?: unknown;
29
+ /** The workspace's default harness, used only when starting a fresh session. */
30
+ workspaceDefault?: unknown;
31
+ /** Final fallback when nothing else resolves (default `opencode`). */
32
+ fallback?: Harness;
33
+ }
34
+ interface ResolvedSessionHarness {
35
+ /** The harness to actually run — the locked one when the session already has it. */
36
+ harness: Harness;
37
+ /** True when the session already had a locked harness (this turn did not pick it). */
38
+ locked: boolean;
39
+ /** True when `requested` differs from the locked harness — a forbidden mid-session
40
+ * swap the caller should reject or warn on. The lock always wins regardless. */
41
+ swapAttempted: boolean;
42
+ }
43
+ /**
44
+ * Resolve the harness for a turn, enforcing the session lock.
45
+ *
46
+ * - **Session already started** (`sessionHarness` is a known harness): that harness
47
+ * wins (`locked: true`); a differing `requested` sets `swapAttempted` so the caller
48
+ * can reject the swap. The model is a separate per-turn concern and is unaffected.
49
+ * - **Fresh session**: pick `requested → workspaceDefault → fallback`. The caller
50
+ * persists the result as the session's lock for every subsequent turn.
51
+ */
52
+ declare function resolveSessionHarness(input?: ResolveSessionHarnessInput): ResolvedSessionHarness;
53
+
54
+ export { DEFAULT_HARNESS, type Harness, KNOWN_HARNESSES, type ResolveSessionHarnessInput, type ResolvedSessionHarness, coerceHarness, isHarness, resolveSessionHarness };
@@ -0,0 +1,15 @@
1
+ import {
2
+ DEFAULT_HARNESS,
3
+ KNOWN_HARNESSES,
4
+ coerceHarness,
5
+ isHarness,
6
+ resolveSessionHarness
7
+ } from "../chunk-SD2H4FWY.js";
8
+ export {
9
+ DEFAULT_HARNESS,
10
+ KNOWN_HARNESSES,
11
+ coerceHarness,
12
+ isHarness,
13
+ resolveSessionHarness
14
+ };
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { AppToolLoopOptions, LoopEvent, LoopToolCall, OpenAICompatStreamTurnOpti
6
6
  export { createTokenRecallChecker, producedFromToolEvents } from './eval/index.js';
7
7
  export { KnowledgeRequirementSpec, KnowledgeSignal, KnowledgeStateAccessor, SatisfiedByRule, buildKnowledgeRequirements, deriveSignals } from './knowledge/index.js';
8
8
  export { CreateKnowledgeLoopDeps, KnowledgeCandidate, KnowledgeDecider, KnowledgeDeciderInput, KnowledgeDecision, KnowledgeGateVerdict, KnowledgeLoop, KnowledgeLoopDriver, createKnowledgeLoop, createReviewerDecider, reviewCandidate } from './knowledge-loop/index.js';
9
+ export { DEFAULT_HARNESS, Harness, KNOWN_HARNESSES, ResolveSessionHarnessInput, ResolvedSessionHarness, coerceHarness, isHarness, resolveSessionHarness } from './harness/index.js';
9
10
  export { AgentAppConfig, AgentDelegationConfig, AgentIdentityConfig, AgentIntegrationsConfig, AgentKnowledgeConfig, AgentTaxonomyConfig, AgentUiConfig, KnowledgeLoopConfig, KnowledgeSourceSpec, agentAppConfigJsonSchema, defineAgentApp } from './config/index.js';
10
11
  export { D1Like, D1PreparedLike, DrizzleColumnLike, DrizzleSqliteCoreLike, PRESET_MIGRATION_SQL, PRESET_TABLES, PresetBillingOptions, PresetKnowledgeAccessorOptions, PresetToolHandlerOptions, VaultKv, createD1KnowledgeStateAccessor, createPresetDrizzleSchema, createPresetFieldCrypto, createPresetToolHandlers, createPresetWorkspaceKeyManager, createPresetWorkspaceKeyStore } from './preset-cloudflare/index.js';
11
12
  export { KeyCrypto, KeyProvisioner, PlanLimit, PlatformBalanceInfo, PlatformBalanceManager, PlatformBalanceManagerOptions, PlatformBillingClient, PlatformIdentity, PlatformProductUsage, SharedBillingState, WorkspaceKeyManager, WorkspaceKeyManagerOptions, WorkspaceKeyRecord, WorkspaceKeyStore, WorkspaceModelKeyUsage, createPlatformBalanceManager, createWorkspaceKeyManager } from './billing/index.js';
@@ -13,7 +14,7 @@ export { DeriveKeyOptions, createFieldCrypto, decodeHexKey, decryptAesGcm, decry
13
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';
14
15
  export { HubExecClient, HubExecClientOptions, HubExecErrorCode, HubExecResult, HubInvokeDeps, HubInvokeInput, HubInvokeOutcome, ParsedIntegrationAction, invokeIntegrationHub, resolveIntegrationAction } from './integrations/index.js';
15
16
  export { JsonObject, KvLike, RateLimitResult, RequestContext, SecurityHeaderOptions, addSecurityHeaders, checkRateLimit, extractRequestContext, parseJsonObjectBody, requireString } from './web/index.js';
16
- 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';
17
18
  export { CompletionRequirement, CompletionVerdict, CorrectnessChecker, ProducedState, RuntimeEventLike, SatisfiedBy, TaskGold, createLlmCorrectnessChecker, extractProducedState, verifyCompletion, weightedComposite } from '@tangle-network/agent-eval';
18
19
  export { D as DEFAULT_TANGLE_ROUTER_BASE_URL, R as ResolveModelOptions, T as TangleModelConfig, r as resolveTangleModelConfig } from './model-BOP69mVu.js';
19
20
  import '@tangle-network/agent-knowledge';
package/dist/index.js CHANGED
@@ -1,3 +1,17 @@
1
+ import {
2
+ DEFAULT_REDACTION_PATTERNS,
3
+ buildRedactedDocument,
4
+ detectSpans,
5
+ redactForIngestion,
6
+ revealSpan
7
+ } from "./chunk-KWHLTXLE.js";
8
+ import {
9
+ DEFAULT_HARNESS,
10
+ KNOWN_HARNESSES,
11
+ coerceHarness,
12
+ isHarness,
13
+ resolveSessionHarness
14
+ } from "./chunk-SD2H4FWY.js";
1
15
  import {
2
16
  agentAppConfigJsonSchema,
3
17
  defineAgentApp
@@ -56,9 +70,6 @@ import {
56
70
  parseJsonObjectBody,
57
71
  requireString
58
72
  } from "./chunk-CN75FIPT.js";
59
- import {
60
- redactForIngestion
61
- } from "./chunk-C5CREGT2.js";
62
73
  import {
63
74
  APP_TOOL_NAMES,
64
75
  DEFAULT_APP_TOOL_PATHS,
@@ -115,11 +126,14 @@ import {
115
126
  export {
116
127
  APP_TOOL_NAMES,
117
128
  DEFAULT_APP_TOOL_PATHS,
129
+ DEFAULT_HARNESS,
118
130
  DEFAULT_HEADER_NAMES,
131
+ DEFAULT_REDACTION_PATTERNS,
119
132
  DEFAULT_TANGLE_ROUTER_BASE_URL,
120
133
  DELEGATION_MCP_SERVER_KEY,
121
134
  DELEGATION_TOOLS,
122
135
  HubExecClient,
136
+ KNOWN_HARNESSES,
123
137
  PRESET_MIGRATION_SQL,
124
138
  PRESET_TABLES,
125
139
  ToolInputError,
@@ -134,8 +148,10 @@ export {
134
148
  buildDelegationMcpServer,
135
149
  buildHttpMcpServer,
136
150
  buildKnowledgeRequirements,
151
+ buildRedactedDocument,
137
152
  buildUserTextParts,
138
153
  checkRateLimit,
154
+ coerceHarness,
139
155
  createAppToolRuntimeExecutor,
140
156
  createBrokerTokenProvider,
141
157
  createCapabilityToken,
@@ -161,6 +177,7 @@ export {
161
177
  delegationMcpForConfig,
162
178
  deriveKey,
163
179
  deriveSignals,
180
+ detectSpans,
164
181
  dispatchAppTool,
165
182
  encodeEvent,
166
183
  encryptAesGcm,
@@ -173,6 +190,7 @@ export {
173
190
  handleAppToolRequest,
174
191
  invokeIntegrationHub,
175
192
  isAppToolName,
193
+ isHarness,
176
194
  mergePersistedPart,
177
195
  messageHasTurnId,
178
196
  normalizeClientTurnId,
@@ -187,9 +205,11 @@ export {
187
205
  requireString,
188
206
  resolveChatTurn,
189
207
  resolveIntegrationAction,
208
+ resolveSessionHarness,
190
209
  resolveTangleModelConfig,
191
210
  resolveToolId,
192
211
  resolveToolName,
212
+ revealSpan,
193
213
  reviewCandidate,
194
214
  runAppToolLoop,
195
215
  streamAppToolLoop,
@@ -1,22 +1,114 @@
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
+ }
27
+ /** The default deterministic patterns. Extend via the `extraPatterns` /
28
+ * `patterns` options rather than forking this module (the seam that lets a
29
+ * product add e.g. a credit-card matcher without a local copy). */
30
+ declare const DEFAULT_REDACTION_PATTERNS: readonly RedactionPattern[];
31
+ interface RedactForIngestionOptions {
32
+ /** Extra patterns appended to {@link DEFAULT_REDACTION_PATTERNS} for the
33
+ * string-level scrub (e.g. credit-card). Additive — defaults still apply. */
34
+ extraPatterns?: readonly RedactionPattern[];
35
+ }
36
+ /**
37
+ * One-way PII scrub for telemetry/ingestion. Backward-compatible: called with no
38
+ * options it behaves exactly as before (SSN/EIN strings + sensitive object keys
39
+ * → sentinels). `extraPatterns` lets a product add matchers (e.g. credit-card)
40
+ * without forking this module.
41
+ */
42
+ declare function redactForIngestion(value: unknown, options?: RedactForIngestionOptions): unknown;
43
+ /** A detected PII span in a source string. */
44
+ interface RedactionSpan {
45
+ /** Stable within a document (index-derived) — used for reveal + audit. */
46
+ id: string;
47
+ kind: string;
48
+ start: number;
49
+ end: number;
50
+ text: string;
51
+ }
52
+ /**
53
+ * Find non-overlapping PII spans in `text`. Matches every pattern, sorts by
54
+ * position, and drops overlaps (first match wins). Deterministic — no ids that
55
+ * vary per call.
56
+ */
57
+ declare function detectSpans(text: string, patterns?: readonly RedactionPattern[]): RedactionSpan[];
58
+ /** A redacted document segment: literal text, or a masked span with the
59
+ * original kept ENCRYPTED for an authorized reveal. */
60
+ type RedactedDocSegment = {
61
+ type: 'text';
62
+ text: string;
63
+ } | {
64
+ type: 'redacted';
65
+ id: string;
66
+ kind: string;
67
+ cipher: string;
68
+ };
69
+ interface RedactedDocument {
70
+ segments: RedactedDocSegment[];
71
+ }
72
+ interface BuildRedactedDocumentOptions {
73
+ /** Encrypt one original span value. Wire it to `agent-app/crypto`
74
+ * (`encryptWithKey` / `createFieldCrypto`). The cipher is what's stored. */
75
+ encrypt: (plaintext: string) => string | Promise<string>;
76
+ /** Patterns to detect (default: {@link DEFAULT_REDACTION_PATTERNS}). */
77
+ patterns?: readonly RedactionPattern[];
78
+ }
79
+ /**
80
+ * Split `text` into text + redacted segments, encrypting each redacted span's
81
+ * original. The result carries NO plaintext PII — only the masked structure and
82
+ * ciphertext — so it is safe to ship to a client; reveal happens server-side via
83
+ * {@link revealSpan}.
84
+ */
85
+ declare function buildRedactedDocument(text: string, options: BuildRedactedDocumentOptions): Promise<RedactedDocument>;
86
+ interface RevealSpanOptions {
87
+ /** Decrypt a span cipher. Wire to `agent-app/crypto` (`decryptWithKey`). */
88
+ decrypt: (cipher: string) => string | Promise<string>;
89
+ /** Authorization gate — return false to deny the reveal (fail-closed). */
90
+ canReveal: (segment: {
91
+ id: string;
92
+ kind: string;
93
+ }) => boolean | Promise<boolean>;
94
+ /** Audit hook — invoked only on a granted reveal (the caller records who/when). */
95
+ onReveal?: (segment: {
96
+ id: string;
97
+ kind: string;
98
+ }) => void | Promise<void>;
99
+ }
100
+ interface RevealResult {
101
+ ok: boolean;
102
+ value?: string;
103
+ /** `not_found` | `forbidden` when `ok` is false. */
104
+ reason?: string;
105
+ }
106
+ /**
107
+ * Reveal one redacted span's original, gated + audited. Fail-closed: an unknown
108
+ * id or a denied `canReveal` returns `{ ok: false }` and never decrypts; a
109
+ * granted reveal decrypts, fires `onReveal` for the audit trail, and returns the
110
+ * value.
19
111
  */
20
- declare function redactForIngestion(value: unknown): unknown;
112
+ declare function revealSpan(doc: RedactedDocument, spanId: string, options: RevealSpanOptions): Promise<RevealResult>;
21
113
 
22
- export { redactForIngestion };
114
+ 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-KWHLTXLE.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.5",
3
+ "version": "0.1.7",
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": [
@@ -76,6 +76,11 @@
76
76
  "import": "./dist/knowledge-loop/index.js",
77
77
  "default": "./dist/knowledge-loop/index.js"
78
78
  },
79
+ "./harness": {
80
+ "types": "./dist/harness/index.d.ts",
81
+ "import": "./dist/harness/index.js",
82
+ "default": "./dist/harness/index.js"
83
+ },
79
84
  "./preset-cloudflare": {
80
85
  "types": "./dist/preset-cloudflare/index.d.ts",
81
86
  "import": "./dist/preset-cloudflare/index.js",
@@ -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":[]}