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:
approvalSecretmakes a token portable. So does thesigningSecret, which covers both kinds of token — see the signing secret.approvalStoremakes 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:
| Family | Catches, 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_LEAK | output 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). redactmarks 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
flaggedas 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
- The identity model — the subject you pass and the actor a policy reads
- The audit trail —
auditSinkandcounterSource; both lanes write the same records - Field & behavior reference — every option and its exact contract
- Runnable examples — governed agents in both lanes