@shomra/agent 0.3.22 → 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.
|
|
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": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { autonomySeverity, localAutonomy } from './signals/autonomy.mjs';
|
|
2
2
|
export { RISKY_CONFIG_MARKERS } from './signals/config-markers.mjs';
|
|
3
|
+
export { classifyConsequence } from './signals/consequence.mjs';
|
|
3
4
|
export { detectCredentialHarvest } from './signals/credential-harvest.mjs';
|
|
4
5
|
export { claimSeverity, detectDurableClaims } from './signals/durable-claims.mjs';
|
|
5
6
|
export { SUSPICIOUS_EGRESS_HOSTS, egressHost } from './signals/egress.mjs';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much a tool call COSTS if it is wrong - the ladder the backend grades on,
|
|
3
|
+
* mirrored here so the client can decide it OFFLINE.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ THIS IS THE FAIL-CLOSED KEY. The hook fails open by design: an agent that
|
|
6
|
+
* hard-stops on an unreachable SaaS backend is one nobody keeps installed. But
|
|
7
|
+
* failing open on EVERY call means an attacker buys a bypass with a slow input
|
|
8
|
+
* or a rate limit. Grading the consequence on-machine is what lets the hook
|
|
9
|
+
* keep ordinary work flowing while still refusing a destructive call it could
|
|
10
|
+
* not get screened.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ It is a MIRROR of src/modules/runtime/intent/domain/consequence.ts and must
|
|
13
|
+
* stay one - pinned by the backend's test/parity/consequence-mirror-bench.mjs.
|
|
14
|
+
* Drift here is asymmetric in the same way every other mirror is: stricter than
|
|
15
|
+
* the server blocks work no server verdict would have blocked, and looser puts
|
|
16
|
+
* a hole in the floor at exactly the moment the floor is all there is.
|
|
17
|
+
*/
|
|
18
|
+
const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push|rm)\b/i;
|
|
19
|
+
|
|
20
|
+
const MATERIAL_VERB =
|
|
21
|
+
/\b(transfer|pay|payment|refund|charge|invoice|wire|send|email|post|publish|deploy|release|merge|approve|grant|invite|share|upload|export)\b/i;
|
|
22
|
+
|
|
23
|
+
const AUTHORITY_GRANT =
|
|
24
|
+
/\b(assume|impersonate|escalate|elevate|sudo|attach|grant|bind|add|put|set|create|assign)\s+(?:\w+\s+){0,2}(role|member|policy|binding|permission|principal|group|user|iam|owner|admin|access)\b/i;
|
|
25
|
+
|
|
26
|
+
const PRIVILEGED_TARGET =
|
|
27
|
+
/\b(admin|administrator|owner|owners|root|superuser|orgadmin|administratoraccess|poweruser|cluster[-_]?admin|iam:\*)\b/i;
|
|
28
|
+
|
|
29
|
+
const PERSISTENCE_TARGET =
|
|
30
|
+
/(^|\/)\.(ssh|aws|kube)\/|authorized_keys|\.bashrc|\.zshrc|\.profile|crontab|\/etc\/(sudoers|passwd|shadow)|systemd|(^|\/)\.env(\.|$)/i;
|
|
31
|
+
|
|
32
|
+
const PRODUCTION = /\b(prod|production|live|main|master|release)\b/i;
|
|
33
|
+
|
|
34
|
+
/* ⚠ `force[-_]?push` never matched the way anybody writes it, so `git push
|
|
35
|
+
* --force origin main` graded MATERIAL and the fail-closed rung never covered
|
|
36
|
+
* it. --force-with-lease is included on purpose: it refuses when the remote
|
|
37
|
+
* moved, which makes it safer, not less destructive. */
|
|
38
|
+
const FORCE_PUSH = /\bgit\s+push\b[^|;&\n]*?\s--?f(?:orce(?:-with-lease)?)?\b/i;
|
|
39
|
+
|
|
40
|
+
export function classifyConsequence(input) {
|
|
41
|
+
const leaf = String(input.tool ?? '').replace(/^mcp__[^_]+__/, '');
|
|
42
|
+
const raw = `${leaf} ${input.args ?? ''}`;
|
|
43
|
+
const blob = raw.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
44
|
+
|
|
45
|
+
if (typeof input.amount === 'number' && input.amount > 0) {
|
|
46
|
+
return SEVERE_VERB.test(blob) ? 'severe' : 'material';
|
|
47
|
+
}
|
|
48
|
+
if (PERSISTENCE_TARGET.test(raw)) return 'severe';
|
|
49
|
+
if (SEVERE_VERB.test(blob) || FORCE_PUSH.test(raw)) return 'severe';
|
|
50
|
+
if (AUTHORITY_GRANT.test(blob)) return PRIVILEGED_TARGET.test(blob) ? 'severe' : 'material';
|
|
51
|
+
if (input.isShell) return 'material';
|
|
52
|
+
if (MATERIAL_VERB.test(blob)) return 'material';
|
|
53
|
+
if (input.isEgress) return 'material';
|
|
54
|
+
if (input.actionId && PRODUCTION.test(blob) && MATERIAL_VERB.test(input.actionId)) return 'severe';
|
|
55
|
+
return 'routine';
|
|
56
|
+
}
|
|
@@ -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 } : {}),
|
package/src/guard/tool-guard.mjs
CHANGED
|
@@ -4,8 +4,11 @@ import path from 'node:path';
|
|
|
4
4
|
import { resolveAgentIdentityHandle } from '../commands/agent-identity.mjs';
|
|
5
5
|
import { isMemoryPath, reportMemoryWrite } from '../commands/memory-scan.mjs';
|
|
6
6
|
import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
|
|
7
|
+
import { CONFIG_DIR } from '../core/config.mjs';
|
|
8
|
+
import { makeLedgerStore } from './ledger.mjs';
|
|
9
|
+
import { VERSION } from '../core/version.mjs';
|
|
7
10
|
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
8
|
-
import { downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
|
|
11
|
+
import { classifyConsequence, downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
|
|
9
12
|
import { WRITE_TOOLS, guardNeedsServer, guardTargetPath, guardText } from './classify.mjs';
|
|
10
13
|
import { emitGuardAsk, emitGuardDeny } from './emit.mjs';
|
|
11
14
|
import { guardPathAllowlisted } from './ignore.mjs';
|
|
@@ -16,6 +19,99 @@ import { buildGuardBody, reportGuardDecision } from './report.mjs';
|
|
|
16
19
|
|
|
17
20
|
const ALLOW_VERDICT = { verdict: 'ALLOW', top: null, findings: [] };
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* ⚠ FAILING OPEN ON EVERYTHING IS AN ENFORCEMENT BYPASS AN ATTACKER BUYS WITH A
|
|
24
|
+
* SLOW INPUT. The hook has to fail open - an agent that hard-stops on an
|
|
25
|
+
* unreachable SaaS backend is one nobody keeps installed - but "open on every
|
|
26
|
+
* call" means padding a command until the screen times out runs it unscreened,
|
|
27
|
+
* which is cheaper than any evasion in the corpus.
|
|
28
|
+
*
|
|
29
|
+
* So the rung decides. A routine or material call still flows: that is the
|
|
30
|
+
* promise that keeps the hook installed. A SEVERE one - a recursive delete, a
|
|
31
|
+
* force push over a shared branch, a write into ~/.ssh - stops and ASKS.
|
|
32
|
+
*
|
|
33
|
+
* ⚠ IT ASKS, IT DOES NOT DENY. A deny during an outage is unappealable at 3am
|
|
34
|
+
* and gets the hook uninstalled, taking every other control with it. An ask
|
|
35
|
+
* puts the human who is already sitting there in the loop and says plainly
|
|
36
|
+
* that the call was NOT screened, which is the honest sentence: we do not know
|
|
37
|
+
* that this is dangerous, we know that we could not check.
|
|
38
|
+
*/
|
|
39
|
+
function failOpenOnSevere() {
|
|
40
|
+
return envFlag('SHOMRA_GUARD_FAILOPEN_SEVERE');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unscreenedSevere(normalized, tool, input) {
|
|
44
|
+
if (failOpenOnSevere()) return false;
|
|
45
|
+
return classifyConsequence({
|
|
46
|
+
tool,
|
|
47
|
+
args: guardText(tool, input),
|
|
48
|
+
isShell: !WRITE_TOOLS.has(tool) && typeof input?.command === 'string',
|
|
49
|
+
}) === 'severe';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ⚠⚠ THE LEDGER HAD NO PRODUCER. `guard/ledger.mjs` builds the fail-open window
|
|
54
|
+
* the backend's EnforcementGap reads - and nothing in this repo ever called
|
|
55
|
+
* `countCall`, so every client reported ZERO gaps forever. The backend then
|
|
56
|
+
* asks whether a capable reporter exists, gets silence, and the estate reads
|
|
57
|
+
* either "no outages" or NOT_ATTEMPTABLE. Both are wrong and one is flattering:
|
|
58
|
+
* a smoke detector reporting no fire with a dead battery, which is the exact
|
|
59
|
+
* shape enforcement-availability.ts was written to prevent.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ COUNTS ARE LOWER BOUNDS BY DESIGN - concurrent hook processes race this
|
|
62
|
+
* file, and the backend already treats them as a floor. Do not "fix" that with
|
|
63
|
+
* a lock on the firewall's hot path.
|
|
64
|
+
*/
|
|
65
|
+
function ledger() {
|
|
66
|
+
return makeLedgerStore(CONFIG_DIR, { version: VERSION });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A call that ran with no server verdict: Tier-0 screened it, or nothing did. */
|
|
70
|
+
function countUnscreened(reason) {
|
|
71
|
+
try {
|
|
72
|
+
ledger().count(localTierDisabled() ? 'unscreened' : 'local', reason);
|
|
73
|
+
} catch {
|
|
74
|
+
/* The ledger is a record, never a gate: a failure to write one must not
|
|
75
|
+
* take the firewall down. The count is a floor and this makes it lower. */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Close the open window and hand the backend everything not yet acknowledged. */
|
|
80
|
+
function sendLedger() {
|
|
81
|
+
try {
|
|
82
|
+
const store = ledger();
|
|
83
|
+
store.close();
|
|
84
|
+
const env = store.envelope();
|
|
85
|
+
pendingLedger = env.gaps ?? [];
|
|
86
|
+
return env;
|
|
87
|
+
} catch {
|
|
88
|
+
/* No ledger is a lower bound, not a wrong number. */
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let pendingLedger = [];
|
|
94
|
+
|
|
95
|
+
function ackLedger() {
|
|
96
|
+
if (!pendingLedger.length) return;
|
|
97
|
+
try {
|
|
98
|
+
ledger().ack(pendingLedger);
|
|
99
|
+
} catch {
|
|
100
|
+
/* Unacknowledged gaps are re-sent next time; a duplicate is a floor read
|
|
101
|
+
* twice, which is safe. Losing one is not. */
|
|
102
|
+
}
|
|
103
|
+
pendingLedger = [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function askUnscreened(agent, why) {
|
|
107
|
+
emitGuardAsk(
|
|
108
|
+
agent,
|
|
109
|
+
`Shomra could not screen this call (${why}), and it is a destructive one - a delete, a force push, `
|
|
110
|
+
+ 'or a write to a file that survives the session. Nothing has judged it: approve it only if you meant it. '
|
|
111
|
+
+ 'Set SHOMRA_GUARD_FAILOPEN_SEVERE=1 to let these through unscreened.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
19
115
|
function readHookPayload() {
|
|
20
116
|
try {
|
|
21
117
|
return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
@@ -78,7 +174,7 @@ function reportUnauthenticated(agent, status, strict) {
|
|
|
78
174
|
process.exit(0);
|
|
79
175
|
}
|
|
80
176
|
|
|
81
|
-
async function requestServerDecision({ url, apiKey, agentId, body, agent, strict }) {
|
|
177
|
+
async function requestServerDecision({ url, apiKey, agentId, body, agent, strict, retried, onUnreachable }) {
|
|
82
178
|
const controller = new AbortController();
|
|
83
179
|
const timer = setTimeout(() => controller.abort(), guardTimeoutMs());
|
|
84
180
|
try {
|
|
@@ -97,19 +193,48 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
|
|
|
97
193
|
|
|
98
194
|
if (!response.ok) {
|
|
99
195
|
if (response.status === 401 || response.status === 403) reportUnauthenticated(agent, response.status, strict);
|
|
196
|
+
/* ⚠ A 429 IS NOT AN OUTAGE, and treating it as one was a silent
|
|
197
|
+
* enforcement bypass: tripping the breaker skips the server for the whole
|
|
198
|
+
* cooldown, so one burst past the rate limit switched org policy, agent
|
|
199
|
+
* identity and flow control off for thirty seconds - on the machine, with
|
|
200
|
+
* nothing said. It means "we are here, come back", so it is retried once
|
|
201
|
+
* against Retry-After and never counted against the breaker. */
|
|
202
|
+
if (response.status === 429) {
|
|
203
|
+
const wait = retryAfterMs(response);
|
|
204
|
+
if (wait !== null && !retried) {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
await sleep(wait);
|
|
207
|
+
return requestServerDecision({ url, apiKey, agentId, body, agent, strict, retried: true, onUnreachable });
|
|
208
|
+
}
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
return onUnreachable('rate limited', { breaker: false });
|
|
211
|
+
}
|
|
100
212
|
throw new Error(`HTTP ${response.status}`);
|
|
101
213
|
}
|
|
102
214
|
const decision = await response.json();
|
|
103
215
|
breakerReset();
|
|
216
|
+
ackLedger();
|
|
104
217
|
return decision;
|
|
105
218
|
} catch (error) {
|
|
106
219
|
clearTimeout(timer);
|
|
107
220
|
breakerTrip();
|
|
108
221
|
if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${error.message}); blocked by fail-closed policy.`);
|
|
109
|
-
return
|
|
222
|
+
return onUnreachable(error.message, { breaker: true });
|
|
110
223
|
}
|
|
111
224
|
}
|
|
112
225
|
|
|
226
|
+
/** Honours a seconds or an HTTP-date Retry-After; null when the server named none. */
|
|
227
|
+
function retryAfterMs(response) {
|
|
228
|
+
const raw = response.headers?.get?.('retry-after');
|
|
229
|
+
if (!raw) return null;
|
|
230
|
+
const secs = Number(raw);
|
|
231
|
+
if (Number.isFinite(secs)) return Math.min(Math.max(secs, 0), 5) * 1000;
|
|
232
|
+
const when = Date.parse(raw);
|
|
233
|
+
return Number.isFinite(when) ? Math.min(Math.max(when - Date.now(), 0), 5000) : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
237
|
+
|
|
113
238
|
function enforceServerDecision(agent, decision) {
|
|
114
239
|
if (decision?.hold) {
|
|
115
240
|
emitGuardAsk(agent, decision.reason || 'Held for approval by Shomra - waiting on a reviewer. Retry once it’s approved.');
|
|
@@ -142,14 +267,37 @@ export async function cmdToolGuard(flags) {
|
|
|
142
267
|
if (strict) {
|
|
143
268
|
emitGuardDeny(agent, 'Shomra is not configured on this machine (SHOMRA_GUARD_STRICT). Run: shomra init --key shm_…');
|
|
144
269
|
}
|
|
270
|
+
if (unscreenedSevere(normalized, tool, input)) askUnscreened(agent, 'Shomra is not configured on this machine');
|
|
145
271
|
process.exit(0);
|
|
146
272
|
}
|
|
147
273
|
|
|
148
274
|
await recordMemoryWrite({ url, apiKey, input, normalized });
|
|
149
275
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
276
|
+
/* ⚠ A SEVERE CALL IS ALWAYS WORTH THE ROUND TRIP. `guardNeedsServer` asks
|
|
277
|
+
* which calls are worth escalating and answered NO for `git push --force
|
|
278
|
+
* origin main` and `rm -rf` alike - so the most destructive calls in the
|
|
279
|
+
* estate were graded by the offline tier and NOTHING ELSE: no org policy, no
|
|
280
|
+
* capability check, no flow control, and no gate event to read afterwards. */
|
|
281
|
+
const severe = unscreenedSevere(normalized, tool, input);
|
|
282
|
+
const escalate = alwaysEscalate || severe || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
|
|
283
|
+
/* ⚠ A call the client CHOSE not to escalate is still a call no server graded,
|
|
284
|
+
* and the denominator has to carry it or the fail-open rate is measured over
|
|
285
|
+
* the escalated traffic alone - which flatters it by exactly the calls the
|
|
286
|
+
* client decided were dull. */
|
|
287
|
+
if (!escalate) {
|
|
288
|
+
countUnscreened('not escalated - screened by the local tier only');
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* Every path out of here that did NOT get a server verdict goes through this
|
|
293
|
+
* one door, so a new way of failing cannot quietly skip the rung check. */
|
|
294
|
+
const onUnreachable = (why) => {
|
|
295
|
+
countUnscreened(why);
|
|
296
|
+
if (severe) askUnscreened(agent, why);
|
|
297
|
+
return process.exit(0);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
if (!strict && breakerOpen()) onUnreachable('the guard is in its backoff window after an earlier failure');
|
|
153
301
|
|
|
154
302
|
const flagged = local.verdict === 'FLAG';
|
|
155
303
|
const decision = await requestServerDecision({
|
|
@@ -158,7 +306,14 @@ export async function cmdToolGuard(flags) {
|
|
|
158
306
|
agentId,
|
|
159
307
|
agent,
|
|
160
308
|
strict,
|
|
161
|
-
|
|
309
|
+
onUnreachable,
|
|
310
|
+
body: {
|
|
311
|
+
...buildGuardBody(normalized, agent, flagged ? 'FLAG' : undefined, flagged ? local.top?.label : undefined),
|
|
312
|
+
/* The window closes the moment a verdict arrives, and rides out on the
|
|
313
|
+
* SAME request - a separate report would be a second round trip on the
|
|
314
|
+
* firewall's hot path, and one that fails exactly when the first did. */
|
|
315
|
+
guard_ledger: sendLedger(),
|
|
316
|
+
},
|
|
162
317
|
});
|
|
163
318
|
|
|
164
319
|
enforceServerDecision(agent, decision);
|