The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Heimdall MCP listing page.
Transparent proxy for any MCP server. Intercepts all JSON-RPC messages, measures latency, stores traces in a configurable database, and enforces per-server allow/deny policies — without touching the original server.
Visit the website to view a full explanation, examples, and other tools!
The proxy always exposes stdio to the MCP client and speaks the correct transport to the real server. Every request/response pair is converted into a span with timing, attributes, and the input/output body.
Drop a heimdall.config.ts in your project root and define exactly which tools, prompts, and resources each MCP server is allowed to expose to the agent — at the proxy layer, without touching the server code.
| Scope | Path | Purpose |
|---|---|---|
| Local | {project-root}/heimdall.config.{ts,js,mjs,cjs,json} | Per-repo rules |
| Global | ~/.config/heimdall/heimdall.config.{ts,js,mjs,cjs,json} | User/org-wide rules |
Both are optional. If neither exists, the proxy stays fully transparent (backward compatible).
TypeScript configs are loaded via jiti without pre-compilation. Also works as .js, .mjs, .cjs, or .json.
When both local and global configs exist, they merge with security-first semantics:
| Rule | Behavior |
|---|---|
| Deny → union | Denied by either = denied. Global deny cannot be overridden locally. |
| Allow → intersection | Must pass both. * or [] means "defer to the other side." |
| Deny beats allow | Within any single config, deny always wins. |
The global config enforces a floor the team can't accidentally loosen. Local configs can only add more restrictions, never fewer.
toolPolicies adds a second enforcement layer on top of name-level tools rules. Instead of just deciding which tools are callable, you can constrain what arguments are allowed on each call.
ArgConstraint fields| Field | Type | Default | Description |
|---|---|---|---|
isPath | boolean | false | Enables path-aware matching (containment check) instead of regex |
allow_pattern | string | string[] | — | Arg must match at least one pattern to pass |
deny_pattern | string | string[] | — | Arg is blocked if it matches any pattern; deny wins over allow |
array_mode | 'all' | 'any' | 'all' | For array-typed args: require all items to pass (all) or at least one (any) |
case_sensitive | boolean | true | Regex flag; not applied to path-root matching |
warn_only | boolean | false | Record the violation in the OTel span without blocking the call |
isPath: trueWhen isPath: true, patterns that look like directory roots are treated as containment checks rather than regex expressions:
| Pattern | Meaning |
|---|---|
"./" or "." | Arg must resolve within process.cwd() |
"/some/dir" | Arg must resolve within /some/dir |
"~" / "${HOME}/projects" | Resolved to homedir |
"${CWD}/data" | Resolved to cwd + /data |
The resolver uses path.resolve + fs.realpathSync to prevent ../ traversal and symlink escapes. Patterns that don't look like directory roots (e.g. "^/etc/.*") fall back to regex matching.
warn_only modeUseful for gradual rollout: set warn_only: true to observe violations without blocking. The call is forwarded and the following attributes appear in the OTel span:
Switch to warn_only: false (the default) when you're ready to enforce.
Use dot notation to constrain fields inside nested parameter objects:
Prevent concurrent tools/call invocations from racing on the same resource (e.g. two agents writing the same file at once). Configure a locks block per server, keyed by tool name.
Concrete scenarios where resource locks solve a real coordination problem:
resource: 'path' or resource: 'file_path') serializes those calls — the second call is rejected (or, with onConflict: 'warn', forwarded with a warning) until the first completes or the lock's TTL expires.run_migration tool exposed through an MCP database server is dangerous to run concurrently — two overlapping migrations against the same database can corrupt state. Locking on a literal resource key (resource: 'db-migration', not tied to any particular argument) ensures only one migration call is in flight at a time, regardless of which session or agent triggered it.query) so near-simultaneous duplicate calls from separate agent turns don't multiply API usage or trip the provider's rate limiter.LockRule fields| Field | Type | Default | Description |
|---|---|---|---|
resource | string | — | Name of a tool-call argument whose value is used as the lock key (e.g. resource: 'path' locks on arguments.path), or a literal resource key (e.g. resource: 'db-migration') if no argument by that name exists. Falls back to the tool name itself when omitted. Path-like values are canonicalized automatically (see below). |
ttl | number | 30000 | Lock time-to-live in milliseconds. Acts as a backstop so a crashed/hung holder can't keep a resource locked forever. |
onConflict | 'reject' | 'warn' | 'reject' | reject blocks the call and returns a RESOURCE_LOCKED JSON-RPC error (default). warn forwards the call anyway and attaches lock.* warning metadata to the OTel span instead of blocking. |
LockInterceptor is a 'write' lock: at most one holder can hold a given resource key at a time, regardless of whether the underlying call is conceptually a read or a write. There's no separate shared/concurrent mode — two calls that both just need to read the same resource still serialize against each other if they share a lock rule.'read' mode exists as a type, not as a feature. The LockStore interface defines LockMode = 'read' | 'write' at the storage layer, but LockInterceptor currently hardcodes 'write' on every acquire() call, and LockRuleSchema has no mode field to select it from heimdall.config.ts. Don't rely on 'read' mode for anything — it's not configurable or reachable from user config yet.ttl is not a per-call timeout — it's a backstop for stuck holders. If the process holding a lock crashes, hangs, or is killed before it releases the lock, the lock would otherwise block that resource forever. Once ttl milliseconds pass since acquisition, the lock is treated as expired and a new caller can acquire the same resource, even though the original holder never explicitly released it. Release is idempotent, so if the original (crashed) holder later calls release() after its lock has already expired and been reacquired by someone else, that stale release is a silent no-op — it does not release the new holder's lock.Status: write-mode (exclusive) locking is enforced on tools/call — LockInterceptor acquires a lock before forwarding, releases it on completion or error, and is backed by a LockStore. Resource keys that look like filesystem paths (absolute, ~, ./, ../, or a drive letter) are canonicalized — expanded, resolved to an absolute path, and symlinks followed — so the same real file is locked consistently no matter how it's referenced (relative path, ~, symlink, or a different project directory). Not yet implemented: read-mode locking (every lock is currently acquired in exclusive 'write' mode; there is no mode field in LockRuleSchema yet — see Limitations).
By default the lock store is a local SQLite file at ~/.config/heimdall/locks.db — zero configuration required. Postgres and MySQL backends are also available for multi-machine lock coordination (e.g. multiple proxy instances sharing the same resource locks), via --lock-store:
servers and default configure MCP servers routed through the proxy's tools/call pipeline. hosts is a separate, sibling top-level field for configuring lock policy on host-native tools — tools built into the coding agent itself (e.g. Claude Code's Write/Edit, or OpenCode's/Codex's equivalents) that are not MCP servers and are not intercepted by the JSON-RPC proxy. It is keyed by host name, and each host's locks block uses the exact same LockRule shape (resource/ttl/onConflict) documented above.
HostPolicy fields| Field | Type | Default | Description |
|---|---|---|---|
locks | Record<string, LockRule> | — | Same shape as a server's locks block, keyed by native tool name (e.g. Write, Edit). See LockRule fields above. |
@cardor/heimdall-mcp exports CLAUDE_CODE_DEFAULT_HOST_POLICY, a ready-made HostPolicy covering Claude Code's file-mutating native tools — Write, Edit, MultiEdit, and NotebookEdit — each locking on a file_path argument with a 30s TTL.
Not yet implemented: Bash is deliberately excluded from CLAUDE_CODE_DEFAULT_HOST_POLICY and has no recommended lock rule. Bash's arbitrary shell commands have no single stable "resource" argument to lock on — a command could touch zero, one, or many files — so locking it would be either meaningless (no resource key to extract) or dangerously coarse (serializing all Bash calls globally, unrelated work included).
Status: config loading, merging, and enforcement via a real Claude Code PreToolUse hook script. The hosts field, HostPolicySchema, and CLAUDE_CODE_DEFAULT_HOST_POLICY are defined, validated, and merged (global config's hosts and local config's hosts are combined per host key, with local's locks for a given host winning entirely over global's when both set it — no field-level union, matching default/servers locks semantics). If neither config sets hosts['claude-code'], CLAUDE_CODE_DEFAULT_HOST_POLICY is applied automatically as a baseline; any user-supplied hosts['claude-code'] value (in either config) replaces the default entirely.
PreToolUse hookbin/hooks/claude-pretooluse.js is a real, working PreToolUse hook script for Claude Code. On every invocation it reads tool_name/tool_input from stdin, reloads heimdall.config.* fresh from disk (local + global, merged — never cached), matches the tool against hosts['claude-code'].locks, resolves the lock resource from the matched rule's resource argument (canonicalizing filesystem paths the same way LockRule resource resolution does elsewhere in this doc), and attempts to acquire an exclusive lock against the same default SQLite lock store used by the proxy (~/.config/heimdall/locks.db). If the lock is free, the call is allowed. If it's held by another holder, the call is denied with a human-readable reason. The hook always exits 0 and fails open (silently allows) on any unexpected error — a bug in the hook must never brick a Claude Code session.
For the full PreToolUse hook contract investigation and unresolved edge cases behind this implementation, see SPIKE_CLAUDE_HOOKS.md.
Recommended: run heimdall-mcp init --hooks claude-code to register this hook automatically:
This reads ~/.claude/settings.json (creating it if it doesn't exist), resolves the absolute path to the installed package's bin/hooks/claude-pretooluse.js, and appends a PreToolUse entry for it — without touching any other hooks.* entries or unrelated top-level settings keys. It's idempotent: running it again when the hook is already registered prints a confirmation and makes no changes, instead of adding a duplicate entry. Written via an atomic temp-file-then-rename, and it never blind-overwrites the file.
The manual, hand-edited version is still supported and useful for troubleshooting or understanding exactly what gets written — it's the same shape init --hooks claude-code produces. Add it to PreToolUse in .claude/settings.json (or .claude/settings.local.json):
Not yet implemented:
PostToolUse companion hook to release the lock once the tool call completes. Locks acquired by this hook are released only via TTL expiration (default 30s, or the rule's configured ttl), not immediately after the tool finishes — a known limitation, not a bug.tool.execute.before pluginsrc/plugins/opencode-heimdall.ts (compiled to dist/plugins/opencode-heimdall.js, re-exported by bin/plugins/opencode-heimdall.js) is an OpenCode plugin implementing the tool.execute.before hook, following the same config-loading, host-matching, and lock-acquisition logic as the Claude Code hook above — but matched against hosts['opencode'] instead of hosts['claude-code']. There is no built-in default policy for OpenCode yet (no OPENCODE_DEFAULT_HOST_POLICY equivalent to CLAUDE_CODE_DEFAULT_HOST_POLICY), so this plugin allows every tool call until you explicitly configure hosts.opencode.locks yourself, e.g.:
For the full tool.execute.before contract investigation, deny-mechanism verification method, and unresolved open questions behind this implementation, see SPIKE_OPENCODE_HOOKS.md.
Unlike Claude Code's hook (a separate process spawned per tool call, communicating over stdin/stdout), OpenCode plugins are resolved and run in-process — OpenCode itself import()s the plugin module and calls its exported factory function once at startup; there is no subprocess, no stdin/stdout, and the resulting hooks stay registered for the whole session.
Recommended: run heimdall-mcp init --hooks opencode to register this plugin automatically:
This reads ~/.config/opencode/opencode.jsonc (creating it if it doesn't exist), resolves the absolute path to the installed package's bin/plugins/opencode-heimdall.js, and appends it to the top-level plugin array — without touching any other keys. It's idempotent: running it again when the plugin is already registered prints a confirmation and makes no changes, instead of adding a duplicate entry. Written via an atomic temp-file-then-rename, and it never blind-overwrites the file.
opencode.jsonc is genuine JSONC — real configs can and do contain // comments and trailing commas. This installer uses jsonc-parser (the same library VS Code uses internally to edit settings.json) to compute a surgical text edit that appends the new array entry, rather than a plain JSON.parse/JSON.stringify round-trip — so existing comments, trailing commas, and formatting elsewhere in the file are preserved. (Comments attached directly to the appended array entry's own line may shift by one entry as a side effect of the array-insertion edit; comments elsewhere in the file are untouched.)
The manual, hand-edited version is still supported and useful for troubleshooting or understanding exactly what gets written — it's the same shape init --hooks opencode produces. Add the compiled package's plugin path to your opencode.jsonc's plugin array:
Confidence caveat on the registration mechanism itself. The plugin array's support for bare absolute file-path entries (no package.json, no npm packaging) was verified with HIGH confidence against OpenCode's real fetched source (sst/opencode, packages/opencode/src/plugin/shared.ts's isPathPluginSpec()/resolvePathPluginTarget()) cross-checked against the actually-installed OpenCode binary's embedded strings — see SPIKE_OPENCODE_HOOKS.md for the verification method. That said, like the plugin's deny mechanism documented below, this has not been validated end-to-end against a live OpenCode process actually loading and using the plugin — treat the registration step, same as the plugin itself, as experimental until independently verified against a real OpenCode session.
Confidence caveat — read before relying on this for security. OpenCode's own published documentation for tool.execute.before was not locally available during development (see SPIKE_OPENCODE_HOOKS.md), and its output type is only { args: any } — there is no confirmed deny/block/status field on this hook (contrast OpenCode's own permission.ask hook, which does have a typed status: "ask" | "deny" | "allow" field). This plugin denies a conflicting lock by throwing an Error from the hook callback, on the assumption that a rejected promise aborts the tool call — the conventional pattern for void-returning before-hooks, and nothing found contradicts it, but this has not been confirmed against a live OpenCode session. If that assumption is wrong, this plugin will silently fail to block anything while still appearing to deny (it throws; whether OpenCode's runtime actually blocks the tool call on that throw is unverified). Treat this integration as experimental until independently verified.
Not yet implemented:
tool.execute.after companion to release the lock once the tool call completes — same TTL-only-release limitation as the Claude Code hook above.init --hooks opencode registration mechanism actually being loaded by a live OpenCode process.A single, canonical list of everything not yet implemented, or not yet independently verified, across resource locks and host policies. Each item also has an inline note near the relevant section above with more local context.
'write' mode; LockRuleSchema has no mode field yet, even though the LockStore type defines 'read' | 'write'. See Semantics above.Bash. Bash is deliberately excluded from CLAUDE_CODE_DEFAULT_HOST_POLICY because arbitrary shell commands have no single stable "resource" argument to lock on — locking it would be either meaningless or dangerously coarse.PreToolUse hook has no PostToolUse companion to release the lock immediately once the tool call finishes. Locks it acquires are released only via TTL expiration (default 30s, or the rule's configured ttl) — not a bug, but a known limitation.tool.execute.before plugin has no tool.execute.after companion; release is TTL-only.Error from the tool.execute.before callback, on the assumption that a thrown error aborts the tool call. This has not been confirmed against a live OpenCode session. See SPIKE_OPENCODE_HOOKS.md.init --hooks opencode registration unverified end-to-end. The registration mechanism (appending a bare file path to opencode.jsonc's plugin array) was verified with high confidence against OpenCode's source and installed binary, but has not been validated against a live OpenCode process actually loading and using the plugin. See SPIKE_OPENCODE_HOOKS.md.For the full hook-contract investigation behind the Claude Code caveats above, see SPIKE_CLAUDE_HOOKS.md. For the OpenCode plugin registration/deny-mechanism investigation, see SPIKE_OPENCODE_HOOKS.md.
The blocked call never reaches the real server:
The client receives:
The OTel span is still recorded — with policy.blocked = true and mcp.error.code = -32001 — so you get a full audit trail of what was attempted and blocked.
tools/list, prompts/list, and resources/list responses are also filtered: denied entries are removed before the client sees them. The agent never learns a denied tool exists.
A tools/call blocked by an active resource lock returns a distinct JSON-RPC error with structured holder info in error.data:
-32600 (exported as RESOURCE_LOCKED) is used deliberately here even though it collides with JSON-RPC 2.0's reserved "Invalid Request" range — see the code comment in LockInterceptor.ts for the rationale.
Set onConflict: 'warn' on a lock rule to forward the call instead of blocking — the OTel span gets lock.conflict_warning = true, lock.resource_key, lock.held_by, and lock.expires_at attributes instead of an error response.
Policy entries are keyed by server name. Use --server-name to set it explicitly in your MCP config:
--server-name overrides the name from the server's initialize response — for both policy lookup and the mcp.server.name OTel attribute.
The MCP client thinks it is talking to heimdall-mcp. The proxy spawns the real server as a child process and forwards all messages.
mcp.json / Claude Desktop configuration:
The -- separator divides heimdall-mcp flags from the real server command. Everything after it is executed as a subprocess.
With a globally installed server:
With Postgres instead of SQLite:
When the MCP server is already running and exposes an HTTP endpoint.
The proxy exposes stdio to the client and forwards each message as an HTTP POST to the target URL.
For servers that use Server-Sent Events.
The proxy connects to {target}/sse to receive responses and sends requests as POST to {target}.
When you have access to the source code and want to integrate the proxy programmatically.
Minimal setup:
stdio → remote HTTP:
HTTP inbound (proxy listens on a port):
With OTLP export and debug logging:
With a custom interceptor:
No external server required — ideal for local development.
Valid connection strings:
Driver: @libsql/client — pure WASM, no native compilation required.
Schema:
Note: SQLite uses
INTEGERfor nanosecond timestamps because SQLite has no nativeBIGINTtype — the integer affinity handles large values correctly.
Driver: postgres — pure JS, no node-gyp.
Schema differences from SQLite:
start_time_unix_nano / end_time_unix_nano → BIGINT (native 64-bit, exact for nanoseconds)attributes / events / links / resource_attributes → JSONB (indexable, queryable)avg_duration → REALupdated_at → TIMESTAMPcreated_at → TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMPDriver: mysql2.
Schema differences from SQLite:
span_id / trace_id / name → VARCHAR(64/512) (explicit lengths)start_time_unix_nano / end_time_unix_nano → BIGINT (native 64-bit, exact for nanoseconds)attributes / events / links / resource_attributes → JSONavg_duration → FLOATid in metrics → BIGINT UNSIGNED AUTO_INCREMENTupdated_at → TIMESTAMP(3) (millisecond precision)Every JSON-RPC message produces a span in the heimdall_spans table. All attributes follow the mcp.* namespace for interoperability with other MCP-aware tools.
| MCP method | Span name | Key attributes |
|---|---|---|
initialize | mcp.initialize | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
tools/list | mcp.tools.list | + mcp.server.name, mcp.server.version, mcp.response_mode, mcp.response.body_hash |
tools/call | mcp.tool.call | + mcp.tool.name, mcp.request.body_hash, mcp.response.body_hash, mcp.latency.proxy_to_server_ms, mcp.latency.proxy_overhead_ms |
resources/read | mcp.resource.read | + mcp.server.name, mcp.server.version |
resources/list | mcp.resources.list | + mcp.server.name, mcp.server.version |
prompts/get | mcp.prompt.get | + mcp.server.name, mcp.server.version |
prompts/list | mcp.prompts.list | + mcp.server.name, mcp.server.version |
shutdown | mcp.shutdown | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
| any other | mcp.{method} | mcp.jsonrpc.method, mcp.jsonrpc.id, mcp.trace.request_id, mcp.transport, mcp.status, duration.ms |
Common attributes on every span:
| Attribute | Description |
|---|---|
mcp.rpc.system | Always "mcp" |
mcp.jsonrpc.method | The JSON-RPC method name |
mcp.jsonrpc.id | JSON-RPC id from the frame, coerced to string. Empty for notifications |
mcp.trace.request_id | Proxy-generated per-request correlation ID (stable across numeric/string/null JSON-RPC IDs) |
mcp.transport | stdio, http, or sse |
mcp.status | ok · error · timeout · cancelled |
mcp.server.name | Name of the real MCP server (captured from initialize response) |
mcp.server.version | Version of the real MCP server (captured from initialize response) |
duration.ms | Total round-trip latency in milliseconds |
Latency breakdown (on tools/call):
| Attribute | Description |
|---|---|
mcp.latency.proxy_to_server_ms | Time the real server took to respond |
mcp.latency.proxy_overhead_ms | Overhead introduced by the proxy itself |
Body capture modes:
Body capture is controlled by --body-mode (CLI) or .setBodyMode() (library). Default is redacted.
| Mode | mcp.tool.request / mcp.tool.response | mcp.request.body_hash | mcp.response.body_hash |
|---|---|---|---|
redacted (default) | — (omitted) | sha256:<hex> (always) | [redacted] |
hash | — (omitted) | sha256:<hex> (always) | sha256:<hex> |
full | raw JSON | sha256:<hex> (always) | sha256:<hex> |
mcp.request.body_hash is always a real hash regardless of mode — use it to correlate repeated identical calls without exposing the payload. mcp.response.body_hash follows the redaction setting.
Use
fullonly for local development — raw bodies in shared OTLP backends can leak secrets.
Agent correlation (optional):
If the MCP client sends a _meta object inside params, heimdall-mcp will automatically extract and record these attributes:
_meta field | Span attribute |
|---|---|
conversationId | gen_ai.conversation.id |
turnId | gen_ai.turn.id |
agentRunId | gen_ai.agent.run.id |
As a fallback, the env vars MCP_CONVERSATION_ID, MCP_TURN_ID, and MCP_AGENT_RUN_ID are used if set.
On error, every span also gets mcp.error.type (protocol · tool · proxy · transport), mcp.error.message, and mcp.error.code attributes plus an error OTel event attached to the span.
When a call is blocked by policy, the span additionally includes policy.blocked = true — so you can query for attempted-but-blocked calls separately from real errors.
Every span's resource_attributes column contains OTel resource metadata:
service.name — @cardor/heimdall-mcpservice.version — package versionservice.namespace — mcp-proxyThe schema follows the OpenTelemetry data model natively:
BIGINT in Postgres/MySQL, INTEGER in SQLite)kind is an integer SpanKind (0=INTERNAL, 1=SERVER, 2=CLIENT, 3=PRODUCER, 4=CONSUMER)status is an integer SpanStatusCode (0=UNSET, 1=OK, 2=ERROR)This means rows can be consumed directly by any OTel-compatible tool without transformation.
heimdall-mcp can export every span to a Jaeger instance in real time via OTLP HTTP, so you can visualize traces without querying the database directly.

--otlp to your configFor the HTTP/SSE variant (e.g. the setup used during development of this project):
Select service heimdall-mcp and click Find Traces. Each MCP method (mcp.tool.call, mcp.initialize, mcp.tools.list, …) appears as a separate trace with full attributes and input/output event bodies.
Dark mode — append ?uiConfig={"theme":"dark"} to the URL, or mount a config file:
The
--otlpflag is additive — spans are saved to the database and exported to Jaeger at the same time.
The Interceptor interface is public. You can add your own logic into the pipeline before the telemetry interceptor:
Calling next() passes control to the next interceptor in the chain. ForwardInterceptor is always last — it makes the actual call to the real server and records latency.proxy_to_server_ms in context.metadata for the telemetry interceptor to read.
You can use context.metadata to pass data between your interceptor and others in the same pipeline run.
The default command. Starts the proxy.
start auto-discovers heimdall.config.{ts,js,json} in the current working directory and ~/.config/heimdall/heimdall.config.* at startup. Config load errors print a warning but never crash the proxy.
Scaffolds a heimdall.config.ts with commented examples. With --hooks <host>, registers a native-tool hook for a coding agent instead (see Claude Code PreToolUse hook and OpenCode tool.execute.before plugin).
Validates the merged policy config and reports per-server policies. Does not connect to any MCP server.
Example output:
If the same entity appears in both allow and deny, health exits with code 1 and lists all conflicts.
The complete attribute vocabulary — required attributes, optional attributes, body capture fields, error classification, and annotated span examples for initialize, tools/list, tools/call, and a proxy failure case — is documented in TRACING.md.
| Phase | Feature | Status |
|---|---|---|
| 1 | Auto-discovered heimdall.config.{ts,js,json} — local + global, allow/deny lists per server | ✅ Done (v1.2) |
| 2 | PolicyInterceptor — blocked calls return JSON-RPC error -32001, recorded in OTel with policy.blocked = true | ✅ Done (v1.2) |
| 3 | heimdall-mcp init + heimdall-mcp health CLI commands | ✅ Done (v1.2) |
| 4 | Stable tracing vocabulary — mcp.jsonrpc.id, mcp.trace.request_id, mcp.error.type, per-direction body hashes, TRACING.md spec | ✅ Done (v1.3) |
| 5 | Filter by action type (read / write / execute) inferred from tool name or MCP metadata | 📋 Planned |
| 6 | Runtime enforcement modes: warn (log + forward), audit (span with policy.violation = true) | 📋 Planned |
| 7 | Resource locks — locks config schema, LockStore, TTL-based expiration, lock-specific JSON-RPC error code, canonical path resolution, Postgres/MySQL-backed stores, hook integrations | 🚧 In progress (write-mode locking with canonical path resolution, RESOURCE_LOCKED error code, onConflict reject/warn modes, and Postgres/MySQL-backed stores implemented; hook integrations and read-mode locking pending) |