@watchlight/sdk 0.4.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/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,5 +1,6 @@
1
1
  import { Scope } from "./attenuation";
2
2
  import { type SanitizeOptions, type SanitizeResult } from "./sanitize";
3
+ import { type PolicyTestCase, type PolicyTestReport } from "./policytest";
3
4
  export { Scope, DE_MAX_DEPTH, AttenuationDenied, DevEditionCeiling } from "./attenuation";
4
5
  export { governedHooks } from "./claude-agent";
5
6
  export type { GovernedHooksOptions, GovernedHooksResult } from "./claude-agent";
@@ -9,6 +10,8 @@ export { sanitize, SanitizeError, DETECTOR_VERSION } from "./sanitize";
9
10
  export type { PiiType, RedactMode, SanitizeOptions, SanitizeReport, SanitizeResult, } from "./sanitize";
10
11
  export type { GovernanceBackend, Decision, AuthorizeRequest } from "./backend";
11
12
  export { InProcessBackend, NetworkedBackend } from "./backend";
13
+ export { runPolicyTests, loadTestSuite } from "./policytest";
14
+ export type { PolicyTestCase, PolicyTestReport, PolicyTestResult, PolicyTestSuite, } from "./policytest";
12
15
  /** Raised when the policy engine refuses a governed tool call (fail-closed). */
13
16
  export declare class Denied extends Error {
14
17
  readonly tool: string;
@@ -156,6 +159,24 @@ export declare class Watchlight {
156
159
  /** A token from {@link mintApproval} (after human confirmation). */
157
160
  approval?: string;
158
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>;
159
180
  /**
160
181
  * Mint a single-use approval token for a specific `(principal, action,
161
182
  * resource)`, to pass to {@link authorize} after a human confirms a
package/dist/index.js CHANGED
Binary file
@@ -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
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@watchlight/sdk",
3
- "version": "0.4.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 && for t in govern claude-agent graduation langchain sanitize action-gate; do node test/$t.test.mjs || exit 1; done",
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": [