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[]` | — |
deny_pattern |
`string \ | string[]` | — |
array_mode |
`'all' \ | 'any'` | 'all' |
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.
Wildcard tool key and dot notation
'*' 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
-32001and never reach the server. -
Body modes —
full,hash, orredactedfor 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
-
toolPoliciesis a sibling field oftoolsinServerPolicy— existingtoolsallow/deny lists are untouched. - Validation via
valibotat startup — descriptive errors for misconfigured constraints. -
PolicyInterceptorpipeline 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
- GitHub: https://github.com/enmanuelmag/heimdall-mcp
-
npm:
npm install -g @cardor/heimdall-mcp - Changelog: https://github.com/enmanuelmag/heimdall-mcp/blob/main/CHANGELOG.md
Top comments (0)