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

1 8
calendar_todayschedule3 min read

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:

```typescript
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.
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1

MCP Is the USB-C of AI. So Why Are You Plugging Everything In?

Ken W. Algerverified - Jun 10

AI Reliability Gap: Why Large Language Models are not for Safety-Critical Systems

praneeth - Mar 31

Architecting a Local-First Hybrid RAG for Finance

Pocket Portfolio - Feb 25

Comparison: Universal Import vs. Plaid/Yodlee

Pocket Portfolio - Mar 12
chevron_left
259 Points9 Badges
Guayaquil, Ecuadorenmanuelmag.cardor.dev
6Posts
1Comments
2Connections
@TawsEspol member

· Engineer in Computer Science

· Technical Consultant @ North Highland

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
3 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!