@goodandready/dsh-approval-gate 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dispatcher.js CHANGED
@@ -1,208 +1,210 @@
1
- // lib/dispatcher.js
2
- // Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
3
- // from the safety evaluation engine.
4
- // Resolves: #133, #65
5
-
6
- import { tokenize, splitSubcommands } from "./tokenizer.js";
7
- import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
8
- import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
9
- import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
10
- import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
11
-
12
- export const TOOL_KIND_SHELL = "shell";
13
- export const TOOL_KIND_FILE_WRITE = "file_write";
14
- export const TOOL_KIND_FILE_READ = "file_read";
15
- export const TOOL_KIND_NETWORK = "network";
16
- export const TOOL_KIND_GENERIC = "generic";
17
-
18
- export const DEFAULT_BASH_TOOL = "bash";
19
- export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
20
- "write",
21
- "edit",
22
- "Write",
23
- "Edit",
24
- "str_replace_editor",
25
- "str_replace",
26
- "apply_patch",
27
- ]);
28
-
29
- export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
30
- "read_file",
31
- "view_file",
32
- "read_url_content",
33
- "search_web",
34
- "list_dir",
35
- "glob",
36
- "grep",
37
- ]);
38
-
39
- export const DEFAULT_NETWORK_TOOLS = Object.freeze([
40
- "fetch",
41
- "web_fetch",
42
- "http_request",
43
- "read_url_content",
44
- ]);
45
-
46
- /**
47
- * Extracts target file path from various tool argument schemas.
48
- *
49
- * @param {any} args
50
- * @returns {string | null}
51
- */
52
- export function extractFilePath(args) {
53
- if (typeof args === "string") return args;
54
- if (!args || typeof args !== "object") return null;
55
- const keys = [
56
- "path",
57
- "file_path",
58
- "filePath",
59
- "file",
60
- "filename",
61
- "target",
62
- "to",
63
- "TargetFile",
64
- "AbsolutePath",
65
- ];
66
- for (const k of keys) {
67
- if (typeof args[k] === "string" && args[k].trim()) {
68
- return args[k].trim();
69
- }
70
- }
71
- return null;
72
- }
73
-
74
- /**
75
- * Checks whether an execution originates from a designated read-only reviewer agent.
76
- * Resolves: #65 (strict read-only reviewer subagent isolation).
77
- *
78
- * @param {any} execution
79
- * @param {any} [options]
80
- * @returns {boolean}
81
- */
82
- export function isReviewerAgent(execution, options = {}) {
83
- if (options && options.reviewerMode === true) return true;
84
- const agent = execution && execution.agent;
85
- if (!agent) return false;
86
-
87
- const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
88
- const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
89
- const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
90
-
91
- return role.includes("review") || name.includes("review") || type.includes("review");
92
- }
93
-
94
- export class CommandDispatcher {
95
- /**
96
- * @param {{
97
- * toolName?: string,
98
- * fileWriteTools?: string[] | Set<string>,
99
- * readOnlyTools?: string[] | Set<string>,
100
- * networkTools?: string[] | Set<string>,
101
- * workspaceDir?: string
102
- * }} [options]
103
- */
104
- constructor(options = {}) {
105
- this.toolName = options.toolName || DEFAULT_BASH_TOOL;
106
- this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
107
- this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
108
- this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
109
- this.workspaceDir = options.workspaceDir || process.cwd();
110
- }
111
-
112
- /**
113
- * Categorizes the tool name into functional kind.
114
- *
115
- * @param {string} name
116
- * @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
117
- */
118
- classifyTool(name) {
119
- if (!name) return TOOL_KIND_GENERIC;
120
- if (name === this.toolName) return TOOL_KIND_SHELL;
121
- if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
122
- if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
123
- if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
124
- return TOOL_KIND_GENERIC;
125
- }
126
-
127
- /**
128
- * Dispatches and normalizes a tool execution request into a structured descriptor.
129
- *
130
- * @param {any} execution - The tool execution event carrying name, arguments, agent, session
131
- * @param {any} [options] - Dynamic context overrides
132
- * @returns {{
133
- * tool: string,
134
- * kind: "shell" | "file_write" | "file_read" | "network" | "generic",
135
- * isMutating: boolean,
136
- * reviewerViolation: boolean,
137
- * command?: string,
138
- * tokens?: any[],
139
- * subcommands?: string[],
140
- * git?: any,
141
- * silentError?: any,
142
- * loopback?: any,
143
- * filePath?: string,
144
- * pathSafety?: any
145
- * }}
146
- */
147
- dispatch(execution, options = {}) {
148
- const name = execution && execution.name ? String(execution.name).trim() : "";
149
- const args = execution ? execution.arguments : undefined;
150
- const kind = this.classifyTool(name);
151
- const isReviewer = isReviewerAgent(execution, options);
152
-
153
- const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
154
- const reviewerViolation = isReviewer && isMutating;
155
-
156
- const descriptor = {
157
- tool: name,
158
- kind,
159
- isMutating,
160
- reviewerViolation,
161
- };
162
-
163
- if (kind === TOOL_KIND_SHELL) {
164
- const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
165
- descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
166
-
167
- if (descriptor.command) {
168
- const tokenized = tokenize(descriptor.command);
169
- descriptor.tokens = tokenized.tokens || [];
170
- descriptor.tokenizerOk = tokenized.ok;
171
- descriptor.tokenizerReason = tokenized.reason;
172
-
173
- // Subcommands breakdown
174
- descriptor.subcommands = splitSubcommands(descriptor.command);
175
-
176
- // Silent errors inspection (#90)
177
- descriptor.silentError = inspectSilentErrors(descriptor.command);
178
-
179
- // Git inspection (#125, #103)
180
- if (/\bgit\b/.test(descriptor.command)) {
181
- const firstWords = descriptor.command.trim().split(/\s+/);
182
- descriptor.git = parseGitArgv(firstWords);
183
- descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
184
- }
185
-
186
- // Loopback detection (#40)
187
- descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
188
- }
189
- } else if (kind === TOOL_KIND_FILE_WRITE) {
190
- const filePath = extractFilePath(args);
191
- descriptor.filePath = filePath;
192
- if (filePath) {
193
- const ws = options.workspaceDir || this.workspaceDir;
194
- descriptor.pathSafety = resolveSafePath(filePath, ws);
195
- descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
196
- descriptor.isTraversal = !descriptor.pathSafety.safe;
197
- }
198
- } else if (kind === TOOL_KIND_NETWORK) {
199
- const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
200
- descriptor.url = typeof url === "string" ? url : "";
201
- if (descriptor.url) {
202
- descriptor.isLoopback = isLoopbackUrl(descriptor.url);
203
- }
204
- }
205
-
206
- return descriptor;
207
- }
1
+ // lib/dispatcher.js
2
+ // Command Dispatcher: architectural isolation of command parsing and syntactic dispatch
3
+ // from the safety evaluation engine.
4
+ // Resolves: #133, #65
5
+
6
+ import { tokenize, splitSubcommands } from "./tokenizer.js";
7
+ import { parseGitArgv, classifyGitOperation } from "./parsers/git.js";
8
+ import { extractRedirects, inspectRedirectSecurity, inspectSilentErrors, inspectPipelines } from "./parsers/shell-cmds.js";
9
+ import { isLoopbackCommand, isLoopbackUrl } from "./loopback.js";
10
+ import { resolveSafePath, isPathTraversal, isProtectedConfigPath } from "./paths.js";
11
+
12
+ export const TOOL_KIND_SHELL = "shell";
13
+ export const TOOL_KIND_FILE_WRITE = "file_write";
14
+ export const TOOL_KIND_FILE_READ = "file_read";
15
+ export const TOOL_KIND_NETWORK = "network";
16
+ export const TOOL_KIND_GENERIC = "generic";
17
+
18
+ export const DEFAULT_BASH_TOOL = "bash";
19
+ export const DEFAULT_FILE_WRITE_TOOLS = Object.freeze([
20
+ "write",
21
+ "edit",
22
+ "Write",
23
+ "Edit",
24
+ "write_to_file",
25
+ "replace_file_content",
26
+ "str_replace_editor",
27
+ "str_replace",
28
+ "apply_patch",
29
+ ]);
30
+
31
+ export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
32
+ "read_file",
33
+ "view_file",
34
+ "read_url_content",
35
+ "search_web",
36
+ "list_dir",
37
+ "glob",
38
+ "grep",
39
+ ]);
40
+
41
+ export const DEFAULT_NETWORK_TOOLS = Object.freeze([
42
+ "fetch",
43
+ "web_fetch",
44
+ "http_request",
45
+ "read_url_content",
46
+ ]);
47
+
48
+ /**
49
+ * Extracts target file path from various tool argument schemas.
50
+ *
51
+ * @param {any} args
52
+ * @returns {string | null}
53
+ */
54
+ export function extractFilePath(args) {
55
+ if (typeof args === "string") return args;
56
+ if (!args || typeof args !== "object") return null;
57
+ const keys = [
58
+ "path",
59
+ "file_path",
60
+ "filePath",
61
+ "file",
62
+ "filename",
63
+ "target",
64
+ "to",
65
+ "TargetFile",
66
+ "AbsolutePath",
67
+ ];
68
+ for (const k of keys) {
69
+ if (typeof args[k] === "string" && args[k].trim()) {
70
+ return args[k].trim();
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Checks whether an execution originates from a designated read-only reviewer agent.
78
+ * Resolves: #65 (strict read-only reviewer subagent isolation).
79
+ *
80
+ * @param {any} execution
81
+ * @param {any} [options]
82
+ * @returns {boolean}
83
+ */
84
+ export function isReviewerAgent(execution, options = {}) {
85
+ if (options && options.reviewerMode === true) return true;
86
+ const agent = execution && execution.agent;
87
+ if (!agent) return false;
88
+
89
+ const role = typeof agent.role === "string" ? agent.role.toLowerCase() : "";
90
+ const name = typeof agent.name === "string" ? agent.name.toLowerCase() : "";
91
+ const type = typeof agent.type === "string" ? agent.type.toLowerCase() : "";
92
+
93
+ return role.includes("review") || name.includes("review") || type.includes("review");
94
+ }
95
+
96
+ export class CommandDispatcher {
97
+ /**
98
+ * @param {{
99
+ * toolName?: string,
100
+ * fileWriteTools?: string[] | Set<string>,
101
+ * readOnlyTools?: string[] | Set<string>,
102
+ * networkTools?: string[] | Set<string>,
103
+ * workspaceDir?: string
104
+ * }} [options]
105
+ */
106
+ constructor(options = {}) {
107
+ this.toolName = options.toolName || DEFAULT_BASH_TOOL;
108
+ this.fileWriteTools = new Set(options.fileWriteTools || DEFAULT_FILE_WRITE_TOOLS);
109
+ this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
110
+ this.networkTools = new Set(options.networkTools || DEFAULT_NETWORK_TOOLS);
111
+ this.workspaceDir = options.workspaceDir || process.cwd();
112
+ }
113
+
114
+ /**
115
+ * Categorizes the tool name into functional kind.
116
+ *
117
+ * @param {string} name
118
+ * @returns {"shell" | "file_write" | "file_read" | "network" | "generic"}
119
+ */
120
+ classifyTool(name) {
121
+ if (!name) return TOOL_KIND_GENERIC;
122
+ if (name === this.toolName) return TOOL_KIND_SHELL;
123
+ if (this.fileWriteTools.has(name)) return TOOL_KIND_FILE_WRITE;
124
+ if (this.readOnlyTools.has(name)) return TOOL_KIND_FILE_READ;
125
+ if (this.networkTools.has(name)) return TOOL_KIND_NETWORK;
126
+ return TOOL_KIND_GENERIC;
127
+ }
128
+
129
+ /**
130
+ * Dispatches and normalizes a tool execution request into a structured descriptor.
131
+ *
132
+ * @param {any} execution - The tool execution event carrying name, arguments, agent, session
133
+ * @param {any} [options] - Dynamic context overrides
134
+ * @returns {{
135
+ * tool: string,
136
+ * kind: "shell" | "file_write" | "file_read" | "network" | "generic",
137
+ * isMutating: boolean,
138
+ * reviewerViolation: boolean,
139
+ * command?: string,
140
+ * tokens?: any[],
141
+ * subcommands?: string[],
142
+ * git?: any,
143
+ * silentError?: any,
144
+ * loopback?: any,
145
+ * filePath?: string,
146
+ * pathSafety?: any
147
+ * }}
148
+ */
149
+ dispatch(execution, options = {}) {
150
+ const name = execution && execution.name ? String(execution.name).trim() : "";
151
+ const args = execution ? execution.arguments : undefined;
152
+ const kind = this.classifyTool(name);
153
+ const isReviewer = isReviewerAgent(execution, options);
154
+
155
+ const isMutating = kind === TOOL_KIND_SHELL || kind === TOOL_KIND_FILE_WRITE;
156
+ const reviewerViolation = isReviewer && isMutating;
157
+
158
+ const descriptor = {
159
+ tool: name,
160
+ kind,
161
+ isMutating,
162
+ reviewerViolation,
163
+ };
164
+
165
+ if (kind === TOOL_KIND_SHELL) {
166
+ const rawCommand = args && typeof args === "object" ? args.command : (typeof args === "string" ? args : "");
167
+ descriptor.command = typeof rawCommand === "string" ? rawCommand : "";
168
+
169
+ if (descriptor.command) {
170
+ const tokenized = tokenize(descriptor.command);
171
+ descriptor.tokens = tokenized.tokens || [];
172
+ descriptor.tokenizerOk = tokenized.ok;
173
+ descriptor.tokenizerReason = tokenized.reason;
174
+
175
+ // Subcommands breakdown
176
+ descriptor.subcommands = splitSubcommands(descriptor.command);
177
+
178
+ // Silent errors inspection (#90)
179
+ descriptor.silentError = inspectSilentErrors(descriptor.command);
180
+
181
+ // Git inspection (#125, #103)
182
+ if (/\bgit\b/.test(descriptor.command)) {
183
+ const firstWords = descriptor.command.trim().split(/\s+/);
184
+ descriptor.git = parseGitArgv(firstWords);
185
+ descriptor.gitClassification = classifyGitOperation(descriptor.git.verb, descriptor.git.args);
186
+ }
187
+
188
+ // Loopback detection (#40)
189
+ descriptor.loopback = isLoopbackCommand(descriptor.command.trim().split(/\s+/));
190
+ }
191
+ } else if (kind === TOOL_KIND_FILE_WRITE) {
192
+ const filePath = extractFilePath(args);
193
+ descriptor.filePath = filePath;
194
+ if (filePath) {
195
+ const ws = options.workspaceDir || this.workspaceDir;
196
+ descriptor.pathSafety = resolveSafePath(filePath, ws);
197
+ descriptor.isProtected = isProtectedConfigPath(descriptor.pathSafety.canonical || filePath);
198
+ descriptor.isTraversal = !descriptor.pathSafety.safe;
199
+ }
200
+ } else if (kind === TOOL_KIND_NETWORK) {
201
+ const url = args && typeof args === "object" ? (args.url || args.Url || args.uri || args.Uri) : "";
202
+ descriptor.url = typeof url === "string" ? url : "";
203
+ if (descriptor.url) {
204
+ descriptor.isLoopback = isLoopbackUrl(descriptor.url);
205
+ }
206
+ }
207
+
208
+ return descriptor;
209
+ }
208
210
  }