This document describes the security boundaries, access controls, and known exposure risks of the OpenROAD MCP server. It covers the Tcl command whitelist, the PTY spawn allowlist, every environment variable that affects security-relevant behaviour, path containment for report images, and the HTTP transport's exposure surface.
- Tcl Command Whitelist
- PTY Spawn Allowlist
- Environment Variable Reference
- Report Image Path Containment
- HTTP Transport Exposure
- Exec is Not a Sandbox
The whitelist is implemented in
typescript/src/config/command_whitelist.ts. It
guards Tcl statements sent to the OpenROAD REPL, not the shell binary (that is the PTY spawn
allowlist below).
Tier 1 — BLOCKED_COMMANDS (denied in both tools)
These are OS-level Tcl built-ins that can escape the EDA context regardless of OpenROAD's own access controls.
| Command | Reason Blocked |
|---|---|
quit |
terminates the OpenROAD process |
socket |
opens arbitrary network connections |
load |
loads compiled C extensions into the interpreter |
glob |
enumerates the filesystem |
fconfigure |
configures I/O channels |
chan |
channel operations |
vwait |
blocks the event loop indefinitely |
rename |
renames or deletes commands, bypassing top-level checks |
after |
schedules arbitrary code execution |
subst |
performs substitutions that can invoke arbitrary commands |
Tier 2 — EXEC_ONLY_PATTERNS (denied in query, allowed in exec)
These are explicitly state-modifying or file-system-touching commands. They are allowed in
interactive_openroad_exec and rejected in interactive_openroad_query.
Exact verbs: exec, source, exit, open, close, file, cd, uplevel
Wildcard patterns: set_*, create_*, read_*, write_*
Named flow commands: initialize_floorplan, place_pins, global_placement,
detailed_placement, clock_tree_synthesis, global_route, detailed_route,
repair_design, repair_timing, repair_clock_nets, log_begin, log_end
Tier 3 — READONLY_PATTERNS (allowed in both tools)
Wildcard patterns: report_*, get_*, check_*
Named commands: estimate_parasitics, sta, help, version
Safe Tcl built-ins: puts, set, expr, return, break, continue, list, llength,
lindex, lappend, lrange, lsort, lsearch, lreplace, string, regexp, regsub,
format, scan, array, dict, error, upvar, global, variable, concat, join,
split, incr, append, info, unset
Unknown commands are treated as exec-only: denied in query, allowed in exec. They will fail at the Tcl level inside OpenROAD if they are not valid commands.
| Context | interactive_openroad_query |
interactive_openroad_exec |
|---|---|---|
| Default policy | deny (only READONLY_PATTERNS pass) | allow (only BLOCKED_COMMANDS fail) |
| Exec-only verbs | rejected | allowed |
| Unknown verbs | rejected (treated as exec-only) | allowed |
| Blocked verbs | rejected | rejected |
When a command is rejected the tool returns a JSON response — no exception is thrown to the client:
{
"output": "",
"session_id": "sess-0001",
"timestamp": "...",
"execution_time": 0,
"command_count": 0,
"buffer_size": 0,
"error": "CommandBlocked: 'exit'",
"message": "Command blocked: 'exit' is not on the OpenROAD allowlist.\nFull command: 'exit'"
}The whitelist parses multi-statement input before checking:
- Statement splitting respects
"quotes,{}braces, and backslash escapes. It splits on;,\n, and a range of Unicode line separators. - Bracket substitution (
[command ...]) is scanned recursively —set x [exec ls]extracts verbexecfrom the inner command. - Body-eval builtins (
if,for,foreach,while,proc,catch,namespace) are not in_TCL_BUILTINS, so they reach the default "unknown → exec-only" path and are rejected in query. evalis not inBLOCKED_COMMANDS, so it is allowed in exec. In query it is rejected as unknown.- Backslash obfuscation is neutralised:
tclUnescape()normalises\socket,\x73ocket, and octal escapes before matching. - Comments (lines starting with
#and blank lines, including any brackets inside them) are skipped entirely.
Set OPENROAD_WHITELIST_ENABLED=false to skip all Tcl checks. Intended for trusted development
environments where the full ORFS Tcl API is needed without restriction. Never expose an
unwhitelisted server over HTTP without additional access controls.
A separate layer in
typescript/src/interactive/pty_handler.ts
guards the shell binary and arguments at session creation, independently of the Tcl whitelist.
When OPENROAD_ENABLE_COMMAND_VALIDATION=true (default):
- The executable name must be in
OPENROAD_ALLOWED_COMMANDS(default:["openroad"]). - No argument may contain shell metacharacters (
;,&,|,`,$,\n,\r). - No argument may contain path traversal (
..). - No argument may contain redirection operators (
>,<).
To allow additional executables (e.g. sta):
OPENROAD_ALLOWED_COMMANDS=openroad,staSet OPENROAD_ENABLE_COMMAND_VALIDATION=false to skip spawn validation entirely.
All variables are read at startup by typescript/src/config/settings.ts.
| Variable | Type | Default | What it gates |
|---|---|---|---|
OPENROAD_COMMAND_TIMEOUT |
float (seconds) | 30.0 |
Per-command timeout; override per-call with timeout_ms |
OPENROAD_COMMAND_COMPLETION_DELAY |
float (seconds) | 0.1 |
Delay before declaring command completion. |
OPENROAD_DEFAULT_BUFFER_SIZE |
integer (bytes) | 131072 (128 KiB) |
Circular output buffer max size per session |
OPENROAD_MAX_SESSIONS |
integer | 50 |
Maximum concurrent active sessions |
OPENROAD_SESSION_QUEUE_SIZE |
integer | 128 |
Maximum pending commands in input queue |
OPENROAD_SESSION_IDLE_TIMEOUT |
float (seconds) | 300.0 |
Idle threshold; does not trigger automatic cleanup (see Session Lifecycle Notes) |
OPENROAD_READ_CHUNK_SIZE |
integer (bytes) | 8192 |
Max chunk size when splitting large PTY bursts |
OPENROAD_IMAGE_MAX_BASE64_KB |
integer (KB) | 1024 |
Report-image payload budget; override per-call with max_size_kb |
OPENROAD_IMAGE_MAX_DIMENSION |
integer (pixels) | 1568 |
Longest edge an image is capped to before encoding |
OPENROAD_IMAGE_MIN_DIMENSION |
integer (pixels) | 512 |
Floor below which the resize ladder will not shrink |
OPENROAD_OUTPUT_HISTORY_CHARS |
integer (chars) | 262144 (256 KB) |
Recent command output retained per session for grep_session_output; 0 disables |
OPENROAD_OUTPUT_HISTORY_COMMANDS |
integer | 50 |
Commands of output retained per session; 0 disables |
OPENROAD_MAX_FLOW_JOBS |
integer | 2 |
Concurrent run_orfs_stage runs allowed |
OPENROAD_FLOW_RUN_TIMEOUT |
float (seconds) | 21600 (6 h) |
Default wall-clock budget for a flow run |
OPENROAD_RUN_LOG_DIR |
path | <tmpdir>/openroad-mcp-runs |
Where flow-run logs are streamed |
OPENROAD_ALLOWED_COMMANDS |
string (comma-separated) | openroad |
PTY spawn executable allowlist |
OPENROAD_ENABLE_COMMAND_VALIDATION |
bool | true |
Enables/disables PtyHandler.validateCommand |
OPENROAD_WHITELIST_ENABLED |
bool | true |
Enables/disables the Tcl command whitelist |
ORFS_FLOW_PATH |
path | ~/OpenROAD-flow-scripts/flow (auto-detected if unset) |
Root for ORFS reports; tilde-expanded at runtime |
LOG_LEVEL |
string | INFO |
Root pino logger level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
LOG_FORMAT |
string | (N/A) | Unused; logging uses pino with a fixed JSON format |
CLI flags --verbose and --log-level override LOG_LEVEL after the settings are initialised.
No other CLI flag overrides a Settings field.
PATH is not a Settings field. At startup the server inherits the client's PATH, then (only if openroad is not already found) merges the login-shell PATH and common install locations. An explicit PATH in the MCP client env block still wins when it already contains openroad.
Report images are served from:
{ORFS_FLOW_PATH}/reports/{platform}/{design}/{run_slug}/
The containment enforcement in
typescript/src/utils/path_security.ts works in
two layers:
Layer 1 — Segment Validation (applied to run_slug and image_name independently):
- Empty or whitespace-only value → rejected
.or..→ rejected- Contains
/or\→ rejected - Contains null byte
\x00→ rejected - Contains glob characters
* ? [ ]→ rejected
Layer 2 — Realpath Containment (applied after joining paths):
The resolved real path (via realpathSync, with a walk-up for non-existent suffixes) must be
under the base directory. A symlink that points outside the base is caught here.
Additional constraints:
- Only report-image extensions are served —
.webp,.png, and the doubled.webp.pngform some ORFS builds emit. The listing skips everything else andread_report_imagerejects anyimage_namethat does not end in one of them. - Symlinks are skipped during directory listing.
- On-disk file size is capped at 50 MB before any decoding.
- The base64 payload is budgeted at
OPENROAD_IMAGE_MAX_BASE64_KB(default 1024 KB), which a caller may override per-call withmax_size_kb. An image over budget is downscaled with sharp (lanczos3, WebP quality 85 → 70 → 55) along a ladder bounded byOPENROAD_IMAGE_MAX_DIMENSION(default 1568 px) above andOPENROAD_IMAGE_MIN_DIMENSION(default 512 px) below, and at most 12 encode attempts. A caller raisingmax_size_kbraises the payload this server will emit; the 50 MB on-disk cap above still bounds what is read.
run_orfs_stage spawns make, which OPENROAD_ALLOWED_COMMANDS does not cover — that setting
gates the openroad binary for PTY sessions only. The flow runner therefore carries its own policy.
Target allowlist. Only synth, floorplan, place, cts, grt, route, finish, all,
metadata and the clean_* forms are accepted. This is not cosmetic: without it a stage value
of -f/tmp/evil.mk reaches make as a flag rather than a goal, redirecting it to an attacker's
makefile.
Override validation. Keys must match ^[A-Z_][A-Z0-9_]*$. These are refused regardless of
value, because they control how make executes every recipe rather than what it builds:
SHELL, MAKESHELL, .SHELLFLAGS, MAKE, MAKEFLAGS, MAKEFILES, PATH, LD_PRELOAD,
LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES.
Values are rejected if they contain newlines or null bytes (which would forge additional make
arguments), or $(, ${ or a backtick. The last matters even though no shell is involved: make
expands $(...) when it reads a variable value, so FOO=$(shell id) is a live execution path.
No shell. The child is spawned with an argv array and shell: false, so an override value is
one argument and is never re-parsed.
Process group. Runs are spawned detached, into their own process group, so cancel_orfs_job,
the timeout, and server shutdown can signal the whole tree. Killing only make would leave the
openroad it spawned running — orphaned OpenROAD processes have been observed on this deployment.
Resource governance. OPENROAD_MAX_FLOW_JOBS (default 2) caps concurrent runs, and
OPENROAD_FLOW_RUN_TIMEOUT (default 6 h) bounds each one. A flow run is far heavier than an
interactive session.
Still privileged. run_orfs_stage writes into the ORFS flow tree and the clean_* targets
delete results; it is annotated destructiveHint: true. The policy above constrains what make is
told to build, not what the design's own configuration causes it to run.
read_orfs_metrics reads only two locations under the ORFS flow root — the stage metrics and logs
in logs/<platform>/<design>/<variant>/, and designs/<platform>/<design>/rules-base.json. It
writes nothing and executes nothing.
designandvariantare validated as single path segments (validatePathSegment), so separators,.., null bytes and glob characters are rejected.- The resolved logs directory is checked with
validateSafePathContainmentagainst<flow>/logs, which resolves symlinks in existing parents, so a symlinked run directory cannot escape the flow tree. platformis not free-form: it is either inferred from the design or checked against the platforms ORFS actually has.- Every path in the response is relative to the flow root, so absolute host paths are not disclosed.
- Stage logs are returned filtered to ORFS's own
[ERROR ...]/[WARNING ...]lines, capped at 50 per category per stage, rather than as raw log contents.
Known image filename mappings:
Images are classified by filename stem: the stage is the prefix before the first _, and the
type is looked up in a fixed mapping. Filenames not in the mapping get type: "unknown".
Recognised stems: cts_clk, cts_clk_layout, cts_core_clock, cts_core_clock_layout,
final_all, final_clocks, final_congestion, final_ir_drop, final_placement,
final_resizer, final_routing.
Start the server with --transport http (default localhost:8000) to expose it over Streamable
HTTP instead of stdio.
There is no authentication, no CORS policy, and no DNS-rebinding protection in the application
code. Any process that can reach localhost:8000 can call any tool.
Practical guidance:
- Use HTTP mode only in trusted, isolated networks (local development, a private cluster with network policies).
- Add a reverse proxy with authentication (e.g. nginx + mTLS, Tailscale) if you need to expose the endpoint beyond localhost.
- The server creates a fresh MCP instance per request; session continuity is maintained via
explicit
session_idparams, not HTTP session cookies. - The request body is capped at 1 MB (
MAX_BODY_BYTES); oversized requests are rejected with HTTP 400.
The whitelist is a guardrail against accidental misuse by AI agents, not a security sandbox.
It prevents the most obvious footguns (quit, network, eval-style injection) but it does not:
- Prevent OpenROAD from reading or writing files through its own C++ API.
- Prevent
exec(allowed in exec tool) from spawning additional processes. - Isolate the OpenROAD process from the host filesystem.
- Protect against a compromised or malicious
openroadbinary.
For a hardened deployment, run the server and OpenROAD inside a container with a restricted filesystem mount and no network access beyond what ORFS requires.