DEV Community

Cover image for One Does Not Simply read_file('/etc/passwd') — Argument Policies Land in Heimdall MCP

One Does Not Simply read_file('/etc/passwd') — Argument Policies Land in Heimdall MCP

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;
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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: './' },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

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 modesfull, 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

Top comments (0)