@shomra/agent 0.3.23 → 0.3.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Shomra - adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const MAX_LEN = 16 * 1024;
5
+ const MAX_UNRESOLVED = 8;
6
+ const MAX_PATH_ENTRIES = 64;
7
+
8
+ const ASSIGN_PREFIX = /^\s*([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S*)\s+/;
9
+ const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
10
+ const SUBST_RE = /\$\([^)]*\)|`[^`\n]*`/;
11
+ const POSITIONAL_RE = /\$[@*#?$0-9]/;
12
+ const INDIRECTION = /[$`]/;
13
+ const ALIAS_OR_FUNCTION = /(?:^|[\s;&|(])(?:alias\s+[A-Za-z_][\w-]*\s*=|function\s+[A-Za-z_][\w-]*\s*(?:\(\s*\))?\s*\{|[A-Za-z_][\w-]*\s*\(\s*\)\s*\{)/;
14
+
15
+ function unquote(v) {
16
+ const s = String(v ?? '');
17
+ if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) return s.slice(1, -1);
18
+ return s;
19
+ }
20
+
21
+ function inlineAssignments(command) {
22
+ const vars = new Map();
23
+ let rest = command;
24
+ for (let m = ASSIGN_PREFIX.exec(rest); m; m = ASSIGN_PREFIX.exec(rest)) {
25
+ vars.set(m[1], unquote(m[2]));
26
+ rest = rest.slice(m[0].length);
27
+ }
28
+ return { vars, rest };
29
+ }
30
+
31
+ /**
32
+ * ⚠ A VARIABLE NOBODY DEFINED STAYS LITERAL AND IS REPORTED UNRESOLVED. Letting
33
+ * it fall through to the empty string is how `rm -rf $DIR/` becomes `rm -rf /`
34
+ * and gets screened as the second one. Absence is reported, never substituted.
35
+ */
36
+ function substitute(text, vars, env, unresolved) {
37
+ return text.replace(VAR_RE, (whole, braced, bare) => {
38
+ const name = braced || bare;
39
+ if (vars.has(name)) return vars.get(name);
40
+ const fromEnv = env?.[name];
41
+ if (typeof fromEnv === 'string' && fromEnv.length) return fromEnv;
42
+ if (unresolved.length < MAX_UNRESOLVED && !unresolved.includes(whole)) unresolved.push(whole);
43
+ return whole;
44
+ });
45
+ }
46
+
47
+ function isExecutable(file, statSync) {
48
+ try {
49
+ const st = statSync(file);
50
+ return st.isFile();
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function locate(word, effectivePath, cwd, statSync) {
57
+ if (!word) return null;
58
+ if (word.includes('/') || word.includes('\\')) {
59
+ const abs = path.isAbsolute(word) ? word : path.resolve(cwd || '.', word);
60
+ return isExecutable(abs, statSync) ? abs : null;
61
+ }
62
+ const entries = String(effectivePath ?? '').split(path.delimiter).filter(Boolean).slice(0, MAX_PATH_ENTRIES);
63
+ for (const dir of entries) {
64
+ const candidate = path.join(dir, word);
65
+ if (isExecutable(candidate, statSync)) return candidate;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * What this command's indirection points at, resolved from the environment and
72
+ * the filesystem this machine actually has - the two things the server does not
73
+ * hold and no pattern can recover from the string.
74
+ *
75
+ * ⚠⚠ IT NEVER EXECUTES ANYTHING. `$(…)` and backticks are REPORTED unresolved,
76
+ * never evaluated: running attacker-influenced text in order to screen it is the
77
+ * vulnerability, not the control. Same reason the scanner resolves an MCP source
78
+ * without launching it.
79
+ *
80
+ * ⚠ The result CLEARS NOTHING server-side. It is evidence the backend screens
81
+ * with the same detectors it ran on the literal text, and it may only ever add.
82
+ */
83
+ export function resolveCommand(command, opts = {}) {
84
+ const cmd = typeof command === 'string' ? command.slice(0, MAX_LEN) : '';
85
+ if (!cmd.trim()) return null;
86
+
87
+ const env = opts.env ?? process.env;
88
+ const statSync = opts.statSync ?? fs.statSync;
89
+ const cwd = opts.cwd ?? env.PWD ?? process.cwd();
90
+
91
+ if (!INDIRECTION.test(cmd) && !ALIAS_OR_FUNCTION.test(cmd)) return null;
92
+
93
+ const unresolved = [];
94
+ if (SUBST_RE.test(cmd)) unresolved.push('command-substitution');
95
+ if (POSITIONAL_RE.test(cmd)) unresolved.push('positional-parameter');
96
+ if (ALIAS_OR_FUNCTION.test(cmd)) unresolved.push('alias-or-function');
97
+
98
+ const { vars, rest } = inlineAssignments(cmd);
99
+ const resolved = substitute(cmd, vars, env, unresolved);
100
+
101
+ const effectivePath = vars.has('PATH') ? substitute(vars.get('PATH'), vars, env, []) : env.PATH;
102
+ const word = substitute(rest, vars, env, []).trim().split(/\s+/)[0] ?? '';
103
+ const executable = SUBST_RE.test(word) || word.includes('$') ? null : locate(word, effectivePath, cwd, statSync);
104
+
105
+ if (resolved === cmd && !executable && !unresolved.length) return null;
106
+ return {
107
+ ...(resolved === cmd ? {} : { command: resolved }),
108
+ ...(executable ? { executable } : {}),
109
+ ...(unresolved.length ? { unresolved } : {}),
110
+ };
111
+ }
@@ -1,11 +1,15 @@
1
1
  import { gateMachine } from '../core/api-client.mjs';
2
2
  import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
3
3
  import { detectEnv } from '../gate/environment.mjs';
4
+ import { resolveCommand } from './command-resolve.mjs';
4
5
 
5
6
  export function buildGuardBody(norm, agent, clientDecision, clientReason) {
7
+ const command = norm.tool_input?.command ?? norm.tool_input?.cmd ?? norm.tool_input?.script;
8
+ const resolved = typeof command === 'string' ? resolveCommand(command) : null;
6
9
  return {
7
10
  tool_name: norm.tool_name,
8
11
  tool_input: norm.tool_input,
12
+ ...(resolved ? { resolved } : {}),
9
13
  cwd: norm.cwd,
10
14
  session_id: norm.session_id,
11
15
  ...(norm.parent_session_id ? { parent_session_id: norm.parent_session_id } : {}),