{"id":51061915,"url":"https://github.com/hadihonarvar/lynx","last_synced_at":"2026-06-23T03:01:32.134Z","repository":{"id":363677100,"uuid":"1264416650","full_name":"hadihonarvar/lynx","owner":"hadihonarvar","description":"Framework-agnostic policy-gated durable execution for AI agents. Every tool call gets a YAML policy check, a checkpoint, and a hash-chained audit event.","archived":false,"fork":false,"pushed_at":"2026-06-18T06:11:53.000Z","size":567,"stargazers_count":8,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-18T08:11:56.924Z","etag":null,"topics":["agentic","agentic-ai","agents","ai","audit","claude","crewai","durable-execution","langgraph","llm","lynx","mcp","openai","policy","policy-engine","python","reliability"],"latest_commit_sha":null,"homepage":"https://github.com/hadihonarvar/lynx","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hadihonarvar.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-09T21:37:30.000Z","updated_at":"2026-06-18T06:11:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hadihonarvar/lynx","commit_stats":null,"previous_names":["hadihonarvar/gazelle","hadihonarvar/lynx"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/hadihonarvar/lynx","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hadihonarvar%2Flynx","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hadihonarvar%2Flynx/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hadihonarvar%2Flynx/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hadihonarvar%2Flynx/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hadihonarvar","download_url":"https://codeload.github.com/hadihonarvar/lynx/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hadihonarvar%2Flynx/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34673437,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-23T02:00:07.161Z","response_time":65,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agentic","agentic-ai","agents","ai","audit","claude","crewai","durable-execution","langgraph","llm","lynx","mcp","openai","policy","policy-engine","python","reliability"],"created_at":"2026-06-23T03:01:27.978Z","updated_at":"2026-06-23T03:01:32.124Z","avatar_url":"https://github.com/hadihonarvar.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Lynx\n\n[![PyPI](https://img.shields.io/pypi/v/lynx-agent.svg?v=2.11.0)](https://pypi.org/project/lynx-agent/)\n[![Python versions](https://img.shields.io/pypi/pyversions/lynx-agent.svg?v=2.11.0)](https://pypi.org/project/lynx-agent/)\n[![License](https://img.shields.io/pypi/l/lynx-agent.svg)](https://github.com/hadihonarvar/lynx/blob/main/LICENSE)\n[![CI](https://github.com/hadihonarvar/lynx/actions/workflows/ci.yml/badge.svg)](https://github.com/hadihonarvar/lynx/actions/workflows/ci.yml)\n[![Website](https://img.shields.io/badge/website-lynxharness.com-f5a623)](https://lynxharness.com)\n\n**A stateless, type-safe policy kernel for AI agent tool calls.**\n\n🌐 **Website (interactive feature tour): [lynxharness.com](https://lynxharness.com)**\n\nPure functions over immutable values. No database. No globals. No leaks. Five verdicts. Streaming events to user-owned sinks.\n\n**Lynx is the governance and safety layer for your agent's loop — not the loop itself.** Every iteration of an agent loop proposes a tool call; Lynx checks each one (`allow / deny / dry_run / approve_required / transform`), audits it, and keeps the loop bounded — *before* it touches the real world. It is not an agent framework and won't write your loop's logic. Use it two ways:\n\n- **Bring your own harness** (OpenAI Agents SDK, LangChain, CrewAI, PydanticAI) and drop `ToolGuard` into its tool calls — the framework drives the loop, Lynx governs each action inside it.\n- **Or use `run_agent`** as a minimal, stateless loop of your own.\n\nEither way you get the same five-verdict policy boundary on every action, plus the loop-control rails a harness needs: **budgets** (step/token/duration caps), a **kill-switch**, a **repetition gate** (breaks same-tool-same-args infinite loops), and **durable resume** (a crash mid-loop replays completed steps without re-running side effects).\n\n```python\nimport asyncio\nfrom lynx import (\n    ToolSet, tool, load_policy_file, run_agent,\n    stdout_sink, auto_deny,\n)\n\n@tool(reversible=False, scope=(\"filesystem:write\",))\nasync def shell(cmd: str) -\u003e str:\n    proc = await asyncio.create_subprocess_shell(\n        cmd,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.PIPE,\n    )\n    out, _ = await proc.communicate()\n    return out.decode()\n\nresult = await run_agent(\n    my_agent,\n    task=\"clean up old logs\",\n    tools=ToolSet.from_functions(shell),\n    policy=load_policy_file(\"policy.yaml\"),\n    sinks=(stdout_sink(),),\n    on_approval=auto_deny(\"no approvals configured\"),\n    environment=\"prod\",          # policy can match on context.environment\n    # principal=Principal(kind=\"user\", id=\"hadi\"),  # optional\n    # workspace=\".\",                                 # optional\n    # budget=Budget(steps=50, duration_seconds=600), # this IS the default; Budget.unlimited() to opt out\n    # correlation_id=None,                           # auto-generated if None\n)\n# result: { correlation_id, bundle_id, final_answer, error, steps_taken }\n# Lynx holds NOTHING. No DB. No state. No leaks.\n```\n\n## What Lynx does\n\n- **Policy-gated execution** at the tool-call boundary. Five verdicts: `allow / deny / dry_run / approve_required / transform`.\n- **Streaming events** to your sinks. We never store events — your sink can buffer, write to disk, ship to OTel, post to a webhook, whatever you choose.\n- **Pure functions everywhere.** The kernel is one function: `run_agent(agent, task, *, tools, policy, sinks, on_approval, ...)`. No `Runtime` class. No singleton.\n- **Immutable values.** Every public type is `frozen=True, slots=True`. Mutation raises at runtime; mypy catches it at write time.\n- **No globals.** No tool registry, no broker, no module-level state. ToolSet is built explicitly at call site.\n- **Hot-swappable policy.** Pass a different `PolicyBundle` on the next `run_agent` call — the bundle is an immutable value; the kernel holds nothing between calls. (Mid-run reload is not supported; build a new bundle and use it on the next run.)\n- **Layered policy scopes** *(optional)*. Compose independent named policies — `compile_policy([PolicyLayer(\"org\", …), PolicyLayer(\"team\", …), PolicyLayer(\"user\", …)])` — each evaluated on its own, then combined by a developer-chosen `Combiner`. Ships `strict_overrides_loose` (default, fail-closed: broadest layer sets a floor narrower layers can only tighten), `last_layer_wins` (most-specific layer may re-grant), and `first_layer_wins` — or bring your own for any trust model. Layers that match no rule abstain; provenance is layer-tagged (`team:block-http`). Mechanism, not policy: Lynx evaluates the layers; you decide who overrides whom. See example 39 and [`docs/02-policy-language.md`](docs/02-policy-language.md#layered-policy-scopes).\n- **Obligations — \"allow, *and also* do X\"** *(optional)*. Attach mandatory side-actions to any verdict (the XACML/Cedar model), resolved against an `ObligationRegistry` you supply — the kernel ships none. A `pre` obligation runs *before* the action and **gates** it (handler fails → the tool never runs; *\"refund only if a scoped credential issued\"*); a `post` obligation runs after (notify-finance, write a special audit record). Unknown id or no registry → fail-closed; every obligation streams `obligation.required/fulfilled/failed` audit events. It is not a verdict — it rides on `allow`/`deny`/`transform`/etc. See [`docs/02-policy-language.md`](docs/02-policy-language.md#obligations--allow-and-also-do-x).\n- **Durable runs, no double side effects** *(opt-in)*. Pass a `RunStore` you implement over your own storage and a stable `run_id`: a crashed run resumes at the first incomplete step — the model is not re-called for completed steps (no re-burned tokens) and journaled actions are not re-executed (no double charges). Two racing workers resolve to one winner; the loser exits `superseded` before executing anything.\n- **Token metering and caps.** Adapters report per-step input/output token counts; the kernel streams them as `step.usage` events, totals them on `RunResult.usage`, and enforces `Budget(tokens=…, input_tokens=…, output_tokens=…)` between steps. The kernel counts and enforces counts — it never converts tokens to money; multiply by your own rates in a sink.\n- **Token optimization (the compressor seam)** *(opt-in)*. Metering measures spend; this reduces it. Pass `compressor=` and every fresh tool result is shrunk *before* it enters the conversation, the journal, and any replay — so a 40 KB log dumped once isn't re-sent in full on every later step. Lynx ships pure-Python reference compressors (`truncate_compressor`, `dedup_compressor`, `compose_compressors`, `route_compressor` via `@tool(compress=…)`, `external_filter_compressor`) and **fails open** — a broken compressor never drops a real output. Lynx is *not* a token optimizer; it owns the seam where yours plugs in. Separately, the Claude adapter now enables Anthropic **prompt caching** (`cache_prompt=True`) so a long loop re-reads prior turns from cache instead of re-billing them. *(RTK — github.com/rtk-ai/rtk — has no stdin filter and is wired at the tool level: your shell tool runs `rtk \u003ccmd\u003e`.)*\n- **Pluggable execution (the executor seam).** Every approved action flows through one `Executor` — in-process by default, a subprocess with rlimits, or *your* Docker/gVisor/E2B wrapper (one async callable). Route per-tool via `@tool(isolation=\"container\")` + `route_executor({...})`, failing closed when a requested isolation has no route. Lynx defines the seam; the security boundary is whatever you plug in.\n- **Handoff graphs** *(optional)*. Sequential multi-agent workflows where **the edge is a permission boundary**: each node is one `run_agent` call with its own policy/tools/budget, and edges route on outcomes — including **denial counts**. Bounded by construction (`max_transitions`), explicit context passing, YAML-declarable, durable via the same `RunStore`. Just sugar over a loop of `run_agent` calls — skip it and write the loop yourself anytime.\n- **MCP proxy** *(optional)*. Put Lynx *in front of* any MCP server: the client (Claude Desktop/Code, Cursor, …) points at Lynx instead of the server, and every `call_tool` flows through the same `evaluate → mediate` path — `allow / deny / dry_run / approve_required / transform` — with an audit stream, **zero code change** on client or server. `serve_mcp_proxy(upstream, policy=…, sinks=…)` wires the stdio transport; `GovernedProxy` / `govern_call` are the transport-free, unit-testable core. See example 34. *(`pip install lynx-agent[mcp]`.)*\n- **Framework-native governance** *(optional)*. When an agent *framework* owns the loop (OpenAI Agents SDK, LangChain, CrewAI, PydanticAI) instead of Lynx, drop a `ToolGuard` in front of its tool calls — `await guard.check(tool_name, args)` runs the same `evaluate → mediate` kernel and returns a `GovernedCall`, so all five verdicts work at the boundary with no proxy and no rewrite. This is the inverse of an **adapter** (`lynx.adapters`, where Lynx drives the loop): here the framework drives, Lynx governs each call inside it. `governed_function_tools(tools, policy=…)` turns a `ToolSet` into governed OpenAI Agents SDK tools in one line. See example 40. *(`pip install lynx-agent[openai-agents]` for the SDK shim; `ToolGuard` itself is stdlib-only.)*\n- **Loop control \u0026 operability.** The rails that keep an agent loop bounded: a kill-switch (`cancel=CancelToken()`) checked at every step boundary and before each tool runs — a cancelled run stops after at most one more action; a repetition gate (`Budget(max_repeated_calls=)`) that breaks same-tool-same-args infinite loops; hard `Budget` caps (steps / tokens / duration); and per-step / per-tool timeouts.\n\n## What Lynx does NOT do\n\n- **No storage** — durability journals to a `RunStore` *you* implement on *your* Redis/Postgres/Dynamo (the contract is two methods and one sentence); audit events stream to *your* sinks. Lynx never opens a file or a connection.\n- **No process supervision** — Lynx does not restart dead workers; your supervisor (systemd, k8s, a queue) does. Lynx makes the restart cheap and safe.\n- **No prompt filtering** — that's [NeMo Guardrails](https://github.com/NVIDIA/NeMo-Guardrails) or [Guardrails AI](https://github.com/guardrails-ai/guardrails).\n- **No cluster orchestration** — that's [Temporal](https://temporal.io) or [Inngest](https://www.inngest.com).\n- **No agent framework** — that's [LangGraph](https://langchain-ai.github.io/langgraph/) / [CrewAI](https://www.crewai.com); we wrap them via adapters.\n\n## Install\n\n```bash\npip install lynx-agent                    # core (3 deps)\npip install lynx-agent[anthropic]         # Claude adapter\npip install lynx-agent[openai]            # GPT + any OpenAI-compatible provider\npip install lynx-agent[langgraph]\npip install lynx-agent[crewai]\npip install lynx-agent[openai-agents]     # govern the OpenAI Agents SDK (ToolGuard)\npip install lynx-agent[mcp]\npip install lynx-agent[otel]              # OpenTelemetry audit sink\n```\n\nThe `[openai]` adapter also targets any **OpenAI-compatible** provider — Grok (xAI), Mistral, DeepSeek, Groq, OpenRouter, Together, Fireworks, Perplexity, Ollama — via one registry, and the *same policy* governs every one:\n\n```python\nfrom lynx.adapters.openai_compat import openai_compatible_agent\nagent = openai_compatible_agent(\"deepseek\", tools=tools, model=\"deepseek-chat\")\n# swap \"deepseek\" → \"grok\" / \"mistral\" / \"groq\" / … — run_agent(...) is unchanged\n```\n\n## Quickstart\n\n```bash\npip install lynx-agent\nlynx init           # writes one file: policy.yaml\npython examples/01_hello_allow.py\n```\n\n## Documentation\n\n| Doc | What's in it |\n|---|---|\n| [`docs/concepts.md`](docs/concepts.md) | The model end-to-end: the loop, the five verdicts, every seam, and how they compose |\n| [`docs/02-policy-language.md`](docs/02-policy-language.md) | Full policy reference — YAML schema, operators, Python rules, layered scopes |\n| [`docs/cli.md`](docs/cli.md) | Complete CLI reference — every command, flag, output line, and exit code |\n| [`docs/cookbook.md`](docs/cookbook.md) | Copy-pasteable policy patterns (block `rm -rf`, tiered approvals, layered org/team/user) |\n| [`docs/integration-cookbook.md`](docs/integration-cookbook.md) | Wiring recipes — sinks (SQLite/Postgres/OTel/Splunk/HTTP), durability stores, Slack approvals, `ToolGuard` in your framework |\n| [`docs/what-lynx-is-and-isnt.md`](docs/what-lynx-is-and-isnt.md) | The boundary Lynx owns vs. what to compose it with |\n| [`docs/faq.md`](docs/faq.md) | Common questions — performance, MCP, framework support, hot-reload, cleanup |\n| [`docs/roadmap.md`](docs/roadmap.md) | Shipped vs. planned, by phase |\n\n## How it works\n\n```\n                ┌────────────────────────────────────────────┐\n                │  Agent (any framework)                     │\n                └──────────────────┬─────────────────────────┘\n                                   │  ToolCall\n                                   ▼\n              ╔═══════════════════════════════════════════╗\n              ║  run_agent (pure function)                ║\n              ║   1. PDP evaluates → Decision             ║\n              ║   2. Mediator dispatches by verdict       ║\n              ║   3. Sinks called with each AuditEvent    ║\n              ║   4. Approval handler called sync if needed║\n              ╚═══════════════════════════════════════════╝\n                                   │ side effect\n                                   ▼\n                ┌────────────────────────────────────────────┐\n                │  Real world                                │\n                └────────────────────────────────────────────┘\n```\n\nEach agent step:\n1. Build `ActionRequest` from the agent's `ToolCall`\n2. `evaluate(policy, request, context)` returns a `Decision` (pure function)\n3. `mediate(request, decision, tools, on_approval)` dispatches\n4. Each step emits a few events; sinks consume them\n5. Result is appended to a new `conversation` tuple; old tuple is freed\n\n## Tools — `@tool` and `ToolSet`\n\nEvery tool is an `async def` decorated with `@tool`. The decorator attaches an\nimmutable `ToolDef` to the function (no global registry); you bundle decorated\nfunctions into a `ToolSet` explicitly at the call site.\n\n```python\nfrom lynx import tool\n\n@tool(\n    cost=\"low\",                     # \"low\" | \"medium\" | \"high\" (default \"low\")\n    reversible=False,               # if False, dry_run requires a .shadow\n    scope=(\"filesystem:write\",),    # free-form tags policy can match on\n    blast_radius_hint=None,         # int | None — opaque to the kernel; readable by your rules via declared.blast_radius_hint\n    name=None,                      # override; default = fn.__name__\n    description=None,               # override; default = first line of docstring\n)\nasync def write_file(path: str, content: str) -\u003e str:\n    \"\"\"Save text to a file.\"\"\"\n    Path(path).write_text(content)\n    return f\"wrote {len(content)} bytes to {path}\"\n```\n\n### Shadows — pure previews for `dry_run`\n\nIf a tool is irreversible and policy chooses `dry_run`, the kernel calls the\n**shadow** instead of the real function. Shadows must be pure (no I/O, no side\neffects) and return a JSON-serializable preview.\n\n```python\n@write_file.shadow\nasync def _write_file_shadow(path: str, content: str) -\u003e dict:\n    p = Path(path)\n    return {\n        \"would_write\": path,\n        \"bytes\": len(content.encode()),\n        \"would_overwrite\": p.exists(),\n        \"preview\": content[:120],\n    }\n```\n\nIf no shadow is registered and policy defaults `on_missing_shadow: approve_required`\n(the default), an irreversible tool with no rule match falls through to approval\nrather than running blind.\n\nAlternative attachment form:\n\n```python\nfrom lynx import shadow\n\n@shadow(write_file)\nasync def _write_file_shadow(path, content): ...\n```\n\n### `ToolSet` — immutable, built at call site\n\n```python\nfrom lynx import ToolSet\n\ntools = ToolSet.from_functions(write_file, shell, get_customer)\n\ntools.names()                        # (\"get_customer\", \"shell\", \"write_file\")\ntools.get(\"write_file\")              # ToolDef\ntools.with_tool(other_def)           # returns NEW ToolSet\ntools.without_tool(\"shell\")          # returns NEW ToolSet\ntools.union(other_toolset)           # returns NEW ToolSet\nlen(tools)                           # 3\n```\n\nEvery operation returns a new `ToolSet`; the original is untouched.\n\n## Policy — full reference\n\nA policy is a frozen `PolicyBundle` produced by `compile_policy(yaml_str)` or\n`load_policy_file(path)`. Bundles are content-addressed by `bundle.id` and safe\nto hot-reload — the kernel holds no policy state between calls.\n\n### YAML schema\n\n```yaml\nversion: 1                        # int; currently only 1 is defined\n\ndefaults:\n  on_no_match: deny               # verdict when no rule matches a request\n  on_missing_shadow: approve_required\n                                  # verdict when no rule matches AND the tool\n                                  # is irreversible AND has no .shadow\n\npredicates:                       # named, reusable matchers\n  in_prod: { context.environment: prod }\n  is_kubectl: { tool: kubectl }\n  is_destructive_sql:\n    tool: sql_exec\n    args.sql.matches: '(?i)\\b(UPDATE|DELETE)\\b'\n\nrules:\n  - id: hard-block-rm-rf-root     # str; defaults to \"rule_\u003cindex\u003e\"\n    priority: 100                 # int; higher runs first (default 0)\n    description: \"...\"            # optional, free-form\n    match: { ... }                # see \"Match expressions\" below\n    decision: deny                # one of the five verdicts\n    reason: \"rm -rf / is hard-blocked\"\n    approvers: [\"sre-oncall@acme.com\"]   # only used by approve_required\n    timeout_seconds: 1800                # only used by approve_required\n    transform: { ... }                   # only used by transform\n```\n\nRules are sorted by `(-priority, file order)`. The first matching rule wins.\nPython rules (see below) are interleaved with YAML rules by priority — a\nhigher-priority YAML rule beats a lower-priority Python rule, and vice versa.\n\n### The five verdicts\n\n| Verdict | What the mediator does |\n|---|---|\n| `allow` | Call `tool.fn(**args)` normally. |\n| `deny` | Skip execution. Inject a `[denied]` tool message into the conversation. |\n| `dry_run` | Call `tool.shadow_fn(**args)` instead of `fn`. Real side effects suppressed. |\n| `approve_required` | Call `on_approval(...)` synchronously. On grant, proceed as `allow`; on deny, behave as `deny`. |\n| `transform` | Rewrite `args` per the `transform:` block, then call `fn(**rewritten_args)`. |\n\n### Match expressions\n\nMatch expressions read fields off the live `ActionRequest` and `ExecutionContext`.\n\n**Paths** (the part before the operator):\n\n| Path prefix | Reads from |\n|---|---|\n| `tool` | The tool name (string) |\n| `args.\u003cname\u003e...` | The arguments the agent proposed |\n| `declared.\u003cname\u003e` | Tool metadata: `cost`, `reversible`, `scope`, `blast_radius_hint`, `has_shadow` |\n| `context.\u003cname\u003e` | `principal`, `environment`, `workspace`, `correlation_id`, `step_seq`, `timestamp`, `extra` |\n\n**Operators** (suffix the path with `.\u003cop\u003e`):\n\n| Operator | Meaning | Example |\n|---|---|---|\n| (none) / `.eq` | Equality | `tool: kubectl` |\n| `.matches` | Regex `re.search` (RE2-style guards reject catastrophic backtracking) | `args.cmd.matches: '^rm\\s+-rf'` |\n| `.in` | Value is in the listed sequence | `args.customer_id.in: [\"C-789\"]` |\n| `.contains` | Container contains the value | `declared.scope.contains: filesystem:write` |\n| `.contains_any` | Container contains any listed value | `declared.scope.contains_any: [a, b]` |\n| `.contains_all` | Container contains all listed values | `declared.scope.contains_all: [a, b]` |\n| `.gt` `.ge` `.lt` `.le` | Numeric comparison | `args.amount_usd.gt: 500` |\n| `.between` | `lo \u003c= v \u003c= hi` | `args.amount_usd.between: [50, 500]` |\n| `.not_between` | Inverse of `between` | |\n\n**Composition** at any level:\n\n```yaml\nmatch:\n  all_of:\n    - is_kubectl                       # named predicate\n    - in_prod\n    - args.command.matches: '^(apply|delete|patch)\\b'\n  # any_of: [ ... ]\n  # not: { tool: shell }\n```\n\n### `transform:` block\n\n```yaml\ndecision: transform\ntransform:\n  jsonpath: \"$.args.sql\"               # default \"$.args\"; the target arg key\n  append: \" AND tenant_id = 'TENANT-A'\" # one of: set | append | delete\n```\n\n- `set: \u003cvalue\u003e` — replace the value at `jsonpath`\n- `append: \u003cvalue\u003e` — string-concatenate to the existing value\n- `delete: true` — remove the key from `args`\n\n### Python rules\n\nAnything you can't express in YAML, write as a Python predicate. Rules are\nexplicit arguments to `compile_policy`; there is no decorator and no registry.\n\n```python\nfrom lynx import compile_policy\nfrom lynx.policy import allow, deny, dry_run, approve_required, transform\n\ndef block_paths_outside_workspace(req, ctx):\n    if req.tool != \"shell\":\n        return None                                   # skip — let YAML decide\n    if path_escapes(req.args[\"cmd\"], ctx.workspace):\n        return deny(\"path escapes workspace\")\n    return None\n\nbundle = compile_policy(\n    yaml_source,\n    python_rules=(block_paths_outside_workspace,),\n    python_rule_priorities=((\"block_paths_outside_workspace\", 100),),\n)\n```\n\nEach Python rule is `(ActionRequest, ExecutionContext) -\u003e Decision | None`.\nReturn `None` to defer; the first non-`None` result wins. Python and YAML\nrules are interleaved in a single priority-sorted evaluation order (default\npriority `0`). If a rule raises during evaluation, it is recorded as a\ndiagnostic marker in `Decision.matched_rules` (e.g. `\u003crule_error:my_rule:TypeError\u003e`)\nand evaluation continues — buggy rules never silently fail-open.\n\n### Decision constructors\n\nFor Python rules and tests:\n\n```python\nfrom lynx.policy import allow, deny, dry_run, approve_required, transform\n\nallow(reason=\"\", matched_rules=())\ndeny(reason, matched_rules=())\ndry_run(reason=\"\", matched_rules=())\napprove_required(approvers=(), timeout_seconds=1800, reason=\"\", matched_rules=())\ntransform(transform_args={\"sql\": \"...\"}, reason=\"\", matched_rules=())\n```\n\n### Default behavior when no rule matches\n\n1. If the tool is **irreversible AND has no shadow** → `defaults.on_missing_shadow`\n   (default `approve_required`).\n2. Otherwise → `defaults.on_no_match` (default `deny`).\n\nThe matched rule id will be `\"\u003cdefault:on_missing_shadow\u003e\"` or\n`\"\u003cdefault:on_no_match\u003e\"` so you can see the fall-through in audit events.\n\n### Layered policy scopes\n\nFor org/team/user-style composition, pass a list of `PolicyLayer` to\n`compile_policy` instead of one source. Each layer is evaluated independently and\na developer-chosen `Combiner` resolves disagreements:\n\n```python\nfrom lynx import PolicyLayer, compile_policy, last_layer_wins\n\nbundle = compile_policy(\n    [PolicyLayer(\"org\", org_yaml), PolicyLayer(\"team\", team_yaml), PolicyLayer(\"user\", user_yaml)],\n    merge=last_layer_wins,   # optional; defaults to strict_overrides_loose (fail-closed)\n)\n```\n\n`strict_overrides_loose` (default) takes the most-restrictive verdict;\n`last_layer_wins` lets the most-specific layer re-grant; `first_layer_wins` makes\nthe broadest authoritative — or pass your own `Combiner`. Non-matching layers\nabstain; provenance is layer-tagged. Full reference:\n[`docs/02-policy-language.md`](docs/02-policy-language.md#layered-policy-scopes).\n\n### `run_agent` — all kwargs\n\n```python\nresult = await run_agent(\n    agent,                              # implements async step(conv) -\u003e ToolCall | FinalAnswer\n    task,                               # str — becomes the first user Message\n    *,\n    tools,                              # ToolSet\n    policy,                             # PolicyBundle\n    sinks=(),                           # Iterable[Sink]\n    on_approval=None,                   # ApprovalHandler; defaults to auto_deny\n    budget=Budget(steps=50, duration_seconds=600),\n    principal=Principal(kind=\"user\", id=\"anonymous\"),\n    environment=\"dev\",                  # policy reads this via context.environment\n    workspace=\".\",                      # policy reads this via context.workspace\n    correlation_id=None,                # auto-generated UUID4 if None\n)\n```\n\n## Sinks — the audit replacement\n\n```python\nfrom lynx import stdout_sink, jsonl_sink, multi_sink\n\n# Pretty-print + persist to jsonl in one go\nwith open(\"audit.jsonl\", \"a\") as f:\n    sink = multi_sink(stdout_sink(), jsonl_sink(f))\n    await run_agent(..., sinks=(sink,))\n# File is yours. You close it. You rotate it. You ship it where you want.\n```\n\nBuilt-in sinks:\n\n| Sink | What it does |\n|------|-------------|\n| `stdout_sink(stream=...)` | Pretty-print events |\n| `jsonl_sink(handle)` | One JSON line per event |\n| `hash_chained_sink(handle)` | One JSON line per event, **tamper-evident** (hash-chained) |\n| `otel_sink(tracer=...)` | Emit each event as an OpenTelemetry span (`pip install lynx-agent[otel]`) |\n| `noop_sink()` | Discard (for tests) |\n| `multi_sink(*sinks)` | Fan out concurrently |\n| `callback_sink(fn)` | Wrap any async callable |\n\nWrite your own — it's just `async def __call__(event: AuditEvent) -\u003e None`.\n\n### Tamper-evident audit\n\nAn audit log you can quietly edit isn't an audit log. `hash_chained_sink` is a\ndrop-in for `jsonl_sink` that fingerprints every line and chains it to the line\nbefore it — `hash = sha256(prev_hash + canonical_json(event))` — so editing a\nbody, dropping a denial, or reordering events breaks every fingerprint\ndownstream. It's a pure sink (no kernel change, stdlib-only) and composes with\n`multi_sink`.\n\n```python\nfrom lynx import hash_chained_sink, verify_chain\n\nwith open(\"audit.jsonl\", \"a\") as f:\n    await run_agent(..., sinks=(hash_chained_sink(f),))\n\nverify_chain(\"audit.jsonl\")   # VerifyResult(intact=True, lines=42, ...)\n```\n\n```console\n$ lynx verify audit.jsonl\nintact: 42 events, chain verified\n# tamper with one line, then:\n$ lynx verify audit.jsonl\nbroken at line 17: hash mismatch (line was modified)   # exits 1\n```\n\nThis is tamper-*evident* (proves nobody altered the log). See example 37.\n\n### OpenTelemetry\n\nAlready running OTel? `otel_sink` turns every governance decision into a span so\nit lands in your existing backend (Datadog / Honeycomb / Grafana Tempo / Jaeger)\nnext to the rest of your telemetry — no custom plumbing. Each `AuditEvent`\nbecomes one short span named by `event.kind` with `lynx.*` attributes, and it\nnests under the ambient trace automatically when the agent runs inside an\ninstrumented request. Stateless: every span is ended immediately, so nothing\naccumulates over a long run.\n\n```python\nfrom lynx import otel_sink\n\nawait run_agent(..., sinks=(otel_sink(),))   # uses trace.get_tracer(\"lynx\")\n```\n\n`pip install lynx-agent[otel]`. See example 38.\n\n## Approvals — synchronous handlers\n\n```python\nfrom lynx import cli_prompt_approval, callback_approval, ApprovalDecision\n\n# Built-in: prompt on stdin\nawait run_agent(..., on_approval=cli_prompt_approval())\n\n# Or bring your own\nasync def slack_approval(req):\n    msg = await slack.post(f\"Approve {req.request.tool}?\")\n    button = await slack.wait_for_click(msg, timeout=3600)\n    return ApprovalDecision(granted=button == \"approve\", approver=button.user)\n\nawait run_agent(..., on_approval=callback_approval(slack_approval))\n```\n\nThe `run_agent` call blocks on the handler. No queue. No broker. No cross-process resume. Your handler decides how to wait.\n\n## Durability — crash-resume without double side effects\n\nOpt in by passing a `RunStore` (your storage, your dependency) and a stable `run_id`:\n\n```python\nresult = await run_agent(\n    agent, task,\n    tools=tools, policy=policy,\n    store=my_store,                 # you implement two methods (below)\n    run_id=\"invoice-2026-0611\",     # stable across retries\n)\n# Process dies mid-run? Your supervisor retries the same call.\n# Completed steps replay from the journal: the model is NOT re-called,\n# journaled actions are NOT re-executed. A finished run returns the same\n# answer forever. Two racing workers resolve to one; the loser returns\n# error=\"superseded: ...\" having executed nothing.\n```\n\nThe whole `RunStore` contract:\n\n```python\nclass MyStore:                       # Redis / Postgres / Dynamo / a dict\n    async def append(self, record: StepRecord) -\u003e None:\n        # MUST atomically raise DuplicateRecord if (run_id, seq) exists.\n        # Postgres: PRIMARY KEY (run_id, seq). Redis: HSETNX. That's it.\n        ...\n    async def load(self, run_id: str) -\u003e Sequence[StepRecord]:\n        ...                          # ordered by seq\n```\n\nThat one uniqueness rule is the concurrency story: the write-ahead intent\njournaled before every action *is* the claim — no leases, no TTLs, nothing\nto clean up when a worker dies. See\n[`examples/24_durable_resume.py`](examples/24_durable_resume.py) for a\ncomplete ~15-line store plus crash, resume, and supersede in action, and\n[`docs/integration-cookbook.md`](docs/integration-cookbook.md) for Redis /\nPostgres / file-backed recipes.\n\n**The crash window, handled honestly.** If a worker dies *between* executing\nan action and journaling its result, the action *may* have run. On resume,\nLynx re-proposes it to policy with `context.extra.uncertain_retry: true` —\nso your policy decides: re-run it (idempotent tools), deny it, or escalate\nto a human:\n\n```yaml\n- id: never-rerun-uncertain-payments\n  match: { context.extra.uncertain_retry: true, declared.reversible: false }\n  decision: approve_required\n```\n\nInspect any journal with `replay(records)` (pure function) or `lynx trace\nrecords.jsonl` (for file-backed stores).\n\n## Execution isolation — the executor seam\n\nPolicy decides *whether* an action runs; the executor decides *where and\nhow*. By default approved tools run in-process. Pass an `Executor` and all\nreal execution (allow / transform / approval-granted) flows through it\ninstead:\n\n```python\nfrom lynx import inline_executor, route_executor, subprocess_executor\n\n@tool(reversible=False, scope=(\"compute:exec\",), isolation=\"container\")\nasync def run_code(snippet: str) -\u003e str: ...\n\nresult = await run_agent(\n    agent, task, tools=tools, policy=policy,\n    executor=route_executor({\n        None:        inline_executor(),        # default route\n        \"subprocess\": subprocess_executor(),   # rlimits — crash protection\n        \"container\":  my_docker_executor,      # YOURS (~20 lines, see cookbook)\n    }),\n)\n```\n\nA custom executor is one async callable — `(request, tool) -\u003e ActionResult`\n— so Docker, gVisor, Firecracker, E2B, or Modal plug in without Lynx\nshipping any of them as dependencies. Routing **fails closed**: a tool that\ndeclares `isolation=\"microvm\"` when no microvm route exists gets a failed\naction, never a silent fallback to the host. Dry-runs bypass the seam\n(shadows are side-effect-free by contract), and a raising executor fails\nthe action — never the run.\n\nHonesty, as always: Python has no reliable in-language sandbox, and\n`subprocess_executor()` is **crash/runaway protection, not a security\nboundary** (see [SECURITY.md](SECURITY.md)). Lynx is the chokepoint where\nisolation attaches; the boundary itself is whatever you put behind the\nseam — the same stance as \"you bring the database.\"\n\n## Handoff graphs — the edge is a permission boundary\n\nOptional, and deliberately thin: a node is just a `run_agent()` call, so the\ngraph module is declarative sugar over a loop you could write yourself.\nWhat it adds is the part multi-agent frameworks fumble — **enforced role\nboundaries** and bounded, explicit routing:\n\n```python\nfrom lynx import GraphNode, compile_graph, run_graph\n\nnodes = {\n    \"triage\":   GraphNode(agent=triage,   tools=tools, policy=read_only),\n    \"fixer\":    GraphNode(agent=fixer,    tools=tools, policy=can_write),\n    \"reviewer\": GraphNode(agent=reviewer, tools=tools, policy=read_only),\n}\ngraph = compile_graph(\"\"\"\nstart: triage\nmax_transitions: 8                  # mandatory bound — runaway loops impossible\nedges:\n  - { from: triage,   when: { answer_matches: \"(?i)needs fix\" }, to: fixer }\n  - { from: triage,   to: done }\n  - { from: fixer,    to: reviewer }\n  - { from: reviewer, when: { answer_matches: \"(?i)approved\" },  to: done }\n  - { from: reviewer, when: { denials_gt: 2 }, to: privileged }  # policy as a routing signal\n  - { from: reviewer, to: fixer }   # rejected → loop back; cycles are fine\n\"\"\")\nresult = await run_graph(nodes, \"Fix the bug\", router=graph)\n```\n\n- **Per-node policy is enforced, not prompted**: if the triage model tries to\n  write, *its node's policy denies it* — the orchestrator can't bypass its\n  role (the failure mode every role-based framework suffers).\n- **Denial counts route**: `denials_gt` is a predicate no other orchestrator\n  has, because nobody else makes policy first-class.\n- **Context passing is explicit**: the next node's task = the original goal +\n  the previous node's result, clearly marked (`compose_task=` to customize).\n  No hidden shared state, no live agent-to-agent messages, sequential only.\n- **Python first**: skip YAML entirely — any `(NodeOutcome) -\u003e str | None`\n  callable is a `Router`.\n- **Durability composes**: pass `store=`/`run_id=` and node runs + routing\n  decisions journal; a crashed 3-node workflow resumes at the node it died\n  in, and racing graph workers resolve to one winner.\n\nSee [`examples/27_handoff_graph.py`](examples/27_handoff_graph.py) for the\ntriage → fixer ⇄ reviewer loop with an enforced role boundary.\n\n## Token usage \u0026 budgets\n\nAdapters (`ClaudeAgent`, `OpenAIAgent`) attach a `Usage` record to every model\nstep — input/output/cache token counts plus the model name. The kernel then:\n\n```python\nresult = await run_agent(\n    agent, task, tools=tools, policy=policy,\n    budget=Budget(\n        steps=50,\n        duration_seconds=600,\n        input_tokens=500_000,       # separate caps — input and output\n        output_tokens=100_000,      # are priced differently\n        tokens=550_000,             # or one combined cap\n        step_timeout_seconds=120,   # a hung model call fails, never hangs\n    ),\n    sinks=(my_cost_sink,),        # step.usage events stream here\n)\nresult.usage   # Usage(input_tokens=..., output_tokens=...) — lifetime totals\n```\n\n- **`step.usage` events** carry per-step counts + running totals — your sink\n  multiplies by *your* rates for dollars, alerts, and attribution\n  (per-customer = group by `correlation_id`). Lynx ships no price tables;\n  they go stale weekly and your negotiated rates aren't list rates.\n- **Caps are enforced between steps**, exactly like `steps` — when crossed,\n  the run stops with `error=\"output token budget exhausted (…)\"`. Honest\n  caveat: like every in-loop limiter, a cap stops the *next* model call; the\n  step that crossed the line already happened.\n- **Unmetered agents are unmetered.** A hand-rolled `Agent` that attaches no\n  `usage` produces no events and no enforcement — Lynx enforces what it can\n  see and nothing else. With durability, journal-replayed steps count toward\n  totals and caps (they were real spend in a prior attempt).\n\nScope, honestly: Lynx does not restart dead processes (your supervisor does);\ndurability needs no database, but *distributed* durability — runs surviving\nmachine loss, multiple workers — needs *your* database. Budgets count\nreplayed steps (resume a budget-exhausted run by passing a larger budget);\n`duration_seconds` is per-attempt. Tool args/results should be\nJSON-serializable (LLM tool calls always are). Resuming under a different\npolicy emits a `run.bundle_changed` warning; resuming with a different\nToolSet, or with an agent that isn't a pure function of the conversation\n(e.g. the single-shot CrewAI adapter), is out of contract.\n\n## Examples\n\n| # | File | What it shows |\n|---|------|--------------|\n| 01 | [`01_hello_allow.py`](examples/01_hello_allow.py) | Smallest possible run |\n| 02 | [`02_block_dangerous.py`](examples/02_block_dangerous.py) | DENY for `rm -rf /` |\n| 03 | [`03_preview_writes.py`](examples/03_preview_writes.py) | DRY_RUN with file shadow |\n| 04 | [`04_human_approval.py`](examples/04_human_approval.py) | Sync approval via stdin |\n| 05 | [`05_real_llm_blocked.py`](examples/05_real_llm_blocked.py) | Real Claude / GPT |\n| 06 | [`06_streaming_to_jsonl.py`](examples/06_streaming_to_jsonl.py) | Audit replacement: jsonl sink |\n| 07 | [`07_refund_workflow.py`](examples/07_refund_workflow.py) | Multi-tier refund rules |\n| 08 | [`08_sql_transform.py`](examples/08_sql_transform.py) | TRANSFORM verdict |\n| 09 | [`09_fastapi_service.py`](examples/09_fastapi_service.py) | FastAPI integration |\n| 10 | [`10_devops_assistant.py`](examples/10_devops_assistant.py) | All five verdicts (one policy, run in staging + prod) |\n| 11 | [`11_flask_service.py`](examples/11_flask_service.py) | Flask integration |\n| 12 | [`12_django_service.py`](examples/12_django_service.py) | Django integration |\n| 13 | [`13_python_rules.py`](examples/13_python_rules.py) | Python rules + `\u003crule_error:…\u003e` diagnostics |\n| 17 | [`17_shadow_helpers.py`](examples/17_shadow_helpers.py) | Built-in fs/http/shell/sql shadows |\n| 18 | [`18_sandboxed_tool.py`](examples/18_sandboxed_tool.py) | `subprocess_executor` resource caps |\n| 24 | [`24_durable_resume.py`](examples/24_durable_resume.py) | Crash → resume, never double-charge |\n| 26 | [`26_executor_seam.py`](examples/26_executor_seam.py) | Bring-your-own sandbox (`route_executor`) |\n| 27 | [`27_handoff_graph.py`](examples/27_handoff_graph.py) | Handoff graph — the edge is a policy boundary |\n| 32 | [`32_token_optimization.py`](examples/32_token_optimization.py) | Compressor seam |\n| 33 | [`33_subagents.py`](examples/33_subagents.py) | A tool that runs an agent |\n| 34 | [`34_mcp_proxy.py`](examples/34_mcp_proxy.py) | Govern any MCP server, zero code change |\n| 35 | [`35_multi_provider.py`](examples/35_multi_provider.py) | One policy, any model provider |\n| 36 | [`36_fastmcp_governed.py`](examples/36_fastmcp_governed.py) | Build with FastMCP, govern with Lynx |\n| 37 | [`37_tamper_evident_audit.py`](examples/37_tamper_evident_audit.py) | Hash-chained audit + `verify_chain` |\n| 38 | [`38_otel_audit.py`](examples/38_otel_audit.py) | OpenTelemetry audit sink |\n| 39 | [`39_layered_policy.py`](examples/39_layered_policy.py) | Layered policy scopes + combiners |\n| 40 | [`40_framework_native_governance.py`](examples/40_framework_native_governance.py) | `ToolGuard` — govern a framework's tool calls |\n| 41 | [`41_obligations.py`](examples/41_obligations.py) | Obligations — `pre` gate (fail-closed) + `post` notify on any verdict |\n\nAll 41 with one-line descriptions: [`examples/README.md`](examples/README.md).\n\n## CLI — seven commands\n\n```\nlynx --version\nlynx init [--dir \u003cpath\u003e] [--force]    # write a starter policy.yaml (creates the dir)\nlynx run \u003cscript\u003e                     # run a script's async main()\nlynx verify \u003caudit.jsonl\u003e             # check a hash-chained audit log (exits 1 if broken)\nlynx trace \u003crecords.jsonl\u003e [--run-id \u003cid\u003e]   # reconstruct a durable run journal\nlynx policy lint [path]               # compile-check a policy + rule summary (default policy.yaml)\nlynx policy bundle-id [path]          # print a policy's content-addressed id\n```\n\nEvery command also has `--help`. Full reference — flags, output lines, exit\ncodes, and the audit-log-vs-run-journal distinction — in\n[`docs/cli.md`](docs/cli.md).\n\n## Status\n\n**Public API committed; SemVer.** Production-ready for the documented scope. The kernel — `run_agent`, the five verdicts, the policy language, the sink/executor/compressor/durability seams — is stable; new capabilities land as additive seams and adapters (recent: the MCP proxy, OpenAI-compatible providers, FastMCP), never as breaking changes to that core.\n\n## Design\n\n- [`docs/concepts.md`](docs/concepts.md) — vocabulary\n- [`docs/what-lynx-is-and-isnt.md`](docs/what-lynx-is-and-isnt.md) — what Lynx owns vs. what it composes with (mem0/Zep, Langfuse, LiteLLM, MCP gateways, Temporal)\n- [`docs/cookbook.md`](docs/cookbook.md) — policy patterns (YAML)\n- [`docs/integration-cookbook.md`](docs/integration-cookbook.md) — wiring patterns for sinks (SQLite / Postgres / Splunk / OTel / HTTP) + approval handlers (Slack / email / webhook) + durability `RunStore` backends (Redis / Postgres / files / Temporal)\n- [`docs/faq.md`](docs/faq.md) — common questions\n\n## License\n\nApache 2.0.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhadihonarvar%2Flynx","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhadihonarvar%2Flynx","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhadihonarvar%2Flynx/lists"}