@watchlight/sdk 0.2.0 → 0.5.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/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // The `watchlight` command for the Node/TypeScript lane.
4
+ //
5
+ // npx watchlight policy test <suite.json>
6
+ //
7
+ // Runs a policy test suite (golden fixtures → expected Allow/Deny/NeedsApproval)
8
+ // against the DE engine and exits non-zero if any fixture fails — drop it into
9
+ // CI to gate a policy change before it can gate a real action. The suite file is
10
+ //
11
+ // { "policyFile": "watchlight.policy.json", // and/or inline "policies"
12
+ // "tests": [ { "name": "...", "action": "book",
13
+ // "context": { "amount": 200, "limit": 500 },
14
+ // "expect": "Allow" } ] }
15
+ //
16
+ // The dashboard lives in the Python package (`watchlight dev`); this Node CLI is
17
+ // scoped to policy testing.
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ const path = __importStar(require("node:path"));
53
+ const index_1 = require("./index");
54
+ const policytest_1 = require("./policytest");
55
+ const USAGE = `watchlight — Watchlight Developer Edition (Node)
56
+
57
+ usage:
58
+ watchlight policy test <suite.json> run policy fixtures, exit 1 on failure
59
+
60
+ suite.json:
61
+ { "policyFile": "watchlight.policy.json",
62
+ "policies": [ { "name": "...", "code": "permit(...);" } ],
63
+ "tests": [ { "name": "under limit", "action": "book",
64
+ "principal": "User::\\"alice\\"", "resource": "trip/42",
65
+ "context": { "amount": 200, "limit": 500 },
66
+ "expect": "Allow" } ] }
67
+ `;
68
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
69
+ const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s);
70
+ const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
71
+ const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
72
+ function printReport(file, report) {
73
+ console.log(`watchlight policy test — ${file}\n`);
74
+ for (const r of report.results) {
75
+ if (r.ok) {
76
+ console.log(` ${green("✓")} ${r.name} ${dim("→ " + r.actual)}`);
77
+ }
78
+ else {
79
+ const why = r.reason ? dim(` (${r.reason})`) : "";
80
+ console.log(` ${red("✗")} ${r.name} ${red(`— expected ${r.expected}, got ${r.actual}`)}${why}`);
81
+ }
82
+ }
83
+ const summary = `${report.passed} passed, ${report.failed} failed (${report.total} total)`;
84
+ console.log("\n" + (report.failed ? red(summary) : green(summary)));
85
+ }
86
+ async function policyTest(file) {
87
+ if (!file) {
88
+ console.error("watchlight policy test: missing <suite.json>\n");
89
+ console.error(USAGE);
90
+ return 2;
91
+ }
92
+ let suite;
93
+ try {
94
+ suite = (0, policytest_1.loadTestSuite)(file);
95
+ }
96
+ catch (e) {
97
+ console.error(`watchlight: could not read suite '${file}': ${e.message}`);
98
+ return 2;
99
+ }
100
+ // Fresh, policy-free governor (fail-closed); load only what the suite declares.
101
+ // No audit is written — `test()` uses the engine's decision core directly.
102
+ const gov = new index_1.Watchlight({ agent: "policy-test" });
103
+ if (suite.policyFile) {
104
+ gov.load(path.resolve(path.dirname(file), suite.policyFile));
105
+ }
106
+ for (const p of suite.policies ?? [])
107
+ gov.allow(p.code, p.name);
108
+ if (!suite.tests || suite.tests.length === 0) {
109
+ console.error(`watchlight: suite '${file}' has no tests`);
110
+ return 2;
111
+ }
112
+ let report;
113
+ try {
114
+ report = await gov.test(suite.tests);
115
+ }
116
+ catch (e) {
117
+ // malformed fixture (missing action/expect)
118
+ console.error(`watchlight: ${e.message}`);
119
+ return 2;
120
+ }
121
+ printReport(file, report);
122
+ return report.failed > 0 ? 1 : 0;
123
+ }
124
+ async function main(argv) {
125
+ const [cmd, sub, ...rest] = argv;
126
+ if (cmd === "policy" && sub === "test")
127
+ return policyTest(rest[0]);
128
+ if (cmd === "--help" || cmd === "-h" || cmd === undefined) {
129
+ console.log(USAGE);
130
+ return 0;
131
+ }
132
+ console.error(`watchlight: unknown command '${[cmd, sub].filter(Boolean).join(" ")}'\n`);
133
+ console.error(USAGE);
134
+ return 2;
135
+ }
136
+ main(process.argv.slice(2))
137
+ .then((code) => process.exit(code))
138
+ .catch((e) => {
139
+ console.error(`watchlight: ${e?.message ?? e}`);
140
+ process.exit(1);
141
+ });
package/dist/index.d.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import { Scope } from "./attenuation";
2
+ import { type SanitizeOptions, type SanitizeResult } from "./sanitize";
3
+ import { type PolicyTestCase, type PolicyTestReport } from "./policytest";
2
4
  export { Scope, DE_MAX_DEPTH, AttenuationDenied, DevEditionCeiling } from "./attenuation";
3
5
  export { governedHooks } from "./claude-agent";
4
6
  export type { GovernedHooksOptions, GovernedHooksResult } from "./claude-agent";
5
7
  export { governTool, governTools } from "./langchain";
6
8
  export type { LangChainToolLike, GovernToolOptions, GovernToolsOptions, } from "./langchain";
9
+ export { sanitize, SanitizeError, DETECTOR_VERSION } from "./sanitize";
10
+ export type { PiiType, RedactMode, SanitizeOptions, SanitizeReport, SanitizeResult, } from "./sanitize";
7
11
  export type { GovernanceBackend, Decision, AuthorizeRequest } from "./backend";
8
12
  export { InProcessBackend, NetworkedBackend } from "./backend";
13
+ export { runPolicyTests, loadTestSuite } from "./policytest";
14
+ export type { PolicyTestCase, PolicyTestReport, PolicyTestResult, PolicyTestSuite, } from "./policytest";
9
15
  /** Raised when the policy engine refuses a governed tool call (fail-closed). */
10
16
  export declare class Denied extends Error {
11
17
  readonly tool: string;
@@ -13,6 +19,32 @@ export declare class Denied extends Error {
13
19
  readonly reason: string;
14
20
  constructor(tool: string, intent: string, reason: string);
15
21
  }
22
+ /** Raised when a governed call is permitted only after a human confirmation
23
+ * (the matched permit carries the `require_approval` enforcement effect) and no
24
+ * valid approval was supplied. Fail-closed: the tool body did NOT run. */
25
+ export declare class NeedsApproval extends Error {
26
+ readonly tool: string;
27
+ readonly intent: string;
28
+ readonly decisionId?: string;
29
+ readonly reason: string;
30
+ constructor(tool: string, intent: string, decisionId: string | undefined, reason: string);
31
+ }
32
+ /** A per-call binding: a fixed value, or a function of the tool's arguments. */
33
+ export type Binding<A extends unknown[]> = string | ((...args: A) => string);
34
+ /** A record of attributes passed into Cedar `context.*`, or a function of args. */
35
+ export type ContextBinding<A extends unknown[]> = Record<string, unknown> | ((...args: A) => Record<string, unknown>);
36
+ /** Full result of {@link Watchlight.authorize}. */
37
+ export interface AuthorizeResult {
38
+ /** `"Allow"` | `"Deny"` | `"NeedsApproval"`. */
39
+ decision: "Allow" | "Deny" | "NeedsApproval";
40
+ allowed: boolean;
41
+ needsApproval: boolean;
42
+ /** True when a valid approval token downgraded a NeedsApproval to Allow. */
43
+ approved: boolean;
44
+ /** Per-decision correlation id (engine `request_id`) — join to your records. */
45
+ decisionId?: string;
46
+ reason: string;
47
+ }
16
48
  /** A function governed by {@link Watchlight.tool} — always async (the engine's
17
49
  * authorize path is async in WebAssembly). */
18
50
  export type Governed<A extends unknown[], R> = (...args: A) => Promise<Awaited<R>>;
@@ -77,6 +109,24 @@ export declare class Watchlight {
77
109
  */
78
110
  tool<A extends unknown[], R>(fn: (...args: A) => R, opts: {
79
111
  intent: string;
112
+ /** Acting principal, e.g. `User::"u1"` — value or `(args) => value`.
113
+ * Defaults to the agent. */
114
+ principal?: Binding<A>;
115
+ /** Cedar resource entity — value or `(args) => value`. Defaults to
116
+ * `tool/<name>`. */
117
+ resource?: Binding<A>;
118
+ /** Attributes for Cedar `context.*` — object or `(args) => object`. */
119
+ context?: ContextBinding<A>;
120
+ /** Human-in-the-loop hook. Called when the decision is `NeedsApproval`;
121
+ * return `true` to proceed (records an approval), `false`/absent to hold
122
+ * (throws `NeedsApproval`). */
123
+ onNeedsApproval?: (info: {
124
+ intent: string;
125
+ resource: string;
126
+ principal: string;
127
+ decisionId?: string;
128
+ reason: string;
129
+ }) => boolean | Promise<boolean>;
80
130
  }): Governed<A, R>;
81
131
  /**
82
132
  * Authorize a raw `(intent, tool)` pair, audit the decision, and return it.
@@ -88,8 +138,70 @@ export declare class Watchlight {
88
138
  allowed: boolean;
89
139
  decision: string;
90
140
  reason: string;
141
+ decisionId?: string;
91
142
  }>;
92
- private _authorize;
143
+ /**
144
+ * Authorize an action with full control — per-call `principal`, `resource`,
145
+ * and Cedar `context` — and get a correlation id back. The low-level primitive
146
+ * behind {@link tool}; use it directly for any consequential action.
147
+ *
148
+ * Returns a three-state verdict: `Allow` / `Deny` / `NeedsApproval`. A
149
+ * `NeedsApproval` (matched permit annotated `require_approval`) is downgraded
150
+ * to `Allow` when a valid single-use `approval` token — from
151
+ * {@link mintApproval}, minted after a human confirms — is supplied.
152
+ * Fail-closed and audited (value-free).
153
+ */
154
+ authorize(req: {
155
+ action: string;
156
+ principal?: string;
157
+ resource?: string;
158
+ context?: Record<string, unknown>;
159
+ /** A token from {@link mintApproval} (after human confirmation). */
160
+ approval?: string;
161
+ }): Promise<AuthorizeResult>;
162
+ /**
163
+ * The pure decision core behind {@link authorize}: run the engine, apply the
164
+ * approval-token downgrade, and compute the three-state verdict — WITHOUT
165
+ * writing to the audit trail. Used by {@link authorize} (which then audits)
166
+ * and by {@link test} (which must not pollute the trail with fixture runs).
167
+ */
168
+ private _decide;
169
+ /**
170
+ * Run a list of policy fixtures against the loaded policies and report which
171
+ * pass — a golden-test harness for CI, so a policy change is verified before
172
+ * it gates real actions. Each case asserts the expected verdict
173
+ * (`Allow` / `Deny` / `NeedsApproval`) for a `(principal, action, resource,
174
+ * context)`; set `approved: true` to mint a valid approval token and assert
175
+ * the human-confirmed downgrade. Does NOT write to the audit trail. A verdict
176
+ * mismatch is a failed result (inspect `report.failed` and assert on it in
177
+ * your test runner); a malformed fixture missing `action`/`expect` throws.
178
+ */
179
+ test(cases: readonly PolicyTestCase[]): Promise<PolicyTestReport>;
180
+ /**
181
+ * Mint a single-use approval token for a specific `(principal, action,
182
+ * resource)`, to pass to {@link authorize} after a human confirms a
183
+ * `NeedsApproval` decision. Local HMAC, TTL-bounded (default 2 min). In
184
+ * Enterprise these are KMS-signed and recorded in signed lineage.
185
+ */
186
+ mintApproval(challenge: {
187
+ action: string;
188
+ principal?: string;
189
+ resource?: string;
190
+ }, opts?: {
191
+ ttlMs?: number;
192
+ }): string;
193
+ /**
194
+ * Strip PII from text before an agent reads it (governed data minimization).
195
+ * Deterministic, fail-closed. Writes a value-free `sanitization` record to the
196
+ * audit trail (counts by PII type + mode — never the values) and returns the
197
+ * redacted text plus the report. Operates on extracted text — extract a
198
+ * document to text first (never hand the agent a "redacted PDF").
199
+ */
200
+ sanitize(content: string, opts?: SanitizeOptions & {
201
+ intent?: string;
202
+ resource?: string;
203
+ }): SanitizeResult;
204
+ private _auditSanitize;
93
205
  private _announce;
94
206
  private _audit;
95
207
  }
package/dist/index.js CHANGED
@@ -49,11 +49,14 @@ var __importStar = (this && this.__importStar) || (function () {
49
49
  };
50
50
  })();
51
51
  Object.defineProperty(exports, "__esModule", { value: true });
52
- exports.govern = exports.Watchlight = exports.Denied = exports.NetworkedBackend = exports.InProcessBackend = exports.governTools = exports.governTool = exports.governedHooks = exports.DevEditionCeiling = exports.AttenuationDenied = exports.DE_MAX_DEPTH = exports.Scope = void 0;
52
+ exports.govern = exports.Watchlight = exports.NeedsApproval = exports.Denied = exports.loadTestSuite = exports.runPolicyTests = exports.NetworkedBackend = exports.InProcessBackend = exports.DETECTOR_VERSION = exports.SanitizeError = exports.sanitize = exports.governTools = exports.governTool = exports.governedHooks = exports.DevEditionCeiling = exports.AttenuationDenied = exports.DE_MAX_DEPTH = exports.Scope = void 0;
53
53
  const fs = __importStar(require("node:fs"));
54
54
  const path = __importStar(require("node:path"));
55
+ const node_crypto_1 = require("node:crypto");
55
56
  const attenuation_1 = require("./attenuation");
56
57
  const backend_1 = require("./backend");
58
+ const sanitize_1 = require("./sanitize");
59
+ const policytest_1 = require("./policytest");
57
60
  var attenuation_2 = require("./attenuation");
58
61
  Object.defineProperty(exports, "Scope", { enumerable: true, get: function () { return attenuation_2.Scope; } });
59
62
  Object.defineProperty(exports, "DE_MAX_DEPTH", { enumerable: true, get: function () { return attenuation_2.DE_MAX_DEPTH; } });
@@ -64,9 +67,16 @@ Object.defineProperty(exports, "governedHooks", { enumerable: true, get: functio
64
67
  var langchain_1 = require("./langchain");
65
68
  Object.defineProperty(exports, "governTool", { enumerable: true, get: function () { return langchain_1.governTool; } });
66
69
  Object.defineProperty(exports, "governTools", { enumerable: true, get: function () { return langchain_1.governTools; } });
70
+ var sanitize_2 = require("./sanitize");
71
+ Object.defineProperty(exports, "sanitize", { enumerable: true, get: function () { return sanitize_2.sanitize; } });
72
+ Object.defineProperty(exports, "SanitizeError", { enumerable: true, get: function () { return sanitize_2.SanitizeError; } });
73
+ Object.defineProperty(exports, "DETECTOR_VERSION", { enumerable: true, get: function () { return sanitize_2.DETECTOR_VERSION; } });
67
74
  var backend_2 = require("./backend");
68
75
  Object.defineProperty(exports, "InProcessBackend", { enumerable: true, get: function () { return backend_2.InProcessBackend; } });
69
76
  Object.defineProperty(exports, "NetworkedBackend", { enumerable: true, get: function () { return backend_2.NetworkedBackend; } });
77
+ var policytest_2 = require("./policytest");
78
+ Object.defineProperty(exports, "runPolicyTests", { enumerable: true, get: function () { return policytest_2.runPolicyTests; } });
79
+ Object.defineProperty(exports, "loadTestSuite", { enumerable: true, get: function () { return policytest_2.loadTestSuite; } });
70
80
  /** Raised when the policy engine refuses a governed tool call (fail-closed). */
71
81
  class Denied extends Error {
72
82
  constructor(tool, intent, reason) {
@@ -78,6 +88,63 @@ class Denied extends Error {
78
88
  }
79
89
  }
80
90
  exports.Denied = Denied;
91
+ /** Raised when a governed call is permitted only after a human confirmation
92
+ * (the matched permit carries the `require_approval` enforcement effect) and no
93
+ * valid approval was supplied. Fail-closed: the tool body did NOT run. */
94
+ class NeedsApproval extends Error {
95
+ constructor(tool, intent, decisionId, reason) {
96
+ super(`watchlight requires human approval for intent '${intent}' on tool/${tool}`);
97
+ this.name = "NeedsApproval";
98
+ this.tool = tool;
99
+ this.intent = intent;
100
+ this.decisionId = decisionId;
101
+ this.reason = reason;
102
+ }
103
+ }
104
+ exports.NeedsApproval = NeedsApproval;
105
+ // ── approval tokens (DE: local, single-use, HMAC, TTL) ───────────────
106
+ // Enterprise mints these KMS-signed and records them in signed lineage.
107
+ const APPROVAL_SECRET = (0, node_crypto_1.randomBytes)(32);
108
+ const USED_APPROVALS = new Set();
109
+ const approvalPayload = (principal, action, resource, exp, nonce) => `${principal} ${action} ${resource} ${exp} ${nonce}`;
110
+ function mintApprovalToken(principal, action, resource, ttlMs) {
111
+ const exp = Date.now() + ttlMs;
112
+ // A per-mint nonce makes every token unique, so two approvals for the same
113
+ // (principal, action, resource) minted in the same millisecond never collide
114
+ // — and "single-use" is genuinely per-mint, not per-(challenge, exp).
115
+ const nonce = (0, node_crypto_1.randomBytes)(8).toString("hex");
116
+ const sig = (0, node_crypto_1.createHmac)("sha256", APPROVAL_SECRET)
117
+ .update(approvalPayload(principal, action, resource, exp, nonce))
118
+ .digest("hex");
119
+ return `${exp}.${nonce}.${sig}`;
120
+ }
121
+ /** Verify + CONSUME an approval token (single-use). Bound to the exact
122
+ * (principal, action, resource); rejects expired, tampered, or reused tokens. */
123
+ function consumeApprovalToken(token, principal, action, resource) {
124
+ const parts = token.split(".");
125
+ if (parts.length !== 3)
126
+ return false;
127
+ const [expStr, nonce, sig] = parts;
128
+ const exp = Number(expStr);
129
+ if (!Number.isFinite(exp) || Date.now() > exp)
130
+ return false;
131
+ const expected = (0, node_crypto_1.createHmac)("sha256", APPROVAL_SECRET)
132
+ .update(approvalPayload(principal, action, resource, exp, nonce))
133
+ .digest("hex");
134
+ if (sig.length !== expected.length)
135
+ return false;
136
+ if (!(0, node_crypto_1.timingSafeEqual)(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")))
137
+ return false;
138
+ if (USED_APPROVALS.has(token))
139
+ return false;
140
+ USED_APPROVALS.add(token);
141
+ return true;
142
+ }
143
+ function resolveBinding(b, args) {
144
+ if (b === undefined)
145
+ return undefined;
146
+ return typeof b === "function" ? b(...args) : b;
147
+ }
81
148
  const norm = (x) => (x ? [...x] : []);
82
149
  /**
83
150
  * An in-process policy decision point for a single agent. Wraps the
@@ -160,14 +227,32 @@ class Watchlight {
160
227
  tool(fn, opts) {
161
228
  const intent = opts.intent;
162
229
  const name = fn.name || "anonymous";
163
- const resource = `tool/${name}`;
164
230
  return async (...args) => {
165
- const [decision, reason] = await this._authorize(intent, resource);
166
- this._audit(intent, resource, decision, reason);
167
- if (decision !== "Allow") {
168
- throw new Denied(name, intent, reason || "no matching policy");
231
+ const principal = resolveBinding(opts.principal, args) ?? this.agent;
232
+ const resource = resolveBinding(opts.resource, args) ?? `tool/${name}`;
233
+ const context = typeof opts.context === "function" ? opts.context(...args) : opts.context ?? {};
234
+ const d = await this.authorize({ principal, action: intent, resource, context });
235
+ if (d.allowed)
236
+ return (await fn(...args));
237
+ if (d.needsApproval) {
238
+ if (opts.onNeedsApproval) {
239
+ const ok = await opts.onNeedsApproval({
240
+ intent,
241
+ resource,
242
+ principal,
243
+ decisionId: d.decisionId,
244
+ reason: d.reason,
245
+ });
246
+ if (ok) {
247
+ const token = this.mintApproval({ principal, action: intent, resource });
248
+ const d2 = await this.authorize({ principal, action: intent, resource, context, approval: token });
249
+ if (d2.allowed)
250
+ return (await fn(...args));
251
+ }
252
+ }
253
+ throw new NeedsApproval(name, intent, d.decisionId, d.reason);
169
254
  }
170
- return (await fn(...args));
255
+ throw new Denied(name, intent, d.reason || "no matching policy");
171
256
  };
172
257
  }
173
258
  /**
@@ -177,20 +262,135 @@ class Watchlight {
177
262
  * decision is identical to {@link tool}, just without running a body.
178
263
  */
179
264
  async check(intent, toolName) {
180
- const resource = `tool/${toolName}`;
181
- const [decision, reason] = await this._authorize(intent, resource);
182
- this._audit(intent, resource, decision, reason);
183
- return { allowed: decision === "Allow", decision, reason };
265
+ const d = await this.authorize({ action: intent, resource: `tool/${toolName}` });
266
+ return { allowed: d.allowed, decision: d.decision, reason: d.reason, decisionId: d.decisionId };
184
267
  }
185
- // ── internals ─────────────────────────────────────────────────────
186
- async _authorize(intent, resource) {
187
- const { decision, reason } = await this._backend.authorize({
188
- principal: this.agent,
189
- action: intent,
268
+ /**
269
+ * Authorize an action with full control — per-call `principal`, `resource`,
270
+ * and Cedar `context` and get a correlation id back. The low-level primitive
271
+ * behind {@link tool}; use it directly for any consequential action.
272
+ *
273
+ * Returns a three-state verdict: `Allow` / `Deny` / `NeedsApproval`. A
274
+ * `NeedsApproval` (matched permit annotated `require_approval`) is downgraded
275
+ * to `Allow` when a valid single-use `approval` token — from
276
+ * {@link mintApproval}, minted after a human confirms — is supplied.
277
+ * Fail-closed and audited (value-free).
278
+ */
279
+ async authorize(req) {
280
+ const { result, principal, resource, decisionId } = await this._decide(req);
281
+ this._audit(req.action, resource, result.decision, result.reason, {
282
+ principal,
283
+ decisionId,
284
+ approved: result.approved,
285
+ });
286
+ return result;
287
+ }
288
+ /**
289
+ * The pure decision core behind {@link authorize}: run the engine, apply the
290
+ * approval-token downgrade, and compute the three-state verdict — WITHOUT
291
+ * writing to the audit trail. Used by {@link authorize} (which then audits)
292
+ * and by {@link test} (which must not pollute the trail with fixture runs).
293
+ */
294
+ async _decide(req) {
295
+ const principal = req.principal ?? this.agent;
296
+ const resource = req.resource ?? "resource";
297
+ const raw = await this._backend.authorize({
298
+ principal,
299
+ action: req.action,
190
300
  resource,
191
- context: {},
301
+ context: req.context ?? {},
192
302
  });
193
- return [decision, reason];
303
+ let allowed = raw.decision === "Allow";
304
+ let needsApproval = allowed && !!raw.needsApproval;
305
+ let approved = false;
306
+ if (needsApproval) {
307
+ if (req.approval && consumeApprovalToken(req.approval, principal, req.action, resource)) {
308
+ approved = true;
309
+ needsApproval = false; // human-confirmed → proceed
310
+ }
311
+ else {
312
+ allowed = false; // hold for approval
313
+ }
314
+ }
315
+ const decision = allowed
316
+ ? "Allow"
317
+ : needsApproval
318
+ ? "NeedsApproval"
319
+ : "Deny";
320
+ return {
321
+ result: {
322
+ decision,
323
+ allowed,
324
+ needsApproval,
325
+ approved,
326
+ decisionId: raw.decisionId,
327
+ reason: raw.reason,
328
+ },
329
+ principal,
330
+ resource,
331
+ decisionId: raw.decisionId,
332
+ };
333
+ }
334
+ /**
335
+ * Run a list of policy fixtures against the loaded policies and report which
336
+ * pass — a golden-test harness for CI, so a policy change is verified before
337
+ * it gates real actions. Each case asserts the expected verdict
338
+ * (`Allow` / `Deny` / `NeedsApproval`) for a `(principal, action, resource,
339
+ * context)`; set `approved: true` to mint a valid approval token and assert
340
+ * the human-confirmed downgrade. Does NOT write to the audit trail. A verdict
341
+ * mismatch is a failed result (inspect `report.failed` and assert on it in
342
+ * your test runner); a malformed fixture missing `action`/`expect` throws.
343
+ */
344
+ async test(cases) {
345
+ return (0, policytest_1.runPolicyTests)((req) => this._decide(req).then((d) => d.result), (challenge) => this.mintApproval(challenge), cases);
346
+ }
347
+ /**
348
+ * Mint a single-use approval token for a specific `(principal, action,
349
+ * resource)`, to pass to {@link authorize} after a human confirms a
350
+ * `NeedsApproval` decision. Local HMAC, TTL-bounded (default 2 min). In
351
+ * Enterprise these are KMS-signed and recorded in signed lineage.
352
+ */
353
+ mintApproval(challenge, opts = {}) {
354
+ return mintApprovalToken(challenge.principal ?? this.agent, challenge.action, challenge.resource ?? "resource", opts.ttlMs ?? 120000);
355
+ }
356
+ /**
357
+ * Strip PII from text before an agent reads it (governed data minimization).
358
+ * Deterministic, fail-closed. Writes a value-free `sanitization` record to the
359
+ * audit trail (counts by PII type + mode — never the values) and returns the
360
+ * redacted text plus the report. Operates on extracted text — extract a
361
+ * document to text first (never hand the agent a "redacted PDF").
362
+ */
363
+ sanitize(content, opts = {}) {
364
+ const { intent = "read", resource = "document", mode, types } = opts;
365
+ const result = (0, sanitize_1.sanitize)(content, { mode, types });
366
+ this._auditSanitize(intent, resource, result);
367
+ return result;
368
+ }
369
+ // ── internals ─────────────────────────────────────────────────────
370
+ _auditSanitize(intent, resource, result) {
371
+ this._announce();
372
+ const { report } = result;
373
+ // eslint-disable-next-line no-console
374
+ console.log(`watchlight: SANIT ${intent.padEnd(9)} ${resource} redacted ${report.total} (${report.mode})`);
375
+ // Value-free: counts by PII type + mode only — never the PII values.
376
+ const record = {
377
+ ts: new Date().toISOString(),
378
+ agent: this.agent,
379
+ intent,
380
+ event: "sanitization",
381
+ resource,
382
+ mode: report.mode,
383
+ detector: report.detectorVersion,
384
+ counts: report.counts,
385
+ total: report.total,
386
+ };
387
+ try {
388
+ fs.mkdirSync(path.dirname(this._auditPath), { recursive: true });
389
+ fs.appendFileSync(this._auditPath, JSON.stringify(record) + "\n", "utf8");
390
+ }
391
+ catch {
392
+ // Best-effort in dev mode.
393
+ }
194
394
  }
195
395
  _announce() {
196
396
  if (!this._announced) {
@@ -199,22 +399,26 @@ class Watchlight {
199
399
  this._announced = true;
200
400
  }
201
401
  }
202
- _audit(intent, resource, decision, reason) {
402
+ _audit(intent, resource, decision, reason, extra = {}) {
203
403
  this._announce();
204
- const allowed = decision === "Allow";
205
- const tag = allowed ? "ALLOW" : "DENY";
206
- const trailer = allowed ? "" : ` ${reason || "no matching policy"}`;
404
+ const tag = decision === "Allow" ? (extra.approved ? "OK✓" : "ALLOW") : decision === "NeedsApproval" ? "APPRV?" : "DENY";
405
+ const trailer = decision === "Allow" ? "" : ` ${reason || "no matching policy"}`;
207
406
  // eslint-disable-next-line no-console
208
- console.log(`watchlight: ${tag.padEnd(5)} ${intent.padEnd(9)} ${resource}${trailer}`);
407
+ console.log(`watchlight: ${tag.padEnd(6)} ${intent.padEnd(9)} ${resource}${trailer}`);
209
408
  // Value-free audit: argument VALUES never enter the trail — only the
210
- // governance decision. Mirrors the production audit contract.
409
+ // governance decision + correlation id. Mirrors the production audit contract.
211
410
  const record = {
212
411
  ts: new Date().toISOString(),
213
412
  agent: this.agent,
413
+ principal: extra.principal ?? this.agent,
214
414
  intent,
215
415
  resource,
216
416
  decision,
217
417
  };
418
+ if (extra.decisionId)
419
+ record.decision_id = extra.decisionId;
420
+ if (extra.approved)
421
+ record.approved = true;
218
422
  try {
219
423
  fs.mkdirSync(path.dirname(this._auditPath), { recursive: true });
220
424
  fs.appendFileSync(this._auditPath, JSON.stringify(record) + "\n", "utf8");
@@ -0,0 +1,80 @@
1
+ /** A single policy fixture: an input and the verdict it must produce. */
2
+ export interface PolicyTestCase {
3
+ /** Human-readable label (defaults to `"<action> on <resource>"`). */
4
+ name?: string;
5
+ /** The intent / action being authorized (Cedar `action == Action::"<action>"`). */
6
+ action: string;
7
+ /** Acting principal, e.g. `User::"alice"`. Defaults to the governor's agent. */
8
+ principal?: string;
9
+ /** Cedar resource entity. Defaults to `"resource"`. */
10
+ resource?: string;
11
+ /** Attributes exposed to Cedar `context.*`. */
12
+ context?: Record<string, unknown>;
13
+ /** Mint a valid single-use approval token for this case and assert the
14
+ * human-confirmed downgrade (turns a `NeedsApproval` into `Allow`). */
15
+ approved?: boolean;
16
+ /** The verdict this case must produce. Case-insensitive. */
17
+ expect: "Allow" | "Deny" | "NeedsApproval";
18
+ }
19
+ /** The outcome of one fixture. */
20
+ export interface PolicyTestResult {
21
+ name: string;
22
+ expected: "Allow" | "Deny" | "NeedsApproval";
23
+ actual: "Allow" | "Deny" | "NeedsApproval";
24
+ ok: boolean;
25
+ /** Engine reason — surfaced to explain an unexpected verdict. */
26
+ reason: string;
27
+ }
28
+ /** The aggregate report. `failed === 0` means the suite passed. */
29
+ export interface PolicyTestReport {
30
+ total: number;
31
+ passed: number;
32
+ failed: number;
33
+ results: PolicyTestResult[];
34
+ }
35
+ /** A suite file: inline policies and/or a policy file, plus the fixtures. */
36
+ export interface PolicyTestSuite {
37
+ /** Inline Cedar policies. */
38
+ policies?: {
39
+ name?: string;
40
+ code: string;
41
+ }[];
42
+ /** Path to a `watchlight.policy.json` (resolved relative to the suite file). */
43
+ policyFile?: string;
44
+ /** The fixtures to run. */
45
+ tests: PolicyTestCase[];
46
+ }
47
+ type Verdict = "Allow" | "Deny" | "NeedsApproval";
48
+ /** The pure decision function the harness drives — the same core `authorize`
49
+ * uses, minus the audit write. */
50
+ export type DecideFn = (req: {
51
+ action: string;
52
+ principal?: string;
53
+ resource?: string;
54
+ context?: Record<string, unknown>;
55
+ approval?: string;
56
+ }) => Promise<{
57
+ decision: Verdict;
58
+ reason: string;
59
+ }>;
60
+ /** Mints a single-use approval token bound to a challenge — used when a fixture
61
+ * sets `approved: true` to exercise the human-confirmed path. */
62
+ export type MintFn = (challenge: {
63
+ action: string;
64
+ principal?: string;
65
+ resource?: string;
66
+ }) => string;
67
+ /**
68
+ * Run policy fixtures through a decision function and report pass/fail. A
69
+ * verdict mismatch is recorded as a failed result rather than thrown — inspect
70
+ * {@link PolicyTestReport.failed}. A fixture missing a required key (`action` or
71
+ * `expect`) is a malformed suite and throws.
72
+ */
73
+ export declare function runPolicyTests(decide: DecideFn, mint: MintFn, cases: readonly PolicyTestCase[]): Promise<PolicyTestReport>;
74
+ /**
75
+ * Load a suite file. Accepts `{ policies?, policyFile? | policy_file?, tests }`
76
+ * (a bare array of tests is also accepted). Throws on malformed JSON so a broken
77
+ * suite fails the CI step rather than silently passing.
78
+ */
79
+ export declare function loadTestSuite(file: string): PolicyTestSuite;
80
+ export {};
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ // Policy test harness — golden tests for DE policies.
3
+ //
4
+ // A policy is the only thing standing between an agent and a real action, so it
5
+ // deserves the same unit-testing discipline as the code around it. This module
6
+ // runs a list of fixtures against the loaded policies and reports which pass:
7
+ // each case asserts the expected verdict (Allow / Deny / NeedsApproval) for a
8
+ // `(principal, action, resource, context)`. It contains ZERO decision logic —
9
+ // every verdict comes from the engine, via the same decision core `authorize`
10
+ // uses — and it never writes to the audit trail, so CI runs leave no residue.
11
+ //
12
+ // import { govern } from "@watchlight/sdk";
13
+ // govern.load("watchlight.policy.json");
14
+ // const r = await govern.test([
15
+ // { name: "under limit allows", action: "book",
16
+ // context: { amount: 200, limit: 500, refundable: true }, expect: "Allow" },
17
+ // ]);
18
+ // if (r.failed) throw new Error(`${r.failed} policy tests failed`);
19
+ //
20
+ // Or from CI, with the `watchlight` CLI: npx watchlight policy test suite.json
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.runPolicyTests = runPolicyTests;
56
+ exports.loadTestSuite = loadTestSuite;
57
+ const fs = __importStar(require("node:fs"));
58
+ /** Normalize a caller-supplied verdict string to the canonical spelling.
59
+ * Anything unrecognized is returned verbatim so a typo fails loudly. */
60
+ function normalizeVerdict(v) {
61
+ const s = String(v ?? "").trim().toLowerCase();
62
+ if (s === "allow" || s === "permit")
63
+ return "Allow";
64
+ if (s === "deny")
65
+ return "Deny";
66
+ if (s === "needsapproval" || s === "needs_approval" || s === "approve" || s === "approval")
67
+ return "NeedsApproval";
68
+ return v ?? "Deny";
69
+ }
70
+ /**
71
+ * Run policy fixtures through a decision function and report pass/fail. A
72
+ * verdict mismatch is recorded as a failed result rather than thrown — inspect
73
+ * {@link PolicyTestReport.failed}. A fixture missing a required key (`action` or
74
+ * `expect`) is a malformed suite and throws.
75
+ */
76
+ async function runPolicyTests(decide, mint, cases) {
77
+ const results = [];
78
+ let index = 0;
79
+ for (const c of cases) {
80
+ for (const required of ["action", "expect"]) {
81
+ if (c[required] === undefined) {
82
+ throw new Error(`fixture ${index} (${c.name ?? "?"}): missing required key '${required}'`);
83
+ }
84
+ }
85
+ index += 1;
86
+ const expected = normalizeVerdict(c.expect);
87
+ const approval = c.approved
88
+ ? mint({ action: c.action, principal: c.principal, resource: c.resource })
89
+ : undefined;
90
+ const d = await decide({
91
+ action: c.action,
92
+ principal: c.principal,
93
+ resource: c.resource,
94
+ context: c.context,
95
+ approval,
96
+ });
97
+ const actual = normalizeVerdict(d.decision);
98
+ results.push({
99
+ name: c.name ?? `${c.action} on ${c.resource ?? "resource"}`,
100
+ expected,
101
+ actual,
102
+ ok: actual === expected,
103
+ reason: d.reason ?? "",
104
+ });
105
+ }
106
+ const passed = results.filter((r) => r.ok).length;
107
+ return { total: results.length, passed, failed: results.length - passed, results };
108
+ }
109
+ /**
110
+ * Load a suite file. Accepts `{ policies?, policyFile? | policy_file?, tests }`
111
+ * (a bare array of tests is also accepted). Throws on malformed JSON so a broken
112
+ * suite fails the CI step rather than silently passing.
113
+ */
114
+ function loadTestSuite(file) {
115
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
116
+ if (Array.isArray(data))
117
+ return { tests: data };
118
+ return {
119
+ policies: data.policies,
120
+ policyFile: data.policyFile ?? data.policy_file,
121
+ tests: data.tests ?? data.cases ?? [],
122
+ };
123
+ }
@@ -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,10 +1,13 @@
1
1
  {
2
2
  "name": "@watchlight/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.5.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",
7
7
  "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "watchlight": "./dist/cli.js"
10
+ },
8
11
  "files": [
9
12
  "dist",
10
13
  "LICENSE",
@@ -15,7 +18,7 @@
15
18
  },
16
19
  "scripts": {
17
20
  "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",
21
+ "test": "npm run build && for t in govern claude-agent graduation langchain sanitize action-gate policytest; do node test/$t.test.mjs || exit 1; done",
19
22
  "prepublishOnly": "npm run build"
20
23
  },
21
24
  "keywords": [