@goodandready/dsh-approval-gate 0.1.12 → 0.1.14

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.
@@ -0,0 +1,85 @@
1
+ // lib/loopback.js
2
+ // Safe loopback bypass helper for 127.0.0.1 and localhost dev servers.
3
+ // Resolves: #40
4
+
5
+ /**
6
+ * Checks whether a hostname or IP string represents a local loopback interface.
7
+ *
8
+ * @param {string} host
9
+ * @returns {boolean}
10
+ */
11
+ export function isLoopbackHost(host) {
12
+ if (typeof host !== "string" || !host.trim()) return false;
13
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
14
+
15
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
16
+ if (h === "127.0.0.1" || h === "0.0.0.0" || h === "::1" || h === "0:0:0:0:0:0:0:1") return true;
17
+
18
+ // 127.0.0.0/8 IPv4 loopback range
19
+ if (/^127(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/.test(h)) return true;
20
+
21
+ // IPv6 loopback variants (::ffff:127.x.x.x)
22
+ if (/^::ffff:127(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/i.test(h)) return true;
23
+
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * Checks whether a URL points to a loopback address.
29
+ *
30
+ * @param {string} targetUrl
31
+ * @returns {boolean}
32
+ */
33
+ export function isLoopbackUrl(targetUrl) {
34
+ if (typeof targetUrl !== "string" || !targetUrl.trim()) return false;
35
+ try {
36
+ const parsed = new URL(targetUrl.includes("://") ? targetUrl : `http://${targetUrl}`);
37
+ return isLoopbackHost(parsed.hostname);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Checks whether an HTTP/network tool execution or command targets a loopback endpoint.
45
+ *
46
+ * @param {string[]} argv - Tokens of curl/wget or similar command
47
+ * @returns {{ isLoopback: boolean, host?: string, port?: string }}
48
+ */
49
+ export function isLoopbackCommand(argv) {
50
+ if (!Array.isArray(argv) || argv.length === 0) return { isLoopback: false };
51
+ const cmd = argv[0].replace(/\\/g, "/").split("/").pop();
52
+
53
+ if (cmd !== "curl" && cmd !== "wget" && cmd !== "nc" && cmd !== "socat") {
54
+ return { isLoopback: false };
55
+ }
56
+
57
+ const urls = [];
58
+ for (let i = 1; i < argv.length; i += 1) {
59
+ const a = argv[i];
60
+ if (a.startsWith("-")) {
61
+ // Skip flags and their values if common
62
+ if ((a === "-u" || a === "-H" || a === "-d" || a === "--data" || a === "-o" || a === "-O") && i + 1 < argv.length) {
63
+ i += 1;
64
+ }
65
+ continue;
66
+ }
67
+ if (a.includes("localhost") || a.includes("127.0.0.1") || a.includes("::1") || a.includes("0.0.0.0") || a.startsWith("http://") || a.startsWith("https://")) {
68
+ urls.push(a);
69
+ }
70
+ }
71
+
72
+ if (urls.length === 0) return { isLoopback: false };
73
+
74
+ const allLoopback = urls.every((u) => isLoopbackUrl(u));
75
+ if (allLoopback) {
76
+ try {
77
+ const parsed = new URL(urls[0].includes("://") ? urls[0] : `http://${urls[0]}`);
78
+ return { isLoopback: true, host: parsed.hostname, port: parsed.port };
79
+ } catch {
80
+ return { isLoopback: true };
81
+ }
82
+ }
83
+
84
+ return { isLoopback: false };
85
+ }
package/lib/messages.js CHANGED
@@ -32,12 +32,18 @@ export const MESSAGES = Object.freeze({
32
32
  askShellStdin: "Shell input is not fully available to the guard; review it before allowing",
33
33
  askInterpreterPayload: "Interpreter side effects are not fully analyzed; review it before allowing",
34
34
  askCredentialInArgv: "Expanded authorization values become process arguments; use a credential-safe API helper or review before allowing",
35
+ askGitRemotePush: "Publishing commits to a remote repository via git push requires confirmation",
36
+ askSilentErrorMasked: "The command suppresses errors or masks non-zero exit codes; review before allowing",
35
37
  ruleUnknown: "A blocking safety rule matched",
36
38
  ruleRecursiveRm: "Recursive file deletion",
37
39
  ruleProcessSignal: "Process termination",
38
40
  ruleServiceControl: "Service state change",
39
41
  ruleGitClean: "Destructive git clean",
40
42
  ruleGitResetHard: "Destructive git reset --hard",
43
+ ruleGitForcePush: "Forceful git push",
44
+ ruleGitBranchForceDelete: "Forceful git branch deletion",
45
+ ruleGitCheckoutForce: "Forceful checkout discarding local modifications",
46
+ ruleGitRestoreWorktree: "Restoring working tree files discarding modifications",
41
47
  ruleFindDelete: "find -delete",
42
48
  ruleDestructiveSql: "Destructive SQL",
43
49
  ruleSecretWrite: "Write to a protected secret file",
@@ -51,6 +57,10 @@ export const MESSAGES = Object.freeze({
51
57
  rulePathTraversal: "Directory traversal attack detected",
52
58
  ruleBandHardDeny: "Command matched Band 0 critical security blocklist",
53
59
  ruleCircuitBreakerTripped: "Circuit breaker tripped due to repeated security denials",
60
+ ruleSilentErrorMasked: "Suppression of command failure or error stream",
61
+ ruleChmodChown: "Permission or ownership mutation",
62
+ ruleReviewerReadOnly: "Reviewer agent is strictly isolated in read-only mode",
63
+ ruleRetryExhaustion: "Exhausted repeated attempts to bypass safety gate",
54
64
  hintRecursiveRm: "Specify exact target files instead of recursive deletion",
55
65
  hintPathTraversal: "Keep operations strictly within the project directory",
56
66
  hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
@@ -58,6 +68,12 @@ export const MESSAGES = Object.freeze({
58
68
  hintCircuitBreakerTripped: "Request manual operator approval before attempting new system commands",
59
69
  hintDestructiveSql: "Use migrations or execute non-destructive database operations",
60
70
  hintCurlPipe: "Download the script, inspect its content, and execute locally",
71
+ hintGitForcePush: "Push branch without force or check with remote administrator",
72
+ hintGitRemotePush: "Confirm remote target branch and repository before pushing",
73
+ hintSilentErrorMasked: "Allow standard error output to be captured and handle non-zero exit codes explicitly",
74
+ hintChmodChown: "Avoid modifying file permissions or system directory ownership directly",
75
+ hintReviewerReadOnly: "Reviewer agents may only read repository files, not run commands or modify files",
76
+ hintRetryExhaustion: "Repeated denials detected; manual operator intervention required",
61
77
  },
62
78
  zh: {
63
79
  blockedPrefix: "dsh-approval-gate 规则已阻止",
@@ -85,16 +101,22 @@ export const MESSAGES = Object.freeze({
85
101
  askDynamicCommand: "无法验证展开后的命令名;请在允许前进行审查",
86
102
  askDynamicArguments: "敏感命令的展开参数无法验证;请在允许前进行审查",
87
103
  askDynamicRedirect: "展开后的重定向目标无法验证;请在允许前进行审查",
88
- askScriptFile: "卫兵无法获取脚本文件内容;请在允许前进行审查",
104
+ askScriptFile: "脚本文件内容不可见;请在允许前进行审查",
89
105
  askShellStdin: "卫兵无法完整获取 Shell 输入;请在允许前进行审查",
90
106
  askInterpreterPayload: "解释器副作用未完全分析;请在允许前进行审查",
91
107
  askCredentialInArgv: "展开后的授权凭据作为进程参数传递;请使用凭据安全的 API 辅助工具,或在允许前进行审查",
108
+ askGitRemotePush: "通过 git push 向远程仓库发布代码需要确认",
109
+ askSilentErrorMasked: "命令屏蔽了错误输出或强制将退出码设为 0;请在允许前审查",
92
110
  ruleUnknown: "命中阻止性安全规则",
93
111
  ruleRecursiveRm: "递归删除文件",
94
112
  ruleProcessSignal: "终止进程",
95
113
  ruleServiceControl: "更改服务状态",
96
114
  ruleGitClean: "破坏性 git clean",
97
115
  ruleGitResetHard: "破坏性 git reset --hard",
116
+ ruleGitForcePush: "强制 git push",
117
+ ruleGitBranchForceDelete: "强制删除 git 分支",
118
+ ruleGitCheckoutForce: "强制 checkout 丢弃本地修改",
119
+ ruleGitRestoreWorktree: "还原工作区文件并丢弃修改",
98
120
  ruleFindDelete: "find -delete",
99
121
  ruleDestructiveSql: "破坏性 SQL",
100
122
  ruleSecretWrite: "写入受保护的敏感文件",
@@ -108,6 +130,10 @@ export const MESSAGES = Object.freeze({
108
130
  rulePathTraversal: "检测到路径穿越攻击",
109
131
  ruleBandHardDeny: "命令匹配 Band 0 关键安全黑名单",
110
132
  ruleCircuitBreakerTripped: "因连续多次安全拒绝,断路熔断器已触发",
133
+ ruleSilentErrorMasked: "屏蔽错误输出或强制忽略退出码",
134
+ ruleChmodChown: "修改权限或所有权",
135
+ ruleReviewerReadOnly: "审查者代理处于只读隔离模式",
136
+ ruleRetryExhaustion: "多次尝试绕过安全网关已达上限",
111
137
  hintRecursiveRm: "请指定具体目标文件,避免递归删除",
112
138
  hintPathTraversal: "请将操作限制在项目根目录内",
113
139
  hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
@@ -115,6 +141,12 @@ export const MESSAGES = Object.freeze({
115
141
  hintCircuitBreakerTripped: "请在尝试新的系统命令前请求操作员人工授权",
116
142
  hintDestructiveSql: "请使用版本化数据库迁移,或执行非破坏性查询",
117
143
  hintCurlPipe: "先将脚本下载到本地检查内容,再安全执行",
144
+ hintGitForcePush: "请勿使用 force 参数推送分支,或与远程仓库管理员确认",
145
+ hintGitRemotePush: "推送前请核对目标分支与远程仓库地址",
146
+ hintSilentErrorMasked: "请保留标准错误输出,并明确处理非零退出码",
147
+ hintChmodChown: "避免直接修改系统目录所有权或文件权限",
148
+ hintReviewerReadOnly: "审查者代理仅可读取项目文件,禁止执行命令或修改文件",
149
+ hintRetryExhaustion: "检测到重复拒绝;需要操作员手动介入",
118
150
  },
119
151
  });
120
152
 
@@ -154,6 +186,10 @@ const RULE_KEYS = Object.freeze({
154
186
  "service-control": "ruleServiceControl",
155
187
  "git-clean": "ruleGitClean",
156
188
  "git-reset-hard": "ruleGitResetHard",
189
+ "git-force-push": "ruleGitForcePush",
190
+ "git-branch-force-delete": "ruleGitBranchForceDelete",
191
+ "git-checkout-force": "ruleGitCheckoutForce",
192
+ "git-restore-worktree": "ruleGitRestoreWorktree",
157
193
  "find-delete": "ruleFindDelete",
158
194
  "destructive-sql": "ruleDestructiveSql",
159
195
  "secret-write": "ruleSecretWrite",
@@ -167,6 +203,10 @@ const RULE_KEYS = Object.freeze({
167
203
  "path-traversal": "rulePathTraversal",
168
204
  "band-hard-deny": "ruleBandHardDeny",
169
205
  "circuit-breaker-tripped": "ruleCircuitBreakerTripped",
206
+ "silent-error-masked": "ruleSilentErrorMasked",
207
+ "chmod-chown": "ruleChmodChown",
208
+ "reviewer-read-only": "ruleReviewerReadOnly",
209
+ "retry-exhaustion": "ruleRetryExhaustion",
170
210
  });
171
211
 
172
212
  const HINT_KEYS = Object.freeze({
@@ -177,6 +217,12 @@ const HINT_KEYS = Object.freeze({
177
217
  "circuit-breaker-tripped": "hintCircuitBreakerTripped",
178
218
  "destructive-sql": "hintDestructiveSql",
179
219
  "curl-pipe": "hintCurlPipe",
220
+ "git-force-push": "hintGitForcePush",
221
+ "git-remote-push": "hintGitRemotePush",
222
+ "silent-error-masked": "hintSilentErrorMasked",
223
+ "chmod-chown": "hintChmodChown",
224
+ "reviewer-read-only": "hintReviewerReadOnly",
225
+ "retry-exhaustion": "hintRetryExhaustion",
180
226
  });
181
227
 
182
228
  const ASK_KEYS = Object.freeze({
@@ -190,6 +236,8 @@ const ASK_KEYS = Object.freeze({
190
236
  "shell-stdin": "askShellStdin",
191
237
  "interpreter-payload": "askInterpreterPayload",
192
238
  "credential-in-argv": "askCredentialInArgv",
239
+ "git-remote-push": "askGitRemotePush",
240
+ "silent-error-masked": "askSilentErrorMasked",
193
241
  });
194
242
 
195
243
  const DETAIL_KEYS = Object.freeze({
@@ -0,0 +1,321 @@
1
+ // lib/parsers/git.js
2
+ // Specialized Git AST and semantics parser with classification of read, local mutation,
3
+ // remote operations, and destructive commands.
4
+ // Resolves: #125, #103
5
+
6
+ export const GIT_CATEGORY_READ = "read";
7
+ export const GIT_CATEGORY_MUTATE_LOCAL = "mutate_local";
8
+ export const GIT_CATEGORY_REMOTE = "remote";
9
+ export const GIT_CATEGORY_DESTRUCTIVE = "destructive";
10
+
11
+ const GLOBAL_FLAGS_WITH_VALUE = new Set([
12
+ "-C",
13
+ "-c",
14
+ "--git-dir",
15
+ "--work-tree",
16
+ "--namespace",
17
+ "--exec-path",
18
+ "--super-prefix",
19
+ "--config-env",
20
+ ]);
21
+
22
+ const READ_ONLY_VERBS = new Set([
23
+ "status",
24
+ "log",
25
+ "diff",
26
+ "show",
27
+ "rev-parse",
28
+ "describe",
29
+ "cat-file",
30
+ "ls-files",
31
+ "ls-tree",
32
+ "shortlog",
33
+ "blame",
34
+ "check-ignore",
35
+ "check-ref-format",
36
+ "var",
37
+ "version",
38
+ "help",
39
+ "symbolic-ref",
40
+ "name-rev",
41
+ "merge-base",
42
+ "count-objects",
43
+ "diff-index",
44
+ "diff-tree",
45
+ "for-each-ref",
46
+ "rev-list",
47
+ ]);
48
+
49
+ const LOCAL_MUTATE_VERBS = new Set([
50
+ "add",
51
+ "commit",
52
+ "checkout",
53
+ "switch",
54
+ "branch",
55
+ "tag",
56
+ "stash",
57
+ "merge",
58
+ "rebase",
59
+ "cherry-pick",
60
+ "revert",
61
+ "mv",
62
+ "rm",
63
+ "init",
64
+ "apply",
65
+ "am",
66
+ ]);
67
+
68
+ const REMOTE_VERBS = new Set([
69
+ "push",
70
+ "pull",
71
+ "fetch",
72
+ "clone",
73
+ "remote",
74
+ "submodule",
75
+ ]);
76
+
77
+ /**
78
+ * Extracts global git options and isolates the git subcommand verb and its arguments.
79
+ *
80
+ * @param {string[]} argv - Array of tokens starting with 'git' or the first flag/verb.
81
+ * @returns {{ verb: string, args: string[], globalOptions: Record<string, string>, rawArgv: string[] }}
82
+ */
83
+ export function parseGitArgv(argv) {
84
+ if (!Array.isArray(argv) || argv.length === 0) {
85
+ return { verb: "", args: [], globalOptions: {}, rawArgv: [] };
86
+ }
87
+
88
+ const rawArgv = [...argv];
89
+ let idx = rawArgv[0] === "git" ? 1 : 0;
90
+ const globalOptions = {};
91
+
92
+ while (idx < rawArgv.length && rawArgv[idx].startsWith("-")) {
93
+ const flag = rawArgv[idx];
94
+ if (flag === "--") {
95
+ idx += 1;
96
+ break;
97
+ }
98
+ if (GLOBAL_FLAGS_WITH_VALUE.has(flag)) {
99
+ globalOptions[flag] = rawArgv[idx + 1] || "";
100
+ idx += 2;
101
+ } else if (flag.startsWith("--") && flag.includes("=")) {
102
+ const eqIdx = flag.indexOf("=");
103
+ globalOptions[flag.slice(0, eqIdx)] = flag.slice(eqIdx + 1);
104
+ idx += 1;
105
+ } else if (flag.length > 2 && flag[1] !== "-" && flag.startsWith("-C")) {
106
+ globalOptions["-C"] = flag.slice(2);
107
+ idx += 1;
108
+ } else {
109
+ globalOptions[flag] = "true";
110
+ idx += 1;
111
+ }
112
+ }
113
+
114
+ const verb = rawArgv[idx] || "";
115
+ const args = rawArgv.slice(idx + 1);
116
+
117
+ return { verb, args, globalOptions, rawArgv };
118
+ }
119
+
120
+ /**
121
+ * Checks whether an argument contains a specific short flag (e.g. 'f' in '-fdx').
122
+ *
123
+ * @param {string[]} args
124
+ * @param {string} flagChar
125
+ * @returns {boolean}
126
+ */
127
+ function hasShortFlag(args, flagChar) {
128
+ for (const a of args) {
129
+ if (a === "--") break;
130
+ if (a.length > 1 && a[0] === "-" && a[1] !== "-" && a.includes(flagChar)) {
131
+ return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Analyzes and classifies a git subcommand and its arguments.
139
+ *
140
+ * @param {string} verb
141
+ * @param {string[]} args
142
+ * @returns {{
143
+ * verb: string,
144
+ * category: "read" | "mutate_local" | "remote" | "destructive",
145
+ * isDestructive: boolean,
146
+ * isRemote: boolean,
147
+ * isForce: boolean,
148
+ * reason?: string,
149
+ * detail?: string
150
+ * }}
151
+ */
152
+ export function classifyGitOperation(verb, args = []) {
153
+ if (!verb) {
154
+ return {
155
+ verb: "",
156
+ category: GIT_CATEGORY_READ,
157
+ isDestructive: false,
158
+ isRemote: false,
159
+ isForce: false,
160
+ };
161
+ }
162
+
163
+ const subArgs = Array.isArray(args) ? args : [];
164
+
165
+ // 1. Destructive operations check
166
+ if (verb === "reset" && subArgs.includes("--hard")) {
167
+ return {
168
+ verb,
169
+ category: GIT_CATEGORY_DESTRUCTIVE,
170
+ isDestructive: true,
171
+ isRemote: false,
172
+ isForce: true,
173
+ reason: "git-reset-hard",
174
+ detail: "Destructive git reset --hard discards all uncommitted modifications",
175
+ };
176
+ }
177
+
178
+ if (verb === "clean" && (subArgs.includes("-f") || subArgs.includes("--force") || hasShortFlag(subArgs, "f"))) {
179
+ return {
180
+ verb,
181
+ category: GIT_CATEGORY_DESTRUCTIVE,
182
+ isDestructive: true,
183
+ isRemote: false,
184
+ isForce: true,
185
+ reason: "git-clean",
186
+ detail: "Destructive git clean removes untracked files irreversibly",
187
+ };
188
+ }
189
+
190
+ if (verb === "branch" && (subArgs.includes("-D") || (subArgs.includes("-d") && (subArgs.includes("-f") || subArgs.includes("--force"))))) {
191
+ return {
192
+ verb,
193
+ category: GIT_CATEGORY_DESTRUCTIVE,
194
+ isDestructive: true,
195
+ isRemote: false,
196
+ isForce: true,
197
+ reason: "git-branch-force-delete",
198
+ detail: "Forceful git branch deletion (-D) risks losing unmerged commits",
199
+ };
200
+ }
201
+
202
+ // 2. Remote operations (#103: git push vs local git operations)
203
+ if (verb === "push") {
204
+ const isForce = subArgs.some((a) => (
205
+ a === "--force" ||
206
+ a === "-f" ||
207
+ a.startsWith("--force-with-lease") ||
208
+ a === "--delete" ||
209
+ (a[0] === "+" && a.length > 1)
210
+ )) || hasShortFlag(subArgs, "f");
211
+
212
+ if (isForce) {
213
+ return {
214
+ verb,
215
+ category: GIT_CATEGORY_DESTRUCTIVE,
216
+ isDestructive: true,
217
+ isRemote: true,
218
+ isForce: true,
219
+ reason: "git-force-push",
220
+ detail: "Forceful git push overwrites remote repository commit history",
221
+ };
222
+ }
223
+
224
+ return {
225
+ verb,
226
+ category: GIT_CATEGORY_REMOTE,
227
+ isDestructive: false,
228
+ isRemote: true,
229
+ isForce: false,
230
+ reason: "git-remote-push",
231
+ detail: "Publishing commits to a remote repository via git push requires confirmation",
232
+ };
233
+ }
234
+
235
+ if (verb === "checkout" && (subArgs.includes("-f") || subArgs.includes("--force") || (subArgs.includes("--") && subArgs.includes(".")))) {
236
+ return {
237
+ verb,
238
+ category: GIT_CATEGORY_DESTRUCTIVE,
239
+ isDestructive: true,
240
+ isRemote: false,
241
+ isForce: true,
242
+ reason: "git-checkout-force",
243
+ detail: "Forceful checkout discards local working tree changes",
244
+ };
245
+ }
246
+
247
+ if (verb === "restore" && subArgs.includes("--worktree") && (subArgs.includes(".") || subArgs.includes("--staged"))) {
248
+ return {
249
+ verb,
250
+ category: GIT_CATEGORY_DESTRUCTIVE,
251
+ isDestructive: true,
252
+ isRemote: false,
253
+ isForce: true,
254
+ reason: "git-restore-worktree",
255
+ detail: "Restoring working tree files discards local changes",
256
+ };
257
+ }
258
+
259
+ if (REMOTE_VERBS.has(verb)) {
260
+ return {
261
+ verb,
262
+ category: GIT_CATEGORY_REMOTE,
263
+ isDestructive: false,
264
+ isRemote: true,
265
+ isForce: false,
266
+ };
267
+ }
268
+
269
+ if (LOCAL_MUTATE_VERBS.has(verb)) {
270
+ return {
271
+ verb,
272
+ category: GIT_CATEGORY_MUTATE_LOCAL,
273
+ isDestructive: false,
274
+ isRemote: false,
275
+ isForce: false,
276
+ };
277
+ }
278
+
279
+ if (READ_ONLY_VERBS.has(verb)) {
280
+ return {
281
+ verb,
282
+ category: GIT_CATEGORY_READ,
283
+ isDestructive: false,
284
+ isRemote: false,
285
+ isForce: false,
286
+ };
287
+ }
288
+
289
+ return {
290
+ verb,
291
+ category: GIT_CATEGORY_MUTATE_LOCAL,
292
+ isDestructive: false,
293
+ isRemote: false,
294
+ isForce: false,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Full inspection of a git command invocation.
300
+ *
301
+ * @param {string[]} argv - Tokenized arguments
302
+ * @returns {{
303
+ * verb: string,
304
+ * args: string[],
305
+ * category: "read" | "mutate_local" | "remote" | "destructive",
306
+ * isDestructive: boolean,
307
+ * isRemote: boolean,
308
+ * isForce: boolean,
309
+ * reason?: string,
310
+ * detail?: string
311
+ * }}
312
+ */
313
+ export function inspectGitCommand(argv) {
314
+ const parsed = parseGitArgv(argv);
315
+ const classification = classifyGitOperation(parsed.verb, parsed.args);
316
+ return {
317
+ ...classification,
318
+ args: parsed.args,
319
+ globalOptions: parsed.globalOptions,
320
+ };
321
+ }