The gap
Heimdall MCP is a transparent MCP proxy — it sits between your MCP client and any server, records every call as an OpenTelemetry span, and enforces per-server allow/deny policies without touching server code.
The policy layer in v1.3 could answer one question: can this tool be called at all? If read_file was in the allowlist, every call to read_file went through — regardless of the path argument. That's a meaningful gap. The tool name check was never the full story.
v1.4 closes it.
Website: https://stack.cardor.dev/heimdall
What shipped
A new toolPolicies field in heimdall.config.ts lets you define per-argument constraints on tools that already passed the name check. It's a separate field from tools (which stays unchanged) — so every existing config works without modification.
// heimdall.config.ts
export default {
servers: {
filesystem: {
tools: { allow: ['read_file', 'list_directory'] }, // unchanged
toolPolicies: {
// '*' applies to all tools — merged first, tool-specific overrides on conflict
'*': {
args: {
path: { isPath: true, deny_pattern: ['\\.env$', '\\.pem$'] },
},
},
read_file: {
args: {
path: {
isPath: true,
allow_pattern: './', // must resolve within cwd
deny_pattern: ['\\.env$'], // never .env files
},
encoding: {
allow_pattern: ['utf-8', 'utf8', 'ascii'],
},
},
},
},
},
},
} satisfies HeimdallConfig;
ArgConstraint fields
| Field | Type | Default | Description |
isPath | boolean | false | Enables path-aware matching instead of regex |
allow_pattern | string \| string[] | — | Arg must match at least one pattern |
deny_pattern | string \| string[] | — | Blocked if matches any pattern; deny wins |
array_mode | 'all' \| 'any' | 'all' | For array-typed args: all items must pass, or at least one |
case_sensitive | boolean | true | Regex flag; ignored for path matching |
warn_only | boolean | false | Record violation in span without blocking |
Path scoping
When 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 that directory |
"~" / "${HOME}/projects" | Resolved to homedir |
"${CWD}/data" | Resolved to cwd + /data |
The resolver uses path.resolve + fs.realpathSync on the deepest existing ancestor of the path, which handles non-existent files (e.g. pre-creation checks), ../ traversal, and symlink escapes. /tmp on macOS resolves to /private/tmp and containment is checked correctly.
Patterns that don't look like directory roots (e.g. "^/etc/.*", "\\.env$") fall back to standard regex matching.
warn_only mode
Useful for gradual rollout. Set warn_only: true on any constraint to observe violations without blocking:
path: { isPath: true, allow_pattern: './', warn_only: true }
The call is forwarded and these attributes appear in the OTel span:
policy.arg_warning = true
policy.arg_warning_field = "path"
policy.arg_warning_message = "Tool arg 'path' does not match the allow policy"
Switch to warn_only: false (the default) when you're ready to enforce.
'*' in toolPolicies applies constraints to all tools. Tool-specific entries are merged on top — specific wins on conflict, wildcard fills in fields the specific entry doesn't define.
Dot notation accesses nested argument objects:
toolPolicies: {
my_tool: {
args: {
'options.target': { isPath: true, allow_pattern: './' },
},
},
}
Merge strategy (global + local configs)
The same security-first semantics from the tools field apply to toolPolicies when merging a global and local config:
deny_pattern: union — denied by either = denied
allow_pattern: intersection — must be in both; empty/missing defers to the other side
- Structural fields (
isPath, array_mode, warn_only): local wins
What Heimdall could already do (context)
For those new to the project: Heimdall MCP is a transparent proxy for any MCP server. Before v1.4 it already provided:
- OpenTelemetry tracing — every tool call as an OTel span with latency breakdown, request/response hashes, and error classification. Exportable to Jaeger, Tempo, or any OTLP backend.
- Persistent storage — spans saved to SQLite, PostgreSQL, or MySQL via Drizzle ORM.
- Tool name policies — per-server allow/deny lists for tools, prompts, and resources. Denied calls return JSON-RPC error
-32001 and never reach the server.
- Body modes —
full, hash, or redacted for request/response capture.
- Four transport modes — stdio subprocess, HTTP, SSE, or library (embed in your own Node.js app).
v1.4 adds the argument layer on top of name-layer policies. Zero breaking changes.
Implementation notes
toolPolicies is a sibling field of tools in ServerPolicy — existing tools allow/deny lists are untouched.
- Validation via
valibot at startup — descriptive errors for misconfigured constraints.
PolicyInterceptor pipeline position unchanged: name check runs first, then arg check. If name is blocked, arg check is skipped.
- All 192 tests pass including 43 new tests for path resolver and argument policies.
Links