@hiro-c/agent-gate 1.3.0 → 1.4.0

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,31 @@
1
+ /**
2
+ * Light-weight bash analysis utilities used by deterministic rules to
3
+ * see past common obfuscation patterns. This is intentionally not a
4
+ * full shell parser; it handles the cases that matter for guardrails
5
+ * (separators, quoting, heredoc redirects, command substitution
6
+ * markers) and stays small.
7
+ */
8
+ /**
9
+ * Split a command line into top-level statements separated by `;`,
10
+ * `&&`, `||`, `|`, or newline. Respects single- and double-quoted
11
+ * regions so separators inside strings are preserved as part of the
12
+ * statement.
13
+ *
14
+ * Trims and drops empty results.
15
+ */
16
+ export declare function splitStatements(command: string): string[];
17
+ /**
18
+ * Return any redirect targets that follow a heredoc operator. Each
19
+ * `cat <<EOF > target` (or `>> target`) anywhere in the command
20
+ * contributes one entry. Returns the raw target token without quote
21
+ * stripping.
22
+ */
23
+ export declare function extractHeredocTargets(command: string): string[];
24
+ /**
25
+ * True when the command uses command substitution `$(...)` / backticks,
26
+ * or a non-literal variable reference (anything other than the well-
27
+ * known `$HOME` / `$USER` / `$PWD` which the catastrophic-path rule
28
+ * already understands).
29
+ */
30
+ export declare function hasObfuscation(command: string): boolean;
31
+ //# sourceMappingURL=bashAnalysis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bashAnalysis.d.ts","sourceRoot":"","sources":["../../src/deterministic/bashAnalysis.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA2CzD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAS/D;AAWD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAYvD"}
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /**
3
+ * Light-weight bash analysis utilities used by deterministic rules to
4
+ * see past common obfuscation patterns. This is intentionally not a
5
+ * full shell parser; it handles the cases that matter for guardrails
6
+ * (separators, quoting, heredoc redirects, command substitution
7
+ * markers) and stays small.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.splitStatements = splitStatements;
11
+ exports.extractHeredocTargets = extractHeredocTargets;
12
+ exports.hasObfuscation = hasObfuscation;
13
+ /**
14
+ * Split a command line into top-level statements separated by `;`,
15
+ * `&&`, `||`, `|`, or newline. Respects single- and double-quoted
16
+ * regions so separators inside strings are preserved as part of the
17
+ * statement.
18
+ *
19
+ * Trims and drops empty results.
20
+ */
21
+ function splitStatements(command) {
22
+ const out = [];
23
+ let buf = '';
24
+ let i = 0;
25
+ let quote = null;
26
+ while (i < command.length) {
27
+ const c = command[i];
28
+ if (quote) {
29
+ buf += c;
30
+ if (c === quote)
31
+ quote = null;
32
+ i++;
33
+ continue;
34
+ }
35
+ if (c === '"' || c === "'") {
36
+ quote = c;
37
+ buf += c;
38
+ i++;
39
+ continue;
40
+ }
41
+ if (c === ';' || c === '\n' || c === '|') {
42
+ // `||` collapses with `|`; `&&` handled below.
43
+ out.push(buf);
44
+ buf = '';
45
+ i++;
46
+ // collapse a trailing | of `||`
47
+ if (c === '|' && command[i] === '|')
48
+ i++;
49
+ continue;
50
+ }
51
+ if (c === '&' && command[i + 1] === '&') {
52
+ out.push(buf);
53
+ buf = '';
54
+ i += 2;
55
+ continue;
56
+ }
57
+ buf += c;
58
+ i++;
59
+ }
60
+ out.push(buf);
61
+ return out.map((s) => s.trim()).filter((s) => s.length > 0);
62
+ }
63
+ /**
64
+ * Return any redirect targets that follow a heredoc operator. Each
65
+ * `cat <<EOF > target` (or `>> target`) anywhere in the command
66
+ * contributes one entry. Returns the raw target token without quote
67
+ * stripping.
68
+ */
69
+ function extractHeredocTargets(command) {
70
+ const targets = [];
71
+ // `<<` or `<<-`, optional `'TAG'` or `"TAG"` or bare TAG, then a redirect.
72
+ // We are permissive about whitespace.
73
+ const re = /<<-?\s*(?:['"]?\w+['"]?)\s*(?:>>?)\s*([^\s;|&<>]+)/g;
74
+ for (const m of command.matchAll(re)) {
75
+ if (m[1])
76
+ targets.push(m[1]);
77
+ }
78
+ return targets;
79
+ }
80
+ const KNOWN_LITERAL_VARS = new Set([
81
+ '$HOME',
82
+ '${HOME}',
83
+ '$PWD',
84
+ '${PWD}',
85
+ '$USER',
86
+ '${USER}',
87
+ ]);
88
+ /**
89
+ * True when the command uses command substitution `$(...)` / backticks,
90
+ * or a non-literal variable reference (anything other than the well-
91
+ * known `$HOME` / `$USER` / `$PWD` which the catastrophic-path rule
92
+ * already understands).
93
+ */
94
+ function hasObfuscation(command) {
95
+ if (/\$\([^)]*\)/.test(command))
96
+ return true;
97
+ if (/`[^`]*`/.test(command))
98
+ return true;
99
+ // Match each $VAR / ${VAR} occurrence and decide whether it is one
100
+ // of the literal allowlist.
101
+ const re = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/g;
102
+ for (const m of command.matchAll(re)) {
103
+ const tok = m[0];
104
+ if (!KNOWN_LITERAL_VARS.has(tok))
105
+ return true;
106
+ }
107
+ return false;
108
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"preventBashSecretWrite.d.ts","sourceRoot":"","sources":["../../../src/deterministic/rules/preventBashSecretWrite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAe,MAAM,UAAU,CAAA;AAwDzD,eAAO,MAAM,sBAAsB,EAAE,iBAkBpC,CAAA"}
1
+ {"version":3,"file":"preventBashSecretWrite.d.ts","sourceRoot":"","sources":["../../../src/deterministic/rules/preventBashSecretWrite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAe,MAAM,UAAU,CAAA;AA6DzD,eAAO,MAAM,sBAAsB,EAAE,iBAsBpC,CAAA"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.preventBashSecretWrite = void 0;
4
+ const bashAnalysis_1 = require("../bashAnalysis");
4
5
  const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template', '.dist'];
5
6
  function basename(path) {
6
7
  const cleaned = path.replace(/[\s'"]+$/, '');
@@ -30,18 +31,18 @@ function isSecretTargetPath(rawPath) {
30
31
  return false;
31
32
  }
32
33
  /**
33
- * Extracts files that the command writes to via redirect (>, >>) or `tee`.
34
- * Conservative: matches the next non-flag token after the operator/word.
34
+ * Extracts every file the command writes to:
35
+ * 1. Standard redirects (`>`, `>>`, `1>`, `2>`, `&>`)
36
+ * 2. `tee [-a] FILE [FILE ...]`
37
+ * 3. Heredoc redirects (`cat <<EOF > file`)
35
38
  */
36
39
  function extractWriteTargets(command) {
37
40
  const targets = [];
38
- // Redirect operators: >, >>, &>, &>>, 1>, 2> etc.
39
41
  const redirectRe = /[12&]?>>?\s*([^\s;|&<>]+)/g;
40
42
  for (const m of command.matchAll(redirectRe)) {
41
43
  if (m[1])
42
44
  targets.push(m[1]);
43
45
  }
44
- // tee [-a] FILE [FILE ...]
45
46
  const teeRe = /\btee\b(?:\s+-[A-Za-z]+)*\s+([^\s;|&<>]+(?:\s+[^\s;|&<>]+)*)/g;
46
47
  for (const m of command.matchAll(teeRe)) {
47
48
  if (m[1]) {
@@ -51,6 +52,9 @@ function extractWriteTargets(command) {
51
52
  }
52
53
  }
53
54
  }
55
+ for (const t of (0, bashAnalysis_1.extractHeredocTargets)(command)) {
56
+ targets.push(t);
57
+ }
54
58
  return targets;
55
59
  }
56
60
  exports.preventBashSecretWrite = {
@@ -61,13 +65,17 @@ exports.preventBashSecretWrite = {
61
65
  const command = toolInput.command;
62
66
  if (typeof command !== 'string')
63
67
  return { kind: 'allow' };
64
- const targets = extractWriteTargets(command);
65
- for (const target of targets) {
66
- if (isSecretTargetPath(target)) {
67
- return {
68
- kind: 'block',
69
- reason: `Refusing to write to a likely secret/credential file via shell redirect: ${target}. If this is intentional, run the command manually outside of the agent.`,
70
- };
68
+ // Inspect each top-level statement; heredoc spans newlines so the
69
+ // statement splitter preserves the heredoc body inside its segment.
70
+ for (const stmt of (0, bashAnalysis_1.splitStatements)(command)) {
71
+ const targets = extractWriteTargets(stmt);
72
+ for (const target of targets) {
73
+ if (isSecretTargetPath(target)) {
74
+ return {
75
+ kind: 'block',
76
+ reason: `Refusing to write to a likely secret/credential file via shell redirect: ${target}. If this is intentional, run the command manually outside of the agent.`,
77
+ };
78
+ }
71
79
  }
72
80
  }
73
81
  return { kind: 'allow' };
@@ -1 +1 @@
1
- {"version":3,"file":"preventRmRfRoot.d.ts","sourceRoot":"","sources":["../../../src/deterministic/rules/preventRmRfRoot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAe,MAAM,UAAU,CAAA;AA+CzD,eAAO,MAAM,eAAe,EAAE,iBAmB7B,CAAA"}
1
+ {"version":3,"file":"preventRmRfRoot.d.ts","sourceRoot":"","sources":["../../../src/deterministic/rules/preventRmRfRoot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAe,MAAM,UAAU,CAAA;AA0EzD,eAAO,MAAM,eAAe,EAAE,iBAa7B,CAAA"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.preventRmRfRoot = void 0;
4
+ const bashAnalysis_1 = require("../bashAnalysis");
4
5
  const CATASTROPHIC_TARGETS = new Set([
5
6
  '/',
6
7
  '$HOME',
@@ -44,6 +45,29 @@ function extractTargets(command) {
44
45
  const tokens = trimmed.split(/\s+/);
45
46
  return tokens.slice(1).filter((t) => !t.startsWith('-'));
46
47
  }
48
+ function evaluateStatement(stmt) {
49
+ if (!isRecursiveForceRm(stmt))
50
+ return { kind: 'allow' };
51
+ // Substitution / unresolved variable targets cannot be evaluated
52
+ // statically. Treat them as suspicious: we cannot prove they are not
53
+ // catastrophic, so block.
54
+ if ((0, bashAnalysis_1.hasObfuscation)(stmt)) {
55
+ return {
56
+ kind: 'block',
57
+ reason: 'Refusing recursive rm whose target is a command substitution or unresolved variable. The target cannot be evaluated statically, so the operation is rejected as a safety precaution.',
58
+ };
59
+ }
60
+ const targets = extractTargets(stmt);
61
+ for (const target of targets) {
62
+ if (CATASTROPHIC_TARGETS.has(target)) {
63
+ return {
64
+ kind: 'block',
65
+ reason: `Refusing to run recursive rm on a catastrophic path: ${target}. If this is genuinely intended, run the command manually outside of the agent.`,
66
+ };
67
+ }
68
+ }
69
+ return { kind: 'allow' };
70
+ }
47
71
  exports.preventRmRfRoot = {
48
72
  id: 'prevent-rm-rf-root',
49
73
  check(toolName, toolInput) {
@@ -52,16 +76,10 @@ exports.preventRmRfRoot = {
52
76
  const command = toolInput.command;
53
77
  if (typeof command !== 'string')
54
78
  return { kind: 'allow' };
55
- if (!isRecursiveForceRm(command))
56
- return { kind: 'allow' };
57
- const targets = extractTargets(command);
58
- for (const target of targets) {
59
- if (CATASTROPHIC_TARGETS.has(target)) {
60
- return {
61
- kind: 'block',
62
- reason: `Refusing to run recursive rm on a catastrophic path: ${target}. If this is genuinely intended, run the command manually outside of the agent.`,
63
- };
64
- }
79
+ for (const stmt of (0, bashAnalysis_1.splitStatements)(command)) {
80
+ const v = evaluateStatement(stmt);
81
+ if (v.kind === 'block')
82
+ return v;
65
83
  }
66
84
  return { kind: 'allow' };
67
85
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiro-c/agent-gate",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Runtime rule enforcer for AI coding agents. Reads CLAUDE.md / AGENTS.md / .cursorrules and enforces them via Claude Code and Cursor hooks, with a deterministic safety baseline.",
5
5
  "author": "Hiro-Chiba",
6
6
  "license": "MIT",