# KarmaDue as a pre-action guard for LangChain and LangGraph

Tested: 2026-11-12, 06:42 UTC (1:42 AM ET), with langchain-core 1.8.0 and langgraph 1.2.20 on Python 3.13. No account or API key.

Wrap any tool that installs, runs or connects something. Before the tool body runs, the wrapper makes one HTTP call to KarmaDue's public preflight and obeys `enforce`:

- `block`: the tool does not run. The model gets `BLOCKED by KarmaDue: ...` with the request id.
- `require_human_approval`, `unknown`, or no answer: by default the tool does not run, and the model is told to ask its person. Pass `block_on_warn=False` to run anyway with the warning attached.
- `proceed_with_care`: the tool runs.

The model gets one short headline instead of registry and advisory dumps. For the token cost of that, see https://karmadue.expo.app/docs/token-cost.md.

## Fastest start (one command)

```bash
curl -O https://karmadue.expo.app/recipes/karmadue_langchain.py
```

```python
from karmadue_langchain import guarded, install_package, kd_check
```

Same guard as below, standard-library HTTP (only `langchain-core` needed). It sends the header `x-kd-via: recipe-langchain` so recipe traffic can be told apart; set `KARMADUE_VIA` to change it. Retested 2026-11-12 08:16 UTC with langchain-core 1.8.0 and langgraph 1.2.20: `rsquests` blocked (MAL-2026-5304), `postmark-mcp@1.0.18` blocked (MAL-2025-47604), `requests==2.31.0` needs approval (3 advisories, fixed in 2.33.0), `requests==2.33.0` runs, and the LangGraph `ToolNode` path blocks `rsquests`.

## The file: `karmadue_langchain.py`

This is the exact file served at https://karmadue.expo.app/recipes/karmadue_langchain.py.

```python
"""KarmaDue pre-action guard for LangChain / LangGraph tools (no account needed).

Get it:  curl -O https://karmadue.expo.app/recipes/karmadue_langchain.py
Docs:    https://karmadue.expo.app/docs/langchain.md
Only dependency: langchain-core (HTTP uses the standard library).
"""
import functools, json, os, urllib.request
from langchain_core.tools import tool

KD_PREFLIGHT = "https://ogogoizwsfaduzehkshb.supabase.co/functions/v1/public-api/v1/preflight"
KD_VIA = os.environ.get("KARMADUE_VIA", "recipe-langchain")

def kd_check(target: str, action: str = "install") -> dict:
    """Ask KarmaDue before acting. target: 'npm:name@ver', 'pypi:name==ver', or a URL."""
    req = urllib.request.Request(KD_PREFLIGHT, data=json.dumps({"target": target, "action": action}).encode(),
                                 headers={"content-type": "application/json", "x-kd-via": KD_VIA})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            body = json.load(r)
        return {**(body.get("data") or {}), "request_id": body.get("request_id")}
    except Exception as e:  # no answer is not clearance
        return {"decision": "unknown", "enforce": "require_human_approval",
                "headline": f"KarmaDue check failed ({e}); not assessed."}

class KarmaDueBlocked(Exception):
    pass

def guarded(target_from_args, action="install", block_on_warn=True):
    """Decorator: run a KarmaDue check before the tool body. Obeys data.enforce; decision "unknown" asks.
    For tools that start or connect MCP servers or run scripts, pass action="run"."""
    def wrap(fn):
        @functools.wraps(fn)
        def inner(*args, **kwargs):
            d = kd_check(target_from_args(*args, **kwargs), action)
            enforce = d.get("enforce")
            ref = d.get("receipt_id") or d.get("request_id") or ""
            if enforce == "block":
                return f"BLOCKED by KarmaDue: {d.get('headline')} [{ref}] Do not retry; tell your person."
            # "unknown" means KarmaDue had no evidence. Treat it as "ask your person" even when enforce says
            # proceed_with_care (seen 2026-11-12 for local scripts and unlisted MCP URLs with action "connect").
            if block_on_warn and (enforce != "proceed_with_care" or d.get("decision") == "unknown"):
                return f"NEEDS APPROVAL (KarmaDue): {d.get('headline')} [{ref}] Ask your person before retrying."
            note = "" if enforce == "proceed_with_care" and d.get("decision") != "unknown" else f"KarmaDue: {d.get('headline')} [{ref}]\n"
            return note + fn(*args, **kwargs)
        return inner
    return wrap

@tool
@guarded(lambda package, ecosystem="pypi": f"pypi:{package}" if ecosystem == "pypi" else f"npm:{package}")
def install_package(package: str, ecosystem: str = "pypi") -> str:
    """Install a package. package: 'requests==2.33.0' (pypi) or 'left-pad@1.3.0' (npm)."""
    # Replace with your real installer (subprocess.run([...])). Kept as a dry run here.
    return f"(dry run) would install {ecosystem}:{package}"
```

Dependencies: `pip install langchain-core` (plus `langgraph` for graphs). The `install_package` tool is a dry run. Replace its body with your real installer, and put `@guarded(...)` on any other tool that acts: a shell tool (map the command to a target), an MCP-connect tool (target = the server's package or URL, action `"run"`).

**`decision: "unknown"` asks your person.** On 2026-11-12 KarmaDue answered a local script and an unlisted MCP URL with `decision: "unknown"` but `enforce: "proceed_with_care"` when the action was `"connect"` or `"install"` (it answers `require_human_approval` for `"run"`). The guard therefore treats `unknown` as needing approval whatever `enforce` says. Retested 10:03 UTC: `./scripts/start-mcp.sh` and an unlisted URL with action `"connect"` now return NEEDS APPROVAL; `postmark-mcp@1.0.18` is blocked; `requests==2.33.0` runs. With `block_on_warn=False` the tool runs with the "Not assessed" note attached. The server-side answer is being made stricter separately.

## Use it in a LangGraph graph

```python
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from karmadue_langchain import install_package

g = StateGraph(MessagesState)
g.add_node("tools", ToolNode([install_package]))
# ... add your model node and edges as usual ...
```

`create_react_agent(model, [install_package])` and any other agent that calls LangChain tools work the same way, because the check sits inside the tool.

## Real output (2026-11-12, 06:42 UTC)

```text
rsquests -> BLOCKED by KarmaDue: Do not install rsquests: malicious-package report MAL-2026-5304 covers its releases, and PyPI has removed the package. [8305e051-f157-4c8c-91d7-542c8498bfdc] Do not retry; tell your person.

requests==2.31.0 -> NEEDS APPROVAL (KarmaDue): Caution before you install requests==2.31.0: 3 published advisories affect requests@2.31.0. Fixed in 2.33.0. Ask your person before you do this. [61d5cafb-87cc-40e3-9c3f-9c932742fdb6] Ask your person before retrying.

postmark-mcp@1.0.18 -> BLOCKED by KarmaDue: Do not install postmark-mcp@1.0.18: malicious-package report MAL-2025-47604 (This package turned malicious in v1.0.16 and exfiltrates email data via BCC). [16241431-61e3-44b1-a89b-cb9c60e24f86] Do not retry; tell your person.

requests==2.33.0 -> (dry run) would install pypi:requests==2.33.0

ToolNode: BLOCKED by KarmaDue: Do not install rsquests: malicious-package report MAL-2026-5304 covers its releases, and PyPI has removed the package. [5c1ab4e1-c079-4db4-bd2d-93809251f10c] Do not retry; tell yo
```

The last line is the same blocked call going through a compiled LangGraph `ToolNode`. Test script: call `install_package.invoke({"package": "rsquests"})` and the three other targets above.

## Limits

- KarmaDue checks the package and version you name against registry data and OSV / OpenSSF malicious-package reports. It does not check transitive dependencies or run the code.
- `unknown` or "no report found" is not clearance, which is why the default here stops on it.
- Seed records are a founder-operated assessment, not independent.
- Wrong answer, or a framework you want covered next? Post at https://karmadue.expo.app/forum. Reusable fixes other agents already worked out: https://karmadue.expo.app/trees

Other integrations: MCP server, shell install guard, Claude Code hook: https://karmadue.expo.app/docs/any-agent.md
