@watchlight/sdk 0.1.0 → 0.4.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.
package/README.md CHANGED
@@ -81,6 +81,105 @@ for await (const msg of query({ prompt, options: { hooks } })) {
81
81
  The hook is fail-closed and never throws back to the SDK — a governance error
82
82
  denies the call. Every decision is audited.
83
83
 
84
+ ## LangChain / LangGraph.js
85
+
86
+ Govern any LangChain `StructuredTool` (which is what LangGraph.js tools are) — the
87
+ tool is authorized before it runs; denied tools throw and never execute.
88
+
89
+ ```ts
90
+ import { tool } from "@langchain/core/tools";
91
+ import { z } from "zod";
92
+ import { govern, governTool } from "@watchlight/sdk";
93
+
94
+ govern.load("watchlight.policy.json");
95
+
96
+ const search = governTool(
97
+ tool(async ({ query }) => webSearch(query), {
98
+ name: "web_search",
99
+ schema: z.object({ query: z.string() }),
100
+ }),
101
+ { intent: "research" }
102
+ );
103
+
104
+ // Pass `search` to your LangGraph ToolNode / createReactAgent as usual.
105
+ ```
106
+
107
+ `governTool(tool, { intent })` returns a governed view (the original tool isn't
108
+ mutated); `governTools(tools, { intentFor })` maps an array. Intent defaults to
109
+ the tool's name. Fail-closed. `@langchain/core` is a peer dependency.
110
+
111
+ ## Gate a consequential action — runtime context, per-user, human-in-the-loop
112
+
113
+ For money-moving (or any high-stakes) tool calls, pass **runtime facts** into the
114
+ policy, attribute the decision to the **acting user**, get a **correlation id**
115
+ back, and route the risky ones to a **human**.
116
+
117
+ ```ts
118
+ import { govern, NeedsApproval } from "@watchlight/sdk";
119
+
120
+ // principal / resource / context can each be a value or (args) => value
121
+ const book = govern.tool(bookTrip, {
122
+ intent: "book",
123
+ principal: (o) => `User::"${o.userId}"`,
124
+ resource: (o) => `trip/${o.tripId}`,
125
+ context: (o) => ({ amount: o.amount, limit: o.perActionLimit, refundable: o.refundable }),
126
+ onNeedsApproval: async ({ decisionId }) => askUser(decisionId), // one-tap human confirm
127
+ });
128
+ ```
129
+ ```
130
+ permit(principal, action == Action::"book", resource)
131
+ when { context.amount <= context.limit && context.refundable };
132
+ ```
133
+
134
+ Or use the low-level primitive directly (any framework):
135
+
136
+ ```ts
137
+ const d = await govern.authorize({
138
+ principal: `User::"${userId}"`, action: "wire", resource: `acct/${to}`, context: { amount },
139
+ });
140
+ // d.decision → "Allow" | "Deny" | "NeedsApproval"
141
+ // d.decisionId → store next to your booking row for reconstruction
142
+
143
+ if (d.decision === "NeedsApproval") {
144
+ await getHumanConfirmation();
145
+ const token = govern.mintApproval({ action: "wire", resource: `acct/${to}` }); // single-use, TTL, bound
146
+ await govern.authorize({ principal: `User::"${userId}"`, action: "wire", resource: `acct/${to}`, context: { amount }, approval: token });
147
+ }
148
+ ```
149
+
150
+ - **Three-state verdict** — `NeedsApproval` is surfaced when a matched permit is
151
+ annotated `@enforcement_effect("require_approval")`.
152
+ - **Correlation id** — every decision returns `decisionId` (also in the audit
153
+ line), so you can join it to your own records.
154
+ - The audit line now carries `decision_id` + the resolved `principal`, and stays
155
+ value-free (no context values).
156
+
157
+ ## Strip PII before the agent reads a document
158
+
159
+ Redact PII from text before it reaches the agent — deterministic, in-process,
160
+ fail-closed. Extract your document to text first (never hand the agent the
161
+ original PDF — its hidden layers leak), then sanitize:
162
+
163
+ ```ts
164
+ import { govern } from "@watchlight/sdk";
165
+
166
+ const text = await extractPdfText("statement.pdf"); // your extractor
167
+ const { text: safe, report } = govern.sanitize(text, { resource: "statement.pdf" });
168
+
169
+ // safe → "Card on file: <CREDIT_CARD_1> SSN: <SSN_1> ..."
170
+ // report → { mode:"tag", counts:{ CREDIT_CARD:1, SSN:1, ... }, total, ... } (value-free)
171
+ await agent.read(safe);
172
+ ```
173
+
174
+ The deterministic detector covers structured PII — email, phone, SSN, credit card
175
+ (Luhn-validated), IBAN, IPv4, API keys. Modes: `tag` (consistent `<EMAIL_1>`
176
+ placeholders, default), `mask` (`[EMAIL]`), `hash`. `govern.sanitize` records a
177
+ **value-free** audit entry (counts by type + mode — never the values).
178
+
179
+ A pure `sanitize(text, opts)` is also exported. Fail-closed: it throws
180
+ `SanitizeError` rather than return partially-redacted text. Names/addresses need
181
+ NER (Enterprise); recall is bounded by the enabled detectors.
182
+
84
183
  ## Value-free audit
85
184
 
86
185
  `.watchlight/audit.jsonl` records **who / what intent / which tool / the
package/dist/backend.d.ts CHANGED
@@ -8,7 +8,16 @@ export interface AuthorizeRequest {
8
8
  export interface Decision {
9
9
  decision: string;
10
10
  reason: string;
11
+ /** Per-decision correlation id (the engine's `request_id`) — join to your own
12
+ * records. */
13
+ decisionId?: string;
14
+ /** True when a matched permit carries the `require_approval` enforcement
15
+ * effect: the action is permitted only after a human confirmation. */
16
+ needsApproval?: boolean;
11
17
  }
18
+ /** Derive `needsApproval` from a decision's details: a permitting policy result
19
+ * annotated `@enforcement_effect("require_approval")`. */
20
+ export declare function deriveNeedsApproval(details: unknown): boolean;
12
21
  export interface GovernanceBackend {
13
22
  readonly kind: "in-process" | "networked";
14
23
  /** A short human label for the dev announce line. */
package/dist/backend.js CHANGED
@@ -14,8 +14,20 @@
14
14
  // Fail-closed everywhere: any transport/engine error resolves to Deny.
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.NetworkedBackend = exports.InProcessBackend = void 0;
17
+ exports.deriveNeedsApproval = deriveNeedsApproval;
17
18
  exports.selectBackend = selectBackend;
18
19
  const engine_1 = require("@watchlight/engine");
20
+ /** Derive `needsApproval` from a decision's details: a permitting policy result
21
+ * annotated `@enforcement_effect("require_approval")`. */
22
+ function deriveNeedsApproval(details) {
23
+ const results = details?.policy_results;
24
+ // Only the policy that actually matched this request (`applicable: true`)
25
+ // counts — a non-matching require_approval policy elsewhere in the set must not
26
+ // flag this decision.
27
+ return Array.isArray(results)
28
+ ? results.some((r) => r?.applicable === true && r?.enforcement_effect === "require_approval")
29
+ : false;
30
+ }
19
31
  /** DE default — the compiled engine in-process. */
20
32
  class InProcessBackend {
21
33
  constructor() {
@@ -40,13 +52,18 @@ class InProcessBackend {
40
52
  }
41
53
  async authorize(req) {
42
54
  const engine = await this._ready();
43
- const resp = await engine.authorize({
55
+ const resp = (await engine.authorize({
44
56
  principal: req.principal,
45
57
  action: req.action,
46
58
  resource: req.resource,
47
59
  context: req.context ?? {},
48
- });
49
- return { decision: resp.decision ?? "Deny", reason: resp.reason ?? "" };
60
+ }));
61
+ return {
62
+ decision: resp.decision ?? "Deny",
63
+ reason: resp.reason ?? "",
64
+ decisionId: resp.request_id,
65
+ needsApproval: deriveNeedsApproval(resp.details),
66
+ };
50
67
  }
51
68
  engine() {
52
69
  return this._ready();
@@ -91,7 +108,12 @@ class NetworkedBackend {
91
108
  if (!resp.ok)
92
109
  return { decision: "Deny", reason: `APDP error: ${resp.status}` };
93
110
  const data = (await resp.json());
94
- return { decision: data.decision ?? "Deny", reason: data.reason ?? "" };
111
+ return {
112
+ decision: data.decision ?? "Deny",
113
+ reason: data.reason ?? "",
114
+ decisionId: data.request_id,
115
+ needsApproval: deriveNeedsApproval(data.details),
116
+ };
95
117
  }
96
118
  catch (e) {
97
119
  // Fail-closed: an unreachable control plane denies.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { Scope } from "./attenuation";
2
+ import { type SanitizeOptions, type SanitizeResult } from "./sanitize";
2
3
  export { Scope, DE_MAX_DEPTH, AttenuationDenied, DevEditionCeiling } from "./attenuation";
3
4
  export { governedHooks } from "./claude-agent";
4
5
  export type { GovernedHooksOptions, GovernedHooksResult } from "./claude-agent";
6
+ export { governTool, governTools } from "./langchain";
7
+ export type { LangChainToolLike, GovernToolOptions, GovernToolsOptions, } from "./langchain";
8
+ export { sanitize, SanitizeError, DETECTOR_VERSION } from "./sanitize";
9
+ export type { PiiType, RedactMode, SanitizeOptions, SanitizeReport, SanitizeResult, } from "./sanitize";
5
10
  export type { GovernanceBackend, Decision, AuthorizeRequest } from "./backend";
6
11
  export { InProcessBackend, NetworkedBackend } from "./backend";
7
12
  /** Raised when the policy engine refuses a governed tool call (fail-closed). */
@@ -11,6 +16,32 @@ export declare class Denied extends Error {
11
16
  readonly reason: string;
12
17
  constructor(tool: string, intent: string, reason: string);
13
18
  }
19
+ /** Raised when a governed call is permitted only after a human confirmation
20
+ * (the matched permit carries the `require_approval` enforcement effect) and no
21
+ * valid approval was supplied. Fail-closed: the tool body did NOT run. */
22
+ export declare class NeedsApproval extends Error {
23
+ readonly tool: string;
24
+ readonly intent: string;
25
+ readonly decisionId?: string;
26
+ readonly reason: string;
27
+ constructor(tool: string, intent: string, decisionId: string | undefined, reason: string);
28
+ }
29
+ /** A per-call binding: a fixed value, or a function of the tool's arguments. */
30
+ export type Binding<A extends unknown[]> = string | ((...args: A) => string);
31
+ /** A record of attributes passed into Cedar `context.*`, or a function of args. */
32
+ export type ContextBinding<A extends unknown[]> = Record<string, unknown> | ((...args: A) => Record<string, unknown>);
33
+ /** Full result of {@link Watchlight.authorize}. */
34
+ export interface AuthorizeResult {
35
+ /** `"Allow"` | `"Deny"` | `"NeedsApproval"`. */
36
+ decision: "Allow" | "Deny" | "NeedsApproval";
37
+ allowed: boolean;
38
+ needsApproval: boolean;
39
+ /** True when a valid approval token downgraded a NeedsApproval to Allow. */
40
+ approved: boolean;
41
+ /** Per-decision correlation id (engine `request_id`) — join to your records. */
42
+ decisionId?: string;
43
+ reason: string;
44
+ }
14
45
  /** A function governed by {@link Watchlight.tool} — always async (the engine's
15
46
  * authorize path is async in WebAssembly). */
16
47
  export type Governed<A extends unknown[], R> = (...args: A) => Promise<Awaited<R>>;
@@ -75,6 +106,24 @@ export declare class Watchlight {
75
106
  */
76
107
  tool<A extends unknown[], R>(fn: (...args: A) => R, opts: {
77
108
  intent: string;
109
+ /** Acting principal, e.g. `User::"u1"` — value or `(args) => value`.
110
+ * Defaults to the agent. */
111
+ principal?: Binding<A>;
112
+ /** Cedar resource entity — value or `(args) => value`. Defaults to
113
+ * `tool/<name>`. */
114
+ resource?: Binding<A>;
115
+ /** Attributes for Cedar `context.*` — object or `(args) => object`. */
116
+ context?: ContextBinding<A>;
117
+ /** Human-in-the-loop hook. Called when the decision is `NeedsApproval`;
118
+ * return `true` to proceed (records an approval), `false`/absent to hold
119
+ * (throws `NeedsApproval`). */
120
+ onNeedsApproval?: (info: {
121
+ intent: string;
122
+ resource: string;
123
+ principal: string;
124
+ decisionId?: string;
125
+ reason: string;
126
+ }) => boolean | Promise<boolean>;
78
127
  }): Governed<A, R>;
79
128
  /**
80
129
  * Authorize a raw `(intent, tool)` pair, audit the decision, and return it.
@@ -86,8 +135,52 @@ export declare class Watchlight {
86
135
  allowed: boolean;
87
136
  decision: string;
88
137
  reason: string;
138
+ decisionId?: string;
89
139
  }>;
90
- private _authorize;
140
+ /**
141
+ * Authorize an action with full control — per-call `principal`, `resource`,
142
+ * and Cedar `context` — and get a correlation id back. The low-level primitive
143
+ * behind {@link tool}; use it directly for any consequential action.
144
+ *
145
+ * Returns a three-state verdict: `Allow` / `Deny` / `NeedsApproval`. A
146
+ * `NeedsApproval` (matched permit annotated `require_approval`) is downgraded
147
+ * to `Allow` when a valid single-use `approval` token — from
148
+ * {@link mintApproval}, minted after a human confirms — is supplied.
149
+ * Fail-closed and audited (value-free).
150
+ */
151
+ authorize(req: {
152
+ action: string;
153
+ principal?: string;
154
+ resource?: string;
155
+ context?: Record<string, unknown>;
156
+ /** A token from {@link mintApproval} (after human confirmation). */
157
+ approval?: string;
158
+ }): Promise<AuthorizeResult>;
159
+ /**
160
+ * Mint a single-use approval token for a specific `(principal, action,
161
+ * resource)`, to pass to {@link authorize} after a human confirms a
162
+ * `NeedsApproval` decision. Local HMAC, TTL-bounded (default 2 min). In
163
+ * Enterprise these are KMS-signed and recorded in signed lineage.
164
+ */
165
+ mintApproval(challenge: {
166
+ action: string;
167
+ principal?: string;
168
+ resource?: string;
169
+ }, opts?: {
170
+ ttlMs?: number;
171
+ }): string;
172
+ /**
173
+ * Strip PII from text before an agent reads it (governed data minimization).
174
+ * Deterministic, fail-closed. Writes a value-free `sanitization` record to the
175
+ * audit trail (counts by PII type + mode — never the values) and returns the
176
+ * redacted text plus the report. Operates on extracted text — extract a
177
+ * document to text first (never hand the agent a "redacted PDF").
178
+ */
179
+ sanitize(content: string, opts?: SanitizeOptions & {
180
+ intent?: string;
181
+ resource?: string;
182
+ }): SanitizeResult;
183
+ private _auditSanitize;
91
184
  private _announce;
92
185
  private _audit;
93
186
  }
package/dist/index.js CHANGED
Binary file
@@ -0,0 +1,32 @@
1
+ import { Watchlight } from "./index";
2
+ /** The minimal shape of a LangChain `StructuredTool` this adapter needs. */
3
+ export interface LangChainToolLike {
4
+ name: string;
5
+ invoke(input: unknown, config?: unknown): Promise<unknown>;
6
+ [k: string]: unknown;
7
+ }
8
+ export interface GovernToolOptions {
9
+ /** The governor to authorize against. Defaults to the shared `govern`. */
10
+ governor?: Watchlight;
11
+ /** Governance intent for this tool. Defaults to the tool's `name`. */
12
+ intent?: string;
13
+ }
14
+ export interface GovernToolsOptions {
15
+ governor?: Watchlight;
16
+ /** Map a tool name to a governance intent. Defaults to identity (intent =
17
+ * tool name). */
18
+ intentFor?: (toolName: string) => string;
19
+ }
20
+ /**
21
+ * Wrap a LangChain / LangGraph.js tool so its `invoke` is authorized by the
22
+ * in-process engine before it runs. Returns a governed view of the tool (a
23
+ * Proxy) — pass it to your agent / `ToolNode` exactly like the original. The
24
+ * original tool is not mutated. Fail-closed: on anything but ALLOW, `invoke`
25
+ * throws `Denied` and the underlying tool never executes.
26
+ */
27
+ export declare function governTool<T extends LangChainToolLike>(tool: T, opts?: GovernToolOptions): T;
28
+ /**
29
+ * Govern an array of LangChain / LangGraph.js tools. `intentFor` maps each tool
30
+ * name to an intent (default: the tool name).
31
+ */
32
+ export declare function governTools<T extends LangChainToolLike>(tools: T[], opts?: GovernToolsOptions): T[];
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ // LangChain / LangGraph.js integration — govern a tool's execution with the
3
+ // in-process engine. The TS counterpart of Python `watchlight.langgraph`.
4
+ //
5
+ // import { tool } from "@langchain/core/tools";
6
+ // import { govern, governTool } from "@watchlight/sdk";
7
+ //
8
+ // govern.load("watchlight.policy.json");
9
+ // const search = governTool(
10
+ // tool(async ({ query }) => webSearch(query), { name: "web_search", schema }),
11
+ // { intent: "research" }
12
+ // );
13
+ // // pass `search` to your LangGraph agent / ToolNode as usual.
14
+ //
15
+ // Before the tool runs, the engine authorizes (agent, intent, tool/<name>). ALLOW
16
+ // runs it; anything else throws `Denied` and the tool body never executes —
17
+ // denied before it runs. Fail-closed. Works for any LangChain `StructuredTool`
18
+ // (which is what LangGraph.js tools are).
19
+ //
20
+ // This is glue: it intercepts the tool's `invoke`; the decision comes from the
21
+ // engine (via the shared governor). No LangChain hard dependency — the adapter is
22
+ // structurally typed against the tool's public shape, so `@langchain/core` stays
23
+ // a peer you already have installed.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.governTool = governTool;
26
+ exports.governTools = governTools;
27
+ const index_1 = require("./index");
28
+ /**
29
+ * Wrap a LangChain / LangGraph.js tool so its `invoke` is authorized by the
30
+ * in-process engine before it runs. Returns a governed view of the tool (a
31
+ * Proxy) — pass it to your agent / `ToolNode` exactly like the original. The
32
+ * original tool is not mutated. Fail-closed: on anything but ALLOW, `invoke`
33
+ * throws `Denied` and the underlying tool never executes.
34
+ */
35
+ function governTool(tool, opts = {}) {
36
+ const governor = opts.governor ?? index_1.govern;
37
+ const intent = opts.intent ?? tool.name;
38
+ const name = tool.name;
39
+ return new Proxy(tool, {
40
+ get(target, prop, receiver) {
41
+ if (prop === "invoke") {
42
+ return async (input, config) => {
43
+ const { allowed, reason } = await governor.check(intent, name);
44
+ if (!allowed) {
45
+ throw new index_1.Denied(name, intent, reason || "no matching policy");
46
+ }
47
+ return target.invoke(input, config);
48
+ };
49
+ }
50
+ // Delegate everything else to the real tool, bound to it so `this` stays
51
+ // correct and internal calls hit the real (un-governed) methods.
52
+ const value = Reflect.get(target, prop, receiver);
53
+ return typeof value === "function" ? value.bind(target) : value;
54
+ },
55
+ });
56
+ }
57
+ /**
58
+ * Govern an array of LangChain / LangGraph.js tools. `intentFor` maps each tool
59
+ * name to an intent (default: the tool name).
60
+ */
61
+ function governTools(tools, opts = {}) {
62
+ const intentFor = opts.intentFor ?? ((n) => n);
63
+ return tools.map((t) => governTool(t, { governor: opts.governor, intent: intentFor(t.name) }));
64
+ }
@@ -0,0 +1,36 @@
1
+ /** PII categories the deterministic detector recognizes. */
2
+ export type PiiType = "EMAIL" | "PHONE" | "SSN" | "CREDIT_CARD" | "IBAN" | "IPV4" | "API_KEY";
3
+ /** How a detected value is replaced. */
4
+ export type RedactMode = "tag" | "mask" | "hash";
5
+ export declare const DETECTOR_VERSION = "de-rules-1";
6
+ /** Raised when sanitization cannot complete — fail-closed: the caller must NOT
7
+ * fall back to raw content. */
8
+ export declare class SanitizeError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export interface SanitizeOptions {
12
+ /** Replacement strategy. Default `"tag"` (consistent `<EMAIL_1>` placeholders). */
13
+ mode?: RedactMode;
14
+ /** Restrict to these PII types. Default: all deterministic types. */
15
+ types?: PiiType[];
16
+ }
17
+ export interface SanitizeReport {
18
+ mode: RedactMode;
19
+ detectorVersion: string;
20
+ /** Count of redactions per type. Value-free by construction — never the values. */
21
+ counts: Partial<Record<PiiType, number>>;
22
+ /** Total redactions. */
23
+ total: number;
24
+ }
25
+ export interface SanitizeResult {
26
+ /** The redacted text, safe to hand to an agent. */
27
+ text: string;
28
+ /** Value-free summary of what was redacted (for the audit trail). */
29
+ report: SanitizeReport;
30
+ }
31
+ /**
32
+ * Redact PII from `text`. Pure and deterministic. Fail-closed: throws
33
+ * {@link SanitizeError} on any internal error rather than returning partially
34
+ * processed (potentially leaking) text.
35
+ */
36
+ export declare function sanitize(text: string, opts?: SanitizeOptions): SanitizeResult;
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ // govern.sanitize — governed data minimization at the agent boundary.
3
+ //
4
+ // Strip PII from text BEFORE an agent reads it. Deterministic, in-process,
5
+ // fail-closed. This is the Developer-Edition baseline detector: high-precision
6
+ // STRUCTURED PII via rules (email, phone, SSN, credit card w/ Luhn, IBAN, IPv4,
7
+ // API keys). Names/addresses need NER — an opt-in / Enterprise stage — so recall
8
+ // is honestly bounded by the enabled detectors and surfaced in the report.
9
+ //
10
+ // Operates on extracted TEXT. Document extraction (PDF/docx → text, across all
11
+ // layers) is a separate step: you never hand the agent a "redacted PDF" (its
12
+ // hidden layers leak) — you hand it redacted text.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.SanitizeError = exports.DETECTOR_VERSION = void 0;
15
+ exports.sanitize = sanitize;
16
+ const node_crypto_1 = require("node:crypto");
17
+ exports.DETECTOR_VERSION = "de-rules-1";
18
+ /** Raised when sanitization cannot complete — fail-closed: the caller must NOT
19
+ * fall back to raw content. */
20
+ class SanitizeError extends Error {
21
+ constructor(message) {
22
+ super(`sanitize failed (fail-closed): ${message}`);
23
+ this.name = "SanitizeError";
24
+ }
25
+ }
26
+ exports.SanitizeError = SanitizeError;
27
+ // ── deterministic detectors ─────────────────────────────────────────
28
+ // Each returns [start, end) match spans over the input. High precision first;
29
+ // CREDIT_CARD is Luhn-validated to cut false positives.
30
+ const luhnOk = (digits) => {
31
+ let sum = 0;
32
+ let alt = false;
33
+ for (let i = digits.length - 1; i >= 0; i--) {
34
+ let d = digits.charCodeAt(i) - 48;
35
+ if (d < 0 || d > 9)
36
+ return false;
37
+ if (alt) {
38
+ d *= 2;
39
+ if (d > 9)
40
+ d -= 9;
41
+ }
42
+ sum += d;
43
+ alt = !alt;
44
+ }
45
+ return sum % 10 === 0;
46
+ };
47
+ const DETECTORS = [
48
+ { type: "EMAIL", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
49
+ // API keys / tokens with well-known prefixes (before generic patterns).
50
+ { type: "API_KEY", re: /\b(?:sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b/g },
51
+ { type: "SSN", re: /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g },
52
+ {
53
+ type: "CREDIT_CARD",
54
+ re: /\b(?:\d[ -]?){13,19}\b/g,
55
+ valid: (m) => {
56
+ const d = m.replace(/[ -]/g, "");
57
+ return d.length >= 13 && d.length <= 19 && luhnOk(d);
58
+ },
59
+ },
60
+ { type: "IBAN", re: /\b[A-Z]{2}\d{2}(?:[ ]?[A-Za-z0-9]{4}){2,7}(?:[ ]?[A-Za-z0-9]{1,3})?\b/g },
61
+ {
62
+ type: "IPV4",
63
+ re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
64
+ valid: (m) => m.split(".").every((o) => Number(o) <= 255),
65
+ },
66
+ {
67
+ type: "PHONE",
68
+ re: /(?<!\d)(?:\+?\d{1,3}[ .-]?)?(?:\(\d{2,4}\)[ .-]?)?\d{3}[ .-]?\d{4}(?!\d)/g,
69
+ // Require at least 10 digits total to avoid matching short number runs.
70
+ valid: (m) => (m.replace(/\D/g, "").length >= 10),
71
+ },
72
+ ];
73
+ function detect(text, types) {
74
+ const enabled = new Set(types);
75
+ const spans = [];
76
+ for (const det of DETECTORS) {
77
+ if (!enabled.has(det.type))
78
+ continue;
79
+ det.re.lastIndex = 0;
80
+ let m;
81
+ while ((m = det.re.exec(text)) !== null) {
82
+ const value = m[0];
83
+ if (det.valid && !det.valid(value))
84
+ continue;
85
+ spans.push({ start: m.index, end: m.index + value.length, type: det.type, value });
86
+ if (m.index === det.re.lastIndex)
87
+ det.re.lastIndex++; // guard zero-width
88
+ }
89
+ }
90
+ // Resolve overlaps: sort by start, then longest; drop any span overlapping one
91
+ // already kept (first detector wins by the DETECTORS order via stable sort).
92
+ spans.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
93
+ const kept = [];
94
+ let lastEnd = -1;
95
+ for (const s of spans) {
96
+ if (s.start >= lastEnd) {
97
+ kept.push(s);
98
+ lastEnd = s.end;
99
+ }
100
+ }
101
+ return kept;
102
+ }
103
+ function replacement(span, mode, counters, perType) {
104
+ if (mode === "mask")
105
+ return `[${span.type}]`;
106
+ if (mode === "hash") {
107
+ const h = (0, node_crypto_1.createHash)("sha256").update(span.value).digest("hex").slice(0, 8);
108
+ return `<${span.type}_${h}>`;
109
+ }
110
+ // tag: consistent per value (same value → same tag within this call).
111
+ const key = `${span.type}:${span.value}`;
112
+ let tag = counters.get(key);
113
+ if (!tag) {
114
+ const n = (perType.get(span.type) ?? 0) + 1;
115
+ perType.set(span.type, n);
116
+ tag = `<${span.type}_${n}>`;
117
+ counters.set(key, tag);
118
+ }
119
+ return tag;
120
+ }
121
+ /**
122
+ * Redact PII from `text`. Pure and deterministic. Fail-closed: throws
123
+ * {@link SanitizeError} on any internal error rather than returning partially
124
+ * processed (potentially leaking) text.
125
+ */
126
+ function sanitize(text, opts = {}) {
127
+ const mode = opts.mode ?? "tag";
128
+ const types = opts.types ?? DETECTORS.map((d) => d.type);
129
+ if (typeof text !== "string") {
130
+ throw new SanitizeError("input must be a string (extract document text first)");
131
+ }
132
+ try {
133
+ const spans = detect(text, types);
134
+ const counters = new Map();
135
+ const perTypeTag = new Map();
136
+ const counts = {};
137
+ // Rebuild the string, replacing spans left→right.
138
+ let out = "";
139
+ let cursor = 0;
140
+ for (const s of spans) {
141
+ out += text.slice(cursor, s.start);
142
+ out += replacement(s, mode, counters, perTypeTag);
143
+ cursor = s.end;
144
+ counts[s.type] = (counts[s.type] ?? 0) + 1;
145
+ }
146
+ out += text.slice(cursor);
147
+ return {
148
+ text: out,
149
+ report: { mode, detectorVersion: exports.DETECTOR_VERSION, counts, total: spans.length },
150
+ };
151
+ }
152
+ catch (e) {
153
+ throw new SanitizeError(String(e?.message ?? e));
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchlight/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Watchlight Developer Edition govern glue for Node/TypeScript — declare intent, govern a tool with a fail-closed in-process policy decision, and get a value-free audit trail. Glue over @watchlight/engine; zero decision logic in JS.",
5
5
  "type": "commonjs",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "tsc -p tsconfig.json",
18
- "test": "npm run build && node test/govern.test.mjs && node test/claude-agent.test.mjs && node test/graduation.test.mjs",
18
+ "test": "npm run build && for t in govern claude-agent graduation langchain sanitize action-gate; do node test/$t.test.mjs || exit 1; done",
19
19
  "prepublishOnly": "npm run build"
20
20
  },
21
21
  "keywords": [