@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 +1 -1
- package/src/guard/command-resolve.mjs +111 -0
- package/src/guard/report.mjs +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
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
|
+
}
|
package/src/guard/report.mjs
CHANGED
|
@@ -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 } : {}),
|