@watchlight/sdk 0.2.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
@@ -108,6 +108,78 @@ const search = governTool(
108
108
  mutated); `governTools(tools, { intentFor })` maps an array. Intent defaults to
109
109
  the tool's name. Fail-closed. `@langchain/core` is a peer dependency.
110
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
+
111
183
  ## Value-free audit
112
184
 
113
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,9 +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";
5
6
  export { governTool, governTools } from "./langchain";
6
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";
7
10
  export type { GovernanceBackend, Decision, AuthorizeRequest } from "./backend";
8
11
  export { InProcessBackend, NetworkedBackend } from "./backend";
9
12
  /** Raised when the policy engine refuses a governed tool call (fail-closed). */
@@ -13,6 +16,32 @@ export declare class Denied extends Error {
13
16
  readonly reason: string;
14
17
  constructor(tool: string, intent: string, reason: string);
15
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
+ }
16
45
  /** A function governed by {@link Watchlight.tool} — always async (the engine's
17
46
  * authorize path is async in WebAssembly). */
18
47
  export type Governed<A extends unknown[], R> = (...args: A) => Promise<Awaited<R>>;
@@ -77,6 +106,24 @@ export declare class Watchlight {
77
106
  */
78
107
  tool<A extends unknown[], R>(fn: (...args: A) => R, opts: {
79
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>;
80
127
  }): Governed<A, R>;
81
128
  /**
82
129
  * Authorize a raw `(intent, tool)` pair, audit the decision, and return it.
@@ -88,8 +135,52 @@ export declare class Watchlight {
88
135
  allowed: boolean;
89
136
  decision: string;
90
137
  reason: string;
138
+ decisionId?: string;
91
139
  }>;
92
- 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;
93
184
  private _announce;
94
185
  private _audit;
95
186
  }
package/dist/index.js CHANGED
Binary file
@@ -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.2.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 && node test/langchain.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": [