Skip to content

Commit 105e2e8

Browse files
authored
Feat/core manager whitelist (#127)
* add pino logger writing to stderr to keep stdout clean for mcp transport * port command whitelist with minimatch replacing fnmatch for verb matching * add zod result schemas using nullable default null to match pydantic none serialization * expose pty process pid getter for session performance sampling * add session metrics, command history, and idle timeout tracking * add openroad manager singleton for session lifecycle and metrics aggregation * add command whitelist tests ported from python suite * add manager tests mocking the interactive session constructor * add tests for session metrics, history, and idle timeout methods * fix iterVerbs to split on all unicode line boundaries closing \r bypass * pre-compile minimatch patterns at module load for 30x faster verb matching * remove dead CommandRecord zod schema * consolidate pidusage calls into single sample per getDetailedMetrics call
1 parent 94f876a commit 105e2e8

9 files changed

Lines changed: 1620 additions & 3 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
BLOCKED_COMMANDS,
4+
EXEC_ONLY_PATTERNS,
5+
READONLY_PATTERNS,
6+
extractVerb,
7+
isCommandAllowed,
8+
isExecCommand,
9+
isQueryCommand,
10+
} from "../../src/config/command_whitelist.js";
11+
12+
// extractVerb
13+
14+
describe("extractVerb", () => {
15+
it("returns a simple command", () => {
16+
expect(extractVerb("report_checks")).toBe("report_checks");
17+
});
18+
19+
it("returns only the first token for a command with args", () => {
20+
expect(extractVerb("report_checks -path_delay max")).toBe("report_checks");
21+
});
22+
23+
it("strips leading whitespace", () => {
24+
expect(extractVerb(" get_nets *")).toBe("get_nets");
25+
});
26+
27+
it("returns null for an empty string", () => {
28+
expect(extractVerb("")).toBeNull();
29+
});
30+
31+
it("returns null for blank whitespace", () => {
32+
expect(extractVerb(" ")).toBeNull();
33+
});
34+
35+
it("returns null for a comment line", () => {
36+
expect(extractVerb("# this is a comment")).toBeNull();
37+
});
38+
39+
it("returns null for a comment with leading whitespace", () => {
40+
expect(extractVerb(" # comment")).toBeNull();
41+
});
42+
43+
it("returns $-prefixed tokens as-is for rejection", () => {
44+
expect(extractVerb("$variable")).toBe("$variable");
45+
});
46+
47+
it("returns [-prefixed tokens as-is for rejection", () => {
48+
expect(extractVerb("[report_wns]")).toBe("[report_wns]");
49+
});
50+
51+
it("strips a trailing semicolon", () => {
52+
expect(extractVerb("puts;")).toBe("puts");
53+
});
54+
});
55+
56+
// Pattern set membership
57+
58+
describe("pattern sets", () => {
59+
it("READONLY contains report/get/check globs", () => {
60+
expect(READONLY_PATTERNS).toContain("report_*");
61+
expect(READONLY_PATTERNS).toContain("get_*");
62+
expect(READONLY_PATTERNS).toContain("check_*");
63+
});
64+
65+
it("READONLY contains Tcl builtins", () => {
66+
expect(READONLY_PATTERNS).toContain("puts");
67+
expect(READONLY_PATTERNS).toContain("foreach");
68+
expect(READONLY_PATTERNS).toContain("set");
69+
});
70+
71+
it("EXEC_ONLY contains set_*/read_*/write_* globs", () => {
72+
expect(EXEC_ONLY_PATTERNS).toContain("set_*");
73+
expect(EXEC_ONLY_PATTERNS).toContain("read_*");
74+
expect(EXEC_ONLY_PATTERNS).toContain("write_*");
75+
});
76+
77+
it("EXEC_ONLY contains flow commands", () => {
78+
expect(EXEC_ONLY_PATTERNS).toContain("global_placement");
79+
expect(EXEC_ONLY_PATTERNS).toContain("detailed_route");
80+
});
81+
82+
it("EXEC_ONLY does not contain report_*", () => {
83+
expect(EXEC_ONLY_PATTERNS).not.toContain("report_*");
84+
});
85+
86+
it("READONLY does not contain set_* (exec-only setter)", () => {
87+
expect(READONLY_PATTERNS).not.toContain("set_*");
88+
});
89+
90+
it("ORFS file ops are exec-only, not blocked", () => {
91+
for (const cmd of ["exec", "source", "exit", "open", "close", "file", "cd", "uplevel"]) {
92+
expect(EXEC_ONLY_PATTERNS).toContain(cmd);
93+
expect(BLOCKED_COMMANDS.has(cmd)).toBe(false);
94+
}
95+
});
96+
97+
it("BLOCKED contains all 10 OS-level commands", () => {
98+
for (const cmd of [
99+
"quit",
100+
"socket",
101+
"load",
102+
"glob",
103+
"fconfigure",
104+
"chan",
105+
"vwait",
106+
"rename",
107+
"after",
108+
"subst",
109+
]) {
110+
expect(BLOCKED_COMMANDS.has(cmd)).toBe(true);
111+
}
112+
expect(BLOCKED_COMMANDS.size).toBe(10);
113+
});
114+
});
115+
116+
// isQueryCommand
117+
118+
describe("isQueryCommand", () => {
119+
it("allows report_*", () => {
120+
expect(isQueryCommand("report_checks -path_delay max")).toEqual([true, null]);
121+
});
122+
123+
it("allows get_*", () => {
124+
expect(isQueryCommand("get_nets *")).toEqual([true, null]);
125+
});
126+
127+
it("allows check_*", () => {
128+
expect(isQueryCommand("check_placement")).toEqual([true, null]);
129+
});
130+
131+
it("allows sta", () => {
132+
expect(isQueryCommand("sta")).toEqual([true, null]);
133+
});
134+
135+
it("allows help", () => {
136+
expect(isQueryCommand("help")).toEqual([true, null]);
137+
});
138+
139+
it("allows puts", () => {
140+
expect(isQueryCommand("puts hello")).toEqual([true, null]);
141+
});
142+
143+
it("allows bare set (Tcl assignment)", () => {
144+
expect(isQueryCommand("set x 42")).toEqual([true, null]);
145+
});
146+
147+
it("blocks set_* (exec-only)", () => {
148+
expect(isQueryCommand("set_clock_period -name clk 2.0")).toEqual([false, "set_clock_period"]);
149+
});
150+
151+
it("blocks read_db (exec-only)", () => {
152+
expect(isQueryCommand("read_db /path/to/design.odb")).toEqual([false, "read_db"]);
153+
});
154+
155+
it("blocks write_db (exec-only)", () => {
156+
expect(isQueryCommand("write_db /out/design.odb")).toEqual([false, "write_db"]);
157+
});
158+
159+
it("blocks flow commands (exec-only)", () => {
160+
expect(isQueryCommand("global_placement")).toEqual([false, "global_placement"]);
161+
});
162+
163+
it("denies blocked exec", () => {
164+
expect(isQueryCommand("exec ls -la")).toEqual([false, "exec"]);
165+
});
166+
167+
it("blocks unknown commands as exec-only", () => {
168+
expect(isQueryCommand("pdngen")).toEqual([false, "pdngen"]);
169+
});
170+
171+
it("allows a comment-only line", () => {
172+
expect(isQueryCommand("# comment")).toEqual([true, null]);
173+
});
174+
175+
it("allows an empty command", () => {
176+
expect(isQueryCommand("")).toEqual([true, null]);
177+
});
178+
179+
it("allows a multiline all-readonly command", () => {
180+
expect(isQueryCommand("report_checks\nreport_wns\nget_nets *")).toEqual([true, null]);
181+
});
182+
183+
it("blocks a multiline command with one exec verb", () => {
184+
expect(isQueryCommand("report_checks\nglobal_placement")).toEqual([false, "global_placement"]);
185+
});
186+
187+
it("rejects [exec ls] without allowlist bypass", () => {
188+
expect(isQueryCommand("[exec ls]")).toEqual([false, "[exec"]);
189+
});
190+
191+
it("rejects $cmd without allowlist bypass", () => {
192+
expect(isQueryCommand("$cmd")).toEqual([false, "$cmd"]);
193+
});
194+
195+
it("splits on semicolons and rejects the offending verb", () => {
196+
expect(isQueryCommand("report_wns; global_placement")).toEqual([false, "global_placement"]);
197+
});
198+
});
199+
200+
// isExecCommand
201+
202+
describe("isExecCommand", () => {
203+
it("allows set_clock_period", () => {
204+
expect(isExecCommand("set_clock_period -name clk 2.0")).toEqual([true, null]);
205+
});
206+
207+
it("allows create_clock", () => {
208+
expect(isExecCommand("create_clock -name clk -period 2.0 [get_ports clk]")).toEqual([true, null]);
209+
});
210+
211+
it("allows read_db / write_db", () => {
212+
expect(isExecCommand("read_db /path/to/design.odb")).toEqual([true, null]);
213+
expect(isExecCommand("write_db /out/design.odb")).toEqual([true, null]);
214+
});
215+
216+
it("allows flow commands", () => {
217+
expect(isExecCommand("global_placement")).toEqual([true, null]);
218+
});
219+
220+
it("allows readonly commands (allow-by-default)", () => {
221+
expect(isExecCommand("report_wns")).toEqual([true, null]);
222+
expect(isExecCommand("get_nets *")).toEqual([true, null]);
223+
});
224+
225+
it("allows puts and foreach", () => {
226+
expect(isExecCommand("puts hello")).toEqual([true, null]);
227+
expect(isExecCommand("foreach net [get_nets *] { puts $net }")).toEqual([true, null]);
228+
});
229+
230+
it("allows unknown commands", () => {
231+
expect(isExecCommand("pdngen")).toEqual([true, null]);
232+
});
233+
234+
it("allows exec / source / exit (ORFS use)", () => {
235+
expect(isExecCommand("exec yosys $::env(SCRIPTS_DIR)/synth.tcl")).toEqual([true, null]);
236+
expect(isExecCommand("source $::env(SCRIPTS_DIR)/load.tcl")).toEqual([true, null]);
237+
expect(isExecCommand("exit 1")).toEqual([true, null]);
238+
});
239+
240+
it("allows open/close/file ops", () => {
241+
expect(isExecCommand("open /tmp/report.log w")).toEqual([true, null]);
242+
expect(isExecCommand("close $fh")).toEqual([true, null]);
243+
expect(isExecCommand("file mkdir /results/6_final")).toEqual([true, null]);
244+
});
245+
246+
it("blocks socket", () => {
247+
expect(isExecCommand("socket tcp localhost 8080")).toEqual([false, "socket"]);
248+
});
249+
250+
it("blocks quit", () => {
251+
expect(isExecCommand("quit")).toEqual([false, "quit"]);
252+
});
253+
254+
it("allows a multiline all-allowed command", () => {
255+
expect(isExecCommand("read_db design.odb\nglobal_placement\nwrite_db out.odb")).toEqual([true, null]);
256+
});
257+
258+
it("blocks a multiline command with one blocked verb", () => {
259+
expect(isExecCommand("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]);
260+
});
261+
});
262+
263+
// isCommandAllowed (backward-compat alias)
264+
265+
describe("isCommandAllowed", () => {
266+
it("mirrors isExecCommand for allowed commands", () => {
267+
expect(isCommandAllowed("report_checks -path_delay max")).toEqual([true, null]);
268+
expect(isCommandAllowed("read_db /path/to/design.odb")).toEqual([true, null]);
269+
expect(isCommandAllowed("set_clock_period -name clk 2.0")).toEqual([true, null]);
270+
expect(isCommandAllowed("global_placement")).toEqual([true, null]);
271+
expect(isCommandAllowed("pdngen")).toEqual([true, null]);
272+
expect(isCommandAllowed("exec yosys synth.tcl")).toEqual([true, null]);
273+
});
274+
275+
it("allows a multi-statement command with semicolons", () => {
276+
expect(isCommandAllowed("set x 1; report_wns; puts $x")).toEqual([true, null]);
277+
});
278+
279+
it("blocks socket and gives it priority", () => {
280+
expect(isCommandAllowed("socket tcp localhost 8080")).toEqual([false, "socket"]);
281+
expect(isCommandAllowed("global_placement\nsocket tcp localhost")).toEqual([false, "socket"]);
282+
});
283+
});
284+
285+
// splitlines() parity — bypass regression tests
286+
287+
describe("line-boundary splitting (Python splitlines parity)", () => {
288+
// Each of these separators must be treated as a statement boundary so that
289+
// a blocked verb after the separator is not silently skipped.
290+
291+
it("blocks quit hidden after \\r (CR alone)", () => {
292+
expect(isExecCommand("report_checks\rquit")).toEqual([false, "quit"]);
293+
});
294+
295+
it("blocks quit hidden after \\r\\n (CRLF)", () => {
296+
expect(isExecCommand("report_checks\r\nquit")).toEqual([false, "quit"]);
297+
});
298+
299+
it("blocks quit hidden after \\v (vertical tab)", () => {
300+
expect(isExecCommand("report_checks\vquit")).toEqual([false, "quit"]);
301+
});
302+
303+
it("blocks quit hidden after \\f (form feed)", () => {
304+
expect(isExecCommand("report_checks\fquit")).toEqual([false, "quit"]);
305+
});
306+
307+
it("blocks quit hidden after \\x85 (NEL)", () => {
308+
expect(isExecCommand("report_checks\x85quit")).toEqual([false, "quit"]);
309+
});
310+
311+
it("blocks quit hidden after \\u2028 (line separator)", () => {
312+
expect(isExecCommand("report_checks
quit")).toEqual([false, "quit"]);
313+
});
314+
315+
it("blocks quit hidden after \\u2029 (paragraph separator)", () => {
316+
expect(isExecCommand("report_checks
quit")).toEqual([false, "quit"]);
317+
});
318+
319+
it("query tool also blocks exec-only verb hidden after \\r", () => {
320+
expect(isQueryCommand("report_checks\rglobal_placement")).toEqual([false, "global_placement"]);
321+
});
322+
});
323+
324+
// minimatch vs fnmatch parity
325+
326+
describe("glob parity (minimatch vs fnmatch)", () => {
327+
it("matches star-suffix against the empty remainder", () => {
328+
// report_ / set_ / read_ match report_* / set_* / read_* (star matches empty)
329+
expect(isQueryCommand("report_")).toEqual([true, null]);
330+
expect(isExecCommand("set_")).toEqual([true, null]);
331+
expect(isExecCommand("read_")).toEqual([true, null]);
332+
});
333+
334+
it("is case-sensitive like POSIX fnmatch on verbs", () => {
335+
// Report_Checks (capitalized) does not match report_* and is unknown -> blocked in query
336+
expect(isQueryCommand("Report_Checks")).toEqual([false, "Report_Checks"]);
337+
});
338+
});

0 commit comments

Comments
 (0)