Record-first gateway, fail-closed policy, approvals, and audit — for any agent.
GhostGuard is the governance layer an agent's actions pass through before they happen. Every action an agent proposes is classified, decided by an executable policy, and written to an audit record first — and only then, if the verdict allows it, executed. A refusal is recorded with the rule that refused. An action that was not recorded did not happen.
It is framework-neutral: GhostGuard doesn't care whether the agent is a browser bot, a coding agent, a workflow runner, or a chat assistant. The integrator describes each action in GhostGuard's vocabulary, and GhostGuard decides and records. The agent brings the effects; GhostGuard brings the permission, the paper trail, and the brakes.
By Ghost Developer Studio. MIT licensed.
Agents that can click "submit," send email, spend money, deploy, and delete are useful precisely because those actions are consequential — and dangerous for the same reason. The usual failure modes:
- The agent acts first and the log (if any) is written after — or never, when the action crashes the process.
- "Approval" is a prompt the agent itself can route around.
- A typo'd or half-written safety rule silently stops matching, and the system fails open.
- Nobody can answer, afterwards, which rule allowed the thing that went wrong — or who (or what) actually started the run.
GhostGuard's answers are structural, not advisory:
- Record first, act second. The gateway record is assembled complete — intent, exact payload, verdict, deciding rule — and handed to your recorder before any effect runs. If the recorder throws, the action does not run.
- Fail closed, everywhere. No matching rule → deny. A missing or empty policy permits nothing. A broken restrictive rule (unparseable, unevaluable) denies everything and names itself; a broken permissive rule is inert — it never grants, but blocks nothing.
- Strictest verdict wins. Effects rank
deny > hand_off > ask > pre_approved > allow, evaluated by tier, never by document order. An allow rule appended later can never outrank a deny. - Approvals are data, not prompts. An
askverdict carries the exact payload that was refused; your approvals surface proposes exactly that payload for a human decision.pre_approvedis a standing approval whose bounds are the rule's own expression.hand_offroutes an action to a different approver or scope instead of executing it. - Locked rules. Rules marked
locked: trueare the team layer: merging an editable overlay policy can never replace, weaken, or shadow them. - Initiator ≠ actor. Every record distinguishes who proposed the action
(
actor) from what started the run (initiator: person, routine, handoff, memory, system) — stamped by trusted entry-point code, never by the proposer, so policy can hold unattended runs to a stricter standard than the same person at the keyboard. - Secret hygiene. The record shows that a secret was involved — its type and length — never its value. (In the reference implementation, redaction runs at the event-store write boundary, before signing, so no caller can bypass it.)
| File | What it is |
|---|---|
src/policy.ts |
The policy engine: a small hand-rolled evaluator for a CEL subset, tier-ordered verdicts, fail-closed broken-rule semantics, locked-rule merging, and a shipped default policy. Zero runtime dependencies. |
src/gateway.ts |
The gateway record contract: GatewayRecord, GatewayIntent, the ActionRecorder type, and gatewayRecordFromDecision() to assemble a complete record from an intent + a decision. |
src/recorder.ts |
The reference recorder: createJsonlRecorder(path) — an append-only JSONL ActionRecorder that throws on write failure, plus readGatewayRecords(path) to read a trail back. A working audit sink on day one. |
src/types.ts |
The shared vocabulary: PermissionClass (the consequence classes an integrator maps action kinds onto) and InitiatorKind. |
examples/ |
Two worked examples: a guarded CLI agent (npm run example:cli) and a browser-action classifier in the Jev-governor style (npm run example:browser). |
docs/reading-a-denial.md |
How to read a refusal: one real record, walked field by field. |
test/run.ts |
The acceptance suite — the same assertions the engine passes inside Ghost Kernel, plus gateway-contract and recorder tests. Run with npm test. |
integrations/jev/ |
The first outside integration (M2): a gate bridge the governed Jev browser agent calls per action, its scenario policy, and the integration note. Not part of the published package. |
You bring: the action effects themselves, and — if you use ask — an
approvals surface that shows a human the recorded payload and returns
their decision. A recorder is included (the JSONL reference above);
bring your own sink when the trail needs to live somewhere else.
npm install
npm test # acceptance suite (source)
npm run build # tsc emit to dist/ (JS + .d.ts)
npm run example:cli # a guarded agent: approve its asks with `y`
npm run example:browser # the classifier walkthrough (non-interactive)Hit a denial and want to know exactly why? Read
docs/reading-a-denial.md — it walks one
real record field by field.
Four steps, all in the integrator's existing action path:
1. Classify. Map each of your agent's action kinds onto a
PermissionClass:
type PermissionClass =
| "READ" | "WRITE_LOCAL" | "EXECUTE" | "NETWORK_WRITE"
| "DEPLOY" | "PUBLISH" | "COMMUNICATE" | "SPEND" | "DELETE";(Example: a browser agent might map navigation and page reads → READ,
form fills → WRITE_LOCAL, submit-capable clicks → NETWORK_WRITE. When
in doubt, classify up — unknown shapes are consequential.)
2. Write a policy. Rules are data. Each rule has an id, an effect,
and a when expression over the action context (kind,
permission_class, actor, project_id, approved, initiator,
payload.<field>; operators == != < <= > >= in && || !; string functions
startsWith, endsWith, contains, matches):
import { compilePolicy } from "ghostguard";
const policy = compilePolicy({ rules: [
{ id: "no-spend-ever", effect: "deny", locked: true,
when: "permission_class == 'SPEND'" },
{ id: "routines-ask-on-deploys", effect: "ask",
when: "initiator == 'routine' && permission_class == 'DEPLOY'" },
{ id: "docs-are-pre-approved", effect: "pre_approved",
when: "kind == 'fs.write' && startsWith(payload.path, 'docs/')" },
{ id: "comms-go-to-owner", effect: "hand_off", handoff_to: "owner",
when: "permission_class == 'COMMUNICATE'" },
{ id: "allow-the-rest", effect: "allow", when: "true" },
] });Or start from the shipped default — approval-required classes
(DEPLOY, PUBLISH, COMMUNICATE, SPEND, DELETE) ask, everything
else in the map allows — via defaultKernelPolicy(). Layer an editable
overlay over a locked base with mergePolicyDocs(base, overlay).
3. Decide and record — before acting.
import { evaluatePolicy, gatewayRecordFromDecision } from "ghostguard";
const decision = evaluatePolicy(policy, {
kind: action.kind,
permission_class: classify(action.kind),
actor: action.proposedBy,
initiator: run.initiator, // stamped by the run's entry point
project_id: run.projectId,
approved: approvalCovers(action), // a carried human approval, if any
payload: action.payload,
});
const record = gatewayRecordFromDecision({
action_id: action.id,
kind: action.kind,
permission_class: classify(action.kind),
actor: action.proposedBy,
initiator: run.initiator,
project_id: run.projectId,
action_payload: action.payload, // the exact payload that will run
}, decision);
recorder(record); // throws → the action does not run. No record, no action.4. Act on the verdict.
| Verdict | Meaning | Integrator behaviour |
|---|---|---|
allow |
Permitted. | Execute. |
pre_approved |
Permitted under a standing approval. | Execute; the record says which rule's bounds covered it. |
ask |
Needs a human first. | Do not execute. Surface the record's exact payload to your approver; execute later only under a carried approval. |
hand_off |
Not this approver's call. | Do not execute. Route the record to record.handoff_to. |
deny |
Refused. | Do not execute. The record names the rule that refused (record.rule_id; null only for the no-match fail-closed default). |
That's the whole contract. Everything else — how effects run, where records are stored, how approvals render — stays yours.
GhostGuard is an extraction, not a rewrite. The policy engine and gateway
record contract were built inside Ghost Kernel (Ghost Developer
Studio's cognitive-supervisor kernel), where they govern its Action Fabric
in exactly this shape, and were carried into this package with semantics
verified unchanged: src/policy.ts differs from the Kernel's reference
implementation only in its import path and an extraction note, and this
package's acceptance suite ports the Kernel's own engine-level assertions.
The design lineage runs through CopilotKit/OpenBot (MIT): its gateway
policy semantics — decide before acting, record the decision, fail closed
— were adopted into Ghost Kernel (documented in the Kernel's
OPENBOT_ADOPTION.md) and re-implemented there in TypeScript; GhostGuard
carries that implementation out as a standalone product. OpenBot's
proprietary Intelligence dependency was deliberately not adopted.
Portability proof: the same contract is implemented in Python by the
governor built for Jev Ultrafast (a browser agent) — a Kernel-shaped
policy document decides the same way in both runtimes, and Jev's
consequential actions (form submits, sends, purchases) now ask by default
instead of executing unguarded. Since Milestone 2, Jev can also skip
the port: with JEV_GOVERNOR_BACKEND=ghostguard, its loop delegates
each action to this package itself over the gate bridge in
integrations/jev/ — the first outside agent running GhostGuard
end-to-end, recorded by GhostGuard's own recorder.
Milestone 1 complete: ship-ready v0.1 — real build output (dist/,
JS + type declarations; the acceptance suite and a build smoke test
both run green, the smoke test against the build itself), the
reference JSONL recorder, two worked examples, and denial-reading
docs. Zero runtime dependencies; Node ≥ 18; ESM.
Milestone 2 complete: first external integration — the governed Jev
runs this package end-to-end as an opt-in governor backend (bridge,
scenario proof, and lessons in integrations/jev/ and PLAN.md
§8); the acceptance suite now covers the bridge contract too (9
checks).
Not yet published: the package is private: true and the publish
decision (public repo, npm) is the maintainer's, per PLAN.md.
See PLAN.md for the product plan.
MIT — see LICENSE. Copyright (c) 2026 Ghost Developer Studio.
Design lineage: CopilotKit/OpenBot (MIT), via Ghost Kernel.