Skip to main content

The TypeScript / Node lane

You get the same governance in your Node app, with no Python sidecar. @watchlight/sdk runs the same compiled engine in-process, as WebAssembly.

npm install @watchlight/sdk     # Node >= 18, no native toolchain

The five-minute DENY is in the quickstart, side by side with Python. This page is what the lane gives you after that.

Everything below has a Python equivalent under a snake_case name.

Govern a tool

import { govern } from "@watchlight/sdk";

govern.allow('permit(principal, action == Action::"research", resource);');

const search = govern.tool(webSearch, {
intent: "research",
principal: (q) => `User::"${q.userId}"`, // who the call is for
resource: "index/web",
context: (q) => ({ tier: q.tier }), // what a policy reads as context.*
});

await search({ userId: "u1", tier: "pro" }); // Allow runs the body; anything else throws Denied

Each option is a fixed value or a function of the call. Omit them all and the agent is the subject, the resource is tool/<name>, the context is empty. govern.tool is a higher-order function, not a decorator, and returns an async function. It fails closed: with no matching policy, every call is denied.

Ask a human first

A policy annotated @enforcement_effect("require_approval") yields a third verdict, NeedsApproval, and a single-use approval token.

const wire = govern.tool(transfer, { intent: "wire", onNeedsApproval: askOps });

By default that token is signed with a random per-process key and marked used in an in-process map. It cannot cross a process boundary, a restart invalidates outstanding approvals, and behind two replicas the same token is consumable once on each. Two options fix that:

  • approvalSecret makes a token portable. So does the signingSecret, which covers both kinds of token — see the signing secret.
  • approvalStore makes single use hold across replicas. It needs one method, add(id, expiresAt), which must be an atomic check-and-set: reserve the id only if absent, and report whether the reservation was new.

A store that fails, times out, or will not report refuses the approval.

The reservations are yours. The SDK never deletes one. expiresAt is the epoch-millisecond deadline after which an id is safe to drop, so give the row a TTL. Or implement the optional prune(before), which the SDK calls opportunistically alongside a reservation. A failing prune never moves a decision. Full contract: approval tokens.

Govern what a tool returns

const read = govern.tool(readDoc, {
intent: "read",
onResult: (doc, { obligations, decisionId }) => redact(doc, obligations),
onResultTimeoutMs: 8_000,
});

onResult runs after the body and before the caller sees the result. Sanitize, screen, honour the decision's obligations, or re-authorize on the payload. A returned value replaces it; a throw withholds it. Either way a value-free egress record is written, joined to the decision by decision_id.

The hook is bounded. onResultTimeoutMs defaults to 8 seconds, on govern.tool(), governTool / governTools and governedHooks alike. Outrun it and the payload is withheld exactly as a throw withholds it — EgressTimeout, withheld: true — and a hook that settles later is discarded. The deadline cannot be switched off; a hook that needs longer takes a larger number.

Python enforces it on an async tool body only. It cannot interrupt a synchronous hook, so on_result_timeout_ms on a synchronous body raises TypeError rather than being ignored.

Read the obligations on an Allow

const d = await govern.authorize({ action: "read", resource: "doc/1" });
d.obligations; // { redact: ["ssn"], maxItems: 25 } — only the keys a policy set

A permit annotated @obligate_redact("ssn"), @obligate_max_items("25"), @obligate_log_values("false") — or any @obligate_<name>("raw") — attaches constraints your code or onResult must honour. Several carriers merge to the strictest reading. Only an Allow carries them, and an unreadable obligation fails closed with AuthorizeError. Obligations need engine >= 0.2.0. See obligations.

Frameworks

const { hooks } = governedHooks({ intentFor: (name) => TOOL_INTENTS[name] ?? name });   // Claude Agent SDK
const tools = governTools(myTools, { intentFor: (name) => TOOL_INTENTS[name] ?? name });

governedHooks gates every Claude Agent SDK tool call with a PreToolUse hook and, given onResult, a PostToolUse hook over the raw output. governTool / governTools return a governed view of a LangChain StructuredTool — which is what LangGraph.js tools are — without mutating the original. @langchain/core is a peer dependency.

Each takes the same governance terms as govern.tool()principal, agent, resource (resourceFor on the mapping forms), context, onNeedsApproval, onResult, onResultTimeoutMs — so a policy reaches the same verdict through an adapter as through a hand-written governed tool. What each adapter can express is in the adapter table.

A single shared resource is deliberately not offered on the mapping forms: one resource for every tool would collapse them onto one anchor. Return undefined from resourceFor to keep a tool's tool/<name>.

Strip PII, and screen what comes back

const clean  = govern.sanitize(text, { resource: "doc/1", decisionId, principal });
const vetted = govern.screen(text, { resource: "doc/1", decisionId, principal });

sanitize strips structured PII before an agent reads a document — EMAIL, PHONE, SSN, CREDIT_CARD (Luhn), IBAN, IPV4, API_KEY, labelled PASSPORT and DOB — plus a known dictionary you supply and opt-in PERSON / ADDRESS heuristics. Modes are tag (<EMAIL_1>, the default), mask and hash. Extract a document to text first; never hand the agent the original PDF.

screen flags or redacts prompt-injection shapes before text an agent did not write reaches the model. It is rule-based, not a classifier:

FamilyCatches, for example
INSTRUCTION_OVERRIDE"ignore all previous instructions", "new instructions:"
ROLE_SWITCH"you are now a …", "act as an …"
PROMPT_EXFILTRATION"reveal your system prompt", "repeat everything above"
JAILBREAK_MARKER"DAN mode", "Developer Mode enabled"
AUTHORITY_IMPERSONATION"as your administrator…", "you have been granted admin privileges"
HTML_INJECTION<script>, <iframe>, on*= handlers, javascript: URLs, hidden elements
PROMPT_LEAKoutput side: "my system prompt is…", "System prompt:"

registerScreenFamily adds a shape the generic set does not cover — a forced approval, a skipped check, a job title in place of an AI role — under a label of your own:

registerScreenFamily("INJ_FORCE_APPROVAL", /\b(?:approve|mark) this .{0,30}immediately\b/);

It is on by default, counted and redacted like a built-in, and selectable through families. Write the pattern against normalized text: zero-width characters are removed and whitespace runs collapsed before any rule runs, so use single spaces and no \s+. A pattern that backtracks catastrophically is refused at registration — a screening rule runs over every submission — and once anything is registered detectorVersion carries a digest of the set.

registerDetector adds an identifier the built-ins do not know — an alien registration number, an internal case reference — under a label of your own:

registerDetector("ALIEN_NUMBER", /\bA[- ]?\d{8,9}\b/);   // at start-up

It is on by default and tags like any other (<ALIEN_NUMBER_1>). A pattern that backtracks catastrophically is refused there and then, because one (a+)+ in a detector hangs every call that scans a document. A built-in label cannot be replaced. Once anything is registered, detectorVersion carries a digest of the set, so an audit record says what was screening.

decisionId joins the audit line to a decision. principal names whose data it was. Omit principal and the line names this agent instead, as Agent::"<name>" — so pass it when a data-minimisation audit has to name the person. Both are identifiers you supply, never derived from the content, and both are validated: 1–128 characters, no control characters.

Worth knowing:

  • Both reports are value-free — counts, modes and flagged, never the matched text or the input — and both fail closed (SanitizeError, ScreenError).
  • redact marks the trigger; it does not neutralise HTML. Strip markup to text first if the model must not see it.
  • Markers in the input can be spoofed. Decide from the report, never from markers in the text.
  • Screening is robust to case, whitespace and zero-width characters, not to leetspeak, homoglyphs, encodings or paraphrase. Treat flagged as a signal to route, refuse or log — not as a verdict.
  • Run govern.screen(reply) on what the model produced to catch a system-prompt leak before it leaves.

Count against a quota

Cedar is stateless, so a quota needs a number in context. govern.counters() folds the local .watchlight/audit.jsonl into one; counterSource folds your own durable store instead, which is what survives a deploy or a second replica.

const readDoc = govern.tool(fetchDocument, {
intent: "read",
principal: (o) => `User::"${o.userId}"`,
context: async (o) => {
const c = await govern.countersAsync({ principal: `User::"${o.userId}"`, intent: "read", window: "1h" });
return c.truncated ? {} : { reads_this_hour: c.count }; // truncated = lower bound → let the policy deny
},
});

An async source needs the async binding: calling the synchronous counters() against one raises CounterSourceError rather than quietly answering from the local file. The full contract is in the reference.

Attenuate, and graduate

const root  = await govern.scope({ tools: ["read", "write"] });
const child = root.attenuate({ tools: ["read"] }); // strictly a subset
const token = child.toToken(); // carry it to a worker

govern.scopeFromToken() rebuilds it on the far side, and the receiving engine re-proves the subset. Subsets are enforced up to depth DE_MAX_DEPTH (5); see scope attenuation. A token needs a shared signing secret at both ends.

Setting WATCHLIGHT_APDP_URL graduates the same code to the networked control plane — no policy or code change. govern.mode reports "in-process" or "networked", and networked mode is fail-closed. See the comparison.

Test the policies

const report = await govern.test([
{ name: "under limit allows", action: "book",
context: { amount: 200, limit: 500, refundable: true }, expect: "Allow" },
{ name: "big wire needs a human", action: "wire",
context: { amount: 5000 }, expect: "NeedsApproval" },
]);
if (report.failed) throw new Error(`${report.failed} policy tests failed`);
npx --package @watchlight/sdk watchlight policy test suite.json

The --package flag is not optional. The binary is named watchlight but ships inside @watchlight/sdk, and an unrelated package holds the bare name on npm. Full guide: testing and rolling out policies.

Upgrading

Every change that can flip a verdict or raise where it used to return is in breaking changes.

See also