DEV Community

Doug Sillars for Cognous

Posted on

Agentic Guardrails for LangChain: The Manifest You Didn't Know You Needed

Guardrails, or the lack of them

In July 2025, a Replit AI coding agent deleted a production database during a live coding session — despite explicit instructions not to touch production. It wasn't a hack or an outage. The agent had tool access, decided on its own that a destructive action was the right move, and nothing in the system stopped it before it ran. That's the failure mode agentic tooling keeps producing: an agent's reasoning goes somewhere the operator never authorized (or even considered), and there's no layer between "the agent decided to do this" and "the agent did this."

The Open Control Stack is Cognous's answer to that gap — an open-source framework that sits between an agent's decision to act and the tool actually executing, enforcing exactly what the agent is and isn't allowed to do.

If your agents run on LangChain, the obvious question is how that enforcement actually reaches them. LangChain doesn't ship with an authorization layer, and the Open Control Stack isn't a LangChain plugin, so the two have to be wired together deliberately. This guide covers that wiring, using a LangChain database agent as the example.

Why LangChain gets to decide

LangChain's tool-calling agents are built around a simple loop: give the model a set of tools with names, descriptions, and argument schemas, and let the model decide — turn by turn — which tool to call and what arguments to pass. bind_tools is how a tool gets attached to the model in the first place; create_agent is what wires that model, its tools, and the call-and-respond loop between them into a runnable agent. These tools have become the default because they're model-agnostic — the same tool definitions work across the most popular LLMs: OpenAI, Anthropic, and other providers. And they turn "the agent should be able to look things up and take action" into a few lines of code instead of a hand-rolled parser.

That convenience is also the gap. Once a tool is bound to the agent, there's no further policing. Say the agent has write access to a database. LangChain doesn't know the difference between the "helpful work" of adding columns and reindexing data, or the "unhelpful work" of the agent deciding, mid-task, that dropping the customer table is the best solution. As far as the framework is concerned, the call matches the tool's schema, and that's the whole contract. When LangChain holds the reins, it holds them exactly as far as "did I call a function that exists with arguments that match its schema" — and no further.

Where the Open Control Stack fits

The Agent Action Manifest is the first of Cognous' Open Control Stack's four layers:

  • Declare — what the agent is allowed to propose, before it runs (the Agent Action Manifest)
  • Control — what actually happens at runtime when the agent proposes an action (the Agent Control Plane)
  • Replay — a record of what was proposed, decided, and executed, packaged for later inspection
  • Evidence — that record compiled into something an auditor or compliance reviewer can actually read

This guide stays in the Declare layer, building a manifest for the database agent from the example above: one declared action, and everything else left undeclared on purpose.

Declaring the manifest

The Agent Action Manifest is a JSON document that declares, outside of the agent's own code or prompt, what the agent is allowed to do: which tools it can call, what each specific action requires, and what happens by default when it tries something nobody wrote down. It's tool-agnostic: what's written for LangChain here will work for any other framework.

The reason to keep this separate from the agent's own code is auditability: a reviewer can read the manifest and know exactly what an agent is permitted to do without tracing through prompts, tool definitions, and application logic to reconstruct the answer. It's also why the manifest gets declared and validated on its own, before any LangChain code touches it — the two aren't defined together, so nothing about the agent's permissions depends on how its orchestration framework happens to be wired.

A manifest action needs, at minimum, a name, the tool it belongs to, and a review_requirement. Here's the add-column action — safe, so it runs with no review:

{
  "action_name": "schema_add_column",
  "tool_name": "db_tool",
  "action_type": "write",
  "description": "Add a nullable column to an existing table.",
  "authority_required": [
    {
      "scope": "db.schema.write",
      "description": "Permission to modify the application database schema.",
      "required": true,
      "source": "data-platform-team"
    }
  ],
  "review_requirement": {
    "mode": "none"
  },
  "payload_policy": {
    "required_fields": ["table", "column", "column_type"],
    "optional_fields": ["default_value"],
    "forbidden_fields": []
  }
}
Enter fullscreen mode Exit fullscreen mode

But what about the stuff nobody can even imagine the agent might try — the things that could potentially destroy the company? There's no need to enumerate all the bad things, like table_drop. The manifest sets a default posture for everything not explicitly defined:

"default_action": "escalate"
Enter fullscreen mode Exit fullscreen mode

Anything the agent tries that isn't declared falls back to that posture. If the escalation reveals actions that need to be enumerated in the manifest, they get added. But this defensive posture prevents agents from creating catastrophic events.

Validating the manifest

aam is the Agent Action Manifest CLI — it checks a manifest file against the schema before anything downstream trusts it. Running aam validate against the full file confirms it's well-formed before any LangChain code touches it:

$ aam validate data_pipeline_agent.manifest.json
Validation result: VALID
Manifest ID:       data-pipeline-agent-v1
No issues found.
Enter fullscreen mode Exit fullscreen mode

With that in place, the question becomes how to get LangChain to actually consult it instead of just checking that a function call matches its schema.

What the manifest gives you

A manifest action carries a couple of fields that matter for enforcement — payload_policy.required_fields and review_requirement.mode.

The piece that's missing is a guard: a wrapper placed around each tool call that checks the manifest before the tool's own code runs. A guard checks three things before the underlying function executes — is the action declared at all, does the call have what payload_policy requires, and does review_requirement allow it to run immediately. Everything else in the manifest (authority scopes, redaction hints, reliance requirements) belongs to later stages in the stack — this integration only needs those three to decide allow, block, or escalate.

The one thing you add

A decorator that sits between the LangChain @tool wrapper and the function body:

import functools
from langchain_core.tools import tool
from agent_action_manifest import load_manifest

manifest = load_manifest("data_pipeline_agent.manifest.json")

actions_by_tool = {}
for action in manifest.actions:
    actions_by_tool.setdefault(action.tool_name, []).append(action)


class ManifestBlock(Exception):
    pass


def guard(tool_name: str, action_name: str):
    """Check a manifest action before the wrapped tool function runs."""
    action = next(
        (a for a in actions_by_tool.get(tool_name, []) if a.action_name == action_name),
        None,
    )

    def decorator(func):
        @functools.wraps(func)
        def wrapped(*args, **kwargs):
            if action is None:
                raise ManifestBlock(
                    f"{tool_name}.{action_name} isn't declared in the manifest — "
                    f"default posture is '{manifest.default_action.value}'"
                )

            payload = kwargs if kwargs else (args[0] if args else {})

            policy = action.payload_policy
            if policy:
                missing = [f for f in policy.required_fields if f not in payload]
                if missing:
                    raise ManifestBlock(f"{action_name}: missing required fields {missing}")

            review = action.review_requirement
            if review and review.mode.value != "none":
                raise ManifestBlock(
                    f"{action_name}: requires {review.mode.value} before it can execute "
                    f"({review.reason})"
                )

            return func(*args, **kwargs)
        return wrapped
    return decorator
Enter fullscreen mode Exit fullscreen mode

Apply it to a tool the same way you'd apply any other decorator:

@tool
@guard("db_tool", "schema_add_column")
def schema_add_column(table: str, column: str, column_type: str) -> str:
    """Add a nullable column to a table."""
    return f"added column {column} ({column_type}) to {table}"


@tool
@guard("db_tool", "table_drop")
def table_drop(table: str) -> str:
    """Drop a table from the database."""
    return f"dropped {table}"
Enter fullscreen mode Exit fullscreen mode

Running it

Two calls: one declared and safe, one that was never written into the manifest at all:

schema_add_column.invoke({"table": "customers", "column": "loyalty_tier", "column_type": "text"})
# "added column loyalty_tier (text) to customers"

table_drop.invoke({"table": "customers"})
# ManifestBlock: db_tool.table_drop isn't declared in the manifest —
# default posture is 'escalate'
Enter fullscreen mode Exit fullscreen mode

schema_add_column runs because it's declared with review_requirement.mode: none. table_drop never appears in the manifest — there's no entry to check review_requirement on — so the guard falls back to manifest.default_action and blocks it. Nobody had to anticipate table_drop specifically. It's blocked by omission, which is the only kind of coverage that scales.

What this doesn't do

This guard blocks or allows synchronously, in-process. It doesn't route a blocked call to an actual approval queue, and it doesn't record anything for later review — that's the Agent Control Plane's job, not the manifest's.

Extending the guard to hand decisions off to the Control Plane instead of just raising an exception is a natural next step — one that turns each allow or block into a recorded, traced, replayable decision instead of a one-off exception. That's its own guide, and will be covered in an upcoming post.

What this guide covered

This guide wired the Agent Action Manifest into a LangChain agent's tool-calling layer to give it an allowlist of what it's actually permitted to do. That's what stops an agent from going off script and "helping" by dropping a table — the manifest blocks the call and escalates to a human before anything irreversible happens.

The manifest and the Agent Action Manifest CLI can be found on GitHub. More on the Open Control Stack, and how it functions as agentic guardrails, can be found at cogno.us.

Top comments (0)