@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 +72 -0
- package/dist/backend.d.ts +9 -0
- package/dist/backend.js +26 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +141 -0
- package/dist/index.d.ts +113 -1
- package/dist/index.js +228 -24
- package/dist/policytest.d.ts +80 -0
- package/dist/policytest.js +123 -0
- package/dist/sanitize.d.ts +36 -0
- package/dist/sanitize.js +155 -0
- package/package.json +5 -2
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 {
|
|
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/cli.d.ts
ADDED
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
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
const
|
|
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(
|
|
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;
|
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,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@watchlight/sdk",
|
|
3
|
-
"version": "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 &&
|
|
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": [
|