@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 +99 -0
- package/dist/backend.d.ts +9 -0
- package/dist/backend.js +26 -4
- package/dist/index.d.ts +94 -1
- package/dist/index.js +0 -0
- package/dist/langchain.d.ts +32 -0
- package/dist/langchain.js +64 -0
- package/dist/sanitize.d.ts +36 -0
- package/dist/sanitize.js +155 -0
- package/package.json +2 -2
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 {
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/dist/sanitize.js
ADDED
|
@@ -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.
|
|
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 &&
|
|
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": [
|