Skip to main content

Command Palette

Search for a command to run...

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

Updated
5 min readView as Markdown
One Does Not Simply read_file('/etc/passwd') — Argument Policies Land in Heimdall MCP
E

I am a computer science engineer, I have also been a member of CERN’s Summer Students program in 2022. I am constantly learning and reviewing new web development technologies Frontend, Backend, DevOps

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.

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 -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.
V
Varsha1mo ago

This is exactly the kind of MCP security issue people overlook. Tool access is powerful, but argument level controls matter just as much. It’s not enough to say “this tool is allowed.” The system also needs to know what inputs should never pass through.

R

The LOTR title is perfect — argument validation in MCP is no joke, especially when sensitive paths like /etc/passwd are potentially accessible.