Field Note · Cybersecurity

Guarding AI Agent Actions With Typed Security Decisions

JEV Sentinel returns typed verdicts for agent prompts, tool calls and results: deterministic rules first, model judgment can only add vetoes.

Published
Status
Research

Coding agents now run shell commands, edit files, call MCP servers and read web pages on my behalf. Every one of those steps is a place where text the agent did not write can steer what it does next. JEV Sentinel is my attempt at a small, inspectable layer that sits on the hook points these agents already expose, looks at each proposed action or returned result, and produces a typed verdict that the host can use to veto the step. It does not grant anything. The host’s own approval prompts and sandbox stay in charge.

The project installs into nine agent harnesses (OpenClaw, Hermes, OpenCode, Codex, Claude Code, Pi, Gemini CLI, Cursor and GitHub Copilot CLI) and routes all of them through one shared Python evaluator. That evaluator combines a few deterministic rules with an optional, opt-in model judgment from TypeSafe’s Jev API, and writes a metadata-only audit record for every decision.

Status: an executable reference implementation, MIT licensed. Its tests exercise the code, the installer and simulated host contracts. They do not show that it activates correctly inside every real harness, and no detection accuracy is claimed. Treat it as experimental.

Problem

The threat model

I care about four failure modes when an agent has tools:

  • Unwanted tool calls. The model proposes a call that is outside what the user asked for. Sometimes that is a bad plan. Sometimes something told it to.
  • Prompt injection through tool results. A file, a web page, an MCP response or a RAG passage contains text such as “ignore previous instructions”. The agent reads it as data, but the model may treat it as instructions.
  • Destructive commands. rm -rf, force pushes, dropping tables. These are hard to undo.
  • Data exfiltration. Credentials or private context leave the machine, for example a curl that posts an SSH key to a remote host.

A fifth one shows up in the code as well: memory poisoning, where external content tries to write persistent instructions that later turns will trust.

I want to be clear at the start about how much of that list Sentinel covers by itself, because it is less than the list suggests. The local rules are a handful of regular expressions. There is no destructive-command rule at all. Unless you deny a tool by name in denied_tools, an rm -rf gets through the local rules with no extra veto, as I show further down. The project’s own README calls the local rules “a small illustrative detector” and says they “are not a comprehensive prompt-injection defense”.

Solution

The main design idea is a split between model judgment and deterministic code, the same one behind Building Auditable Agents With Typed AI Decisions. The model answers narrow, typed questions. Deterministic code decides what those answers are allowed to change, and the answers can only add restrictions, never remove them.

Typed verdicts: DEFER, REVIEW, BLOCK, QUARANTINE

The decision is one of four strings. The Python wrapper, the Node bridge and the Hermes plugin all reject a reply whose decision is outside that set or whose enforced is not a boolean.

  • DEFER: Sentinel adds no veto. It does not mean “safe” or “allowed”.
  • REVIEW: needs a human or a restricted reviewer. In enforce mode this is a veto, routed to security_review.
  • BLOCK: a veto, routed to administrator.
  • QUARANTINE: used when the suspect content came from an external source. It is a veto too, and on hosts that support replacement the content is swapped out.

The obvious shape for a guard like this is allow / deny / escalate. I deliberately left “allow” out. A guard that can say “allow” will eventually be read as a grant, so DEFER is the closest thing to it. The verdict JSON also carries enforced, reason_codes, route, backend, probabilities (empty for the local backend, so no fake scores) and a random incident id.

Architecture

Where Sentinel intercepts

Each harness has its own hook names and its own response format, so there is one adapter per harness. Claude Code and Codex use UserPromptSubmit, PreToolUse and PostToolUse. Gemini CLI uses BeforeAgent, BeforeTool and AfterTool. OpenCode uses tool.execute.before and tool.execute.after. OpenClaw, Hermes and Pi use their plugin or extension callbacks.

Every adapter turns the native payload into the same eight-field event: stage, source, harness, profile, session_id, tool_name, tool_input and content. The adapter sets source from the hook type, not from the payload: a user prompt is user, a proposed tool call is agent, a tool result is external. If an event has missing or extra fields, it is rejected rather than guessed at.

The evaluator runs in a separate Python process with a 6-second watchdog. The JavaScript and Hermes adapters call it through a small bridge with an 8-second budget. No HTTP listener is involved.

Not every hook can actually stop something. A pre-tool hook can veto a call before it runs. A post-tool hook only sees the result after the command has executed. On some hosts (OpenCode, Pi, and Claude Code for MCP tools and known-shape Bash results) Sentinel can replace the returned output, so the model never sees the suspect text. On others (Hermes post-tool, OpenClaw after-tool, Cursor post-tool, Copilot ingress and post-tool) it can only observe. The README has a per-harness table, and I’d read it before relying on any specific path.

Diagram of the JEV Sentinel decision path: a native hook event is normalized, passes ordered deterministic checks, optionally gets typed Jev probability answers, and produces a verdict JSON, a harness-specific veto shape and a metadata-only SQLite outbox record.
How a verdict is produced, simplified from core.py, hooks.py and cli.py at commit dee8f0b. Diagram I drew from the source.

Implementation

Native hook responses

The same restraint shows up when a verdict is turned into a native hook response. For Claude Code and Codex, a DEFER returns an empty object. It never returns permissionDecision: "allow", because that would skip the host’s own permission prompt:

# jev_sentinel/hooks.py
    if harness == "copilot":
        return {"permissionDecision": "deny", "permissionDecisionReason": reason} if veto and stage == "tool_before" else {}
    if not veto:
        return {}  # Never return permissionDecision=allow in Claude/Codex.
    if harness == "gemini":
        return {"decision": "deny", "reason": reason}

Cursor is the exception. Its hook format requires a permission field, so the adapter returns "allow" there. The README says this means only “no additional hook veto”, not a bypass of Cursor’s own authorization.

Deterministic rules first, model judgment second

The core of assess() is an ordered chain. The first deterministic condition that matches sets the decision. The model is consulted only when none of these conditions matched and the pattern rules below did not already produce a BLOCK:

# jev_sentinel/core.py
    # Hard policy denies always win over Jev, regardless of its probabilities.
    if event["tool_name"] and event["tool_name"] in policy["denied_tools"] and event["stage"] == "tool_before":
        decision, reasons = "BLOCK", ["operator_denied_tool"]
    elif event["stage"] == "memory" and event["source"] != "user":
        decision, reasons = "BLOCK", ["untrusted_memory_write"]
    elif len(text.encode()) > policy["max_content_bytes"]:
        decision, reasons = "REVIEW", ["content_limit"]
    elif "JEV_SENTINEL_TEST_BLOCK" in text:
        decision, reasons = "BLOCK", ["installation_test_canary"]
    elif tainted and sensitive:
        decision, reasons = "REVIEW", ["tainted_session"]

A few of these are worth explaining:

  • Operator-denied tools are an exact-name list in the policy file. This is the only built-in way to stop a whole class of destructive tool.
  • Memory writes from anything other than a user source are blocked. In the SDK, the trusted caller has to pass authorized_by_user=True explicitly. That flag has to come from host code, never from a document or a model.
  • Oversize content goes to REVIEW. I didn’t want it silently truncated and then judged on the half that happened to fit.
  • The taint latch covers the case where a finding at ingress, on a tool result, on retrieved context or on a memory write marks the session in SQLite. After that, every sensitive action in the session (any non-read-only tool call, memory write or egress) goes to REVIEW until an operator clears it. A new user message does not clear it.

If nothing above matches, four regular expressions look for instruction overrides, spoofed system or developer markers, requests to reveal the system prompt, and “remember to ignore” style persistent instructions. A hit gives QUARANTINE for external content and REVIEW otherwise. One more illustrative rule blocks a tool_before event that combines curl, wget or Invoke-WebRequest with strings like API_KEY or .ssh/id_. The code comment calls it “an illustrative rule, not a general egress authorization proof”, and that is accurate.

The optional Jev judgment

With backend=jev, the evaluator sends the event to TypeSafe’s Jev model as a set of typed questions: instruction override, authority spoofing, data exfiltration, tool hijack, memory poisoning and, when a user goal is known for a pre-tool event, action misalignment. Every answer has to come back as a typed probability. The reply is checked strictly:

# jev_sentinel/core.py
    answers = reply["answers"]
    if set(answers) != set(questions):
        raise ValueError("missing_or_extra_jev_answers")
    values = {}
    for name, value in answers.items():
        if not isinstance(value, dict) or value.get("type") != "noul":
            raise ValueError("invalid_jev_answer_type")
        number = value.get("noul")
        if type(number) not in (int, float) or not math.isfinite(number) or not 0 <= number <= 1:
            raise ValueError("invalid_jev_probability")

Before that, it also checks that the reply names the pinned model version (jev-1.13.0 by default). It refuses redirects, ignores environment proxies, and stays within a daily call budget (500 calls per UTC day by default). A probability at or above 0.80 gives BLOCK, or QUARANTINE for external content. At or above 0.35 a DEFER becomes REVIEW. Any exception, whether a timeout, a malformed reply or an exhausted budget, turns a DEFER into REVIEW and adds jev_unavailable_or_invalid. A low score never removes a deterministic finding.

Remote evaluation only happens when three things are all true: the backend is set to jev, TYPESAFE_API_KEY is present, and JEV_SENTINEL_ALLOW_REMOTE=1 is set. Setting a key alone does nothing. Content and tool arguments go through a best-effort redaction pass for private keys, common token formats and password=-style pairs before they are sent, and the docs say plainly that this is not a DLP guarantee. SECURITY.md calls the 0.35 and 0.80 thresholds “policy starting points, not calibrated production security thresholds”.

Audit records and the incident outbox

Every assessment, including DEFER, writes a row to a local SQLite database in the state directory. The row deliberately leaves out the raw prompt and the tool arguments:

# jev_sentinel/core.py
    def audit(self, result: dict, event: dict):
        # NO content, tool arguments, paths, credentials, or raw model text in audit.
        summary = {k: result[k] for k in ("id", "decision", "enforced", "reason_codes", "route", "backend", "session_ref")}
        summary["harness"] = event["harness"]
        summary["stage"] = event["stage"]
        summary["content_sha256"] = hashlib.sha256(event["content"].encode()).hexdigest()
        summary["action_sha256"] = hashlib.sha256(dumps(event["tool_input"]).encode()).hexdigest()

Anything other than DEFER is marked pending. launch.py outbox lists pending incidents. launch.py dispatch --url ... --allow-network posts them to an HTTPS endpoint that the operator chooses, with an Idempotency-Key header and an optional HMAC-SHA256 signature. Nothing is sent automatically. There is no email, Slack or webhook unless you run that command, and no reviewer agent is started.

Retention has limits. Event metadata is kept for seven days, up to about 20,000 events. User-goal snapshots, which do contain redacted user text of up to 4,096 bytes, are cleared after 24 hours. The docs also point out that a SHA-256 of low-entropy content can sometimes be reversed by guessing, so a fingerprint is not anonymization.

Fail-open or fail-closed? It depends on where it fails

The answer differs by layer. As implemented at this commit:

  • Shadow mode (the installed default) fails open on purpose. Every verdict is recorded with enforced: false and nothing is vetoed. A failed assessment in shadow mode is also recorded and not enforced.
  • Evaluator errors in enforce mode fail closed. If the worker crashes, times out or returns something outside the schema, the wrapper returns REVIEW, which is a veto in enforce mode.
  • A missing or corrupt policy file fails closed, even if it was meant to be in shadow mode:
# jev_sentinel/cli.py
def is_enforced(policy_path: Path):
    try:
        return load_policy(policy_path)["mode"] == "enforce"
    except Exception:
        return True  # A corrupt/missing policy must not silently disable a gate.
  • Remote model failures fail toward review. Any Jev error turns a DEFER into REVIEW, and a deterministic finding is kept.
  • The host can still fail open. If the harness skips the hook, unloads the plugin, times out before Sentinel answers or never sends a particular tool path through the hook, nothing local can help. The launcher’s own comment says it: “The underlying harness may still fail open if it kills this launcher.”
  • Post-tool checks cannot undo anything. By the time a result is inspected, the command has already run.
  • The taint latch is not serialized. Concurrent tool calls can start before a post-tool finding is written to SQLite.

Evidence

Everything in this section was run on 2026-10-21 against commit dee8f0b.

I cloned the repository at commit dee8f0b and ran both test suites on Windows 11 with Python 3.14.3 and Node 24. The Python suite ran 66 tests: 65 passed and 1 was skipped. The skipped test checks that symlinked destinations are rejected and is marked to skip on Windows. The Node suite ran 9 tests and all passed. The repository’s own test report records 66 Python and 9 JavaScript passes on Linux and says the included CI workflow had not been run at that point. I did not check the GitHub Actions history.

Those tests use mocked Jev responses and simulated host callbacks. One test does exercise a real Node-to-Python subprocess bridge. No real harness is launched, and no live Jev call is made.

I also pushed ten hand-written synthetic events through launch.py check with temporary policy files, using the local backend and no network:

Table of ten synthetic events and Sentinel verdicts: benign ls and rm -rf both DEFER, a curl posting an SSH key is BLOCK, injected tool output is QUARANTINE, the next tool call in that session is REVIEW, an untrusted memory write is BLOCK, and failure cases return REVIEW.
Synthetic events I ran locally against the local backend. This shows routing behavior, not detection accuracy.

The routing works as described. External injection text was quarantined and latched the session, the next ls in that session went to review, and the failure cases came back as REVIEW. Row 2 matters as much as the others: rm -rf /tmp/x came back as DEFER, because nothing in the local rules looks for destructive commands. The bundled offline RAG example also ran as expected: it kept one benign passage and quarantined one injection-looking passage. That is a two-passage plumbing demo, not an evaluation. No prompt-injection benchmark, adaptive attack suite or penetration test has been run against this code.

Limitations

  • Reference implementation, not a product. The README describes it as “executable reference implementation”. Native hook contracts were checked against upstream documentation on 2026-09-19, not against every installed binary version.
  • A sensor and veto layer, not a sandbox. In its own words, it is “not a complete reference monitor”. Keep the host’s approvals, sandbox and least-privilege credentials in place.
  • The local rules are thin. There are four injection regexes and one egress example. They can miss obfuscated, multilingual, indirect or multi-step attacks, they can flag harmless quoted examples, and they have no destructive-command coverage.
  • Model judgment is unevaluated. No accuracy is claimed for Jev in this setting, the thresholds are uncalibrated starting points, and enabling remote mode sends selected content to a third-party API under that provider’s terms.
  • Same-user attacks. An agent with a shell running as the same OS user can edit the policy, the runtime or the hook configuration, and can read the environment. Strong isolation needs a separate-identity tool broker and a read-only control plane, and the installer does not provide either.
  • Coverage gaps. Hosted tools, subagents, attachments, shell shortcuts, failed-tool paths and later plugin rewrites may bypass a given hook. Several adapters (OpenClaw, Hermes, OpenCode) capture no user-goal snapshot. No image, audio or video scanning is included.
  • Platform verification. Windows command construction is unit-tested, but native Windows and macOS hosts were not exercised in the original build. My Windows run covered the test suites, not real harness activation.
  • Audit durability. A catastrophic bridge or database failure can produce a veto with no durable audit row, and there is no heartbeat or supervisor.

Resources

If you are putting agents near production systems and want to work out which actions need a hard deny, which need review and what should be logged, CompleteTech helps with website security and with taking an approach like this from idea to solution.

JEV Sentinel is published by the CompleteTech LLC AI Research organization on GitHub. The commit history at the time of writing lists Timothy Wayne Gregg as the committer, and the license names “JEV Sentinel contributors”.

Related CompleteTech services

Discuss a similar project
Written by

Timothy Gregg · Founder, CompleteTech

Enterprise IT operations and ITSM implementation on ManageEngine and Microsoft platforms, now focused on AI automation and systems engineering. Presented at the 2023 ManageEngine user conference.