@shomra/agent 0.3.19 → 0.3.21
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/cli/registry.mjs +2 -0
- package/src/commands/ledger.mjs +157 -0
- package/src/detect/signals/injection.mjs +1 -1
- package/src/ledger/ladder.mjs +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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": {
|
package/src/cli/registry.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { cmdGate } from '../commands/gate.mjs';
|
|
|
9
9
|
import { cmdInstallPrecommit } from '../commands/git-hooks.mjs';
|
|
10
10
|
import { cmdInit } from '../commands/init.mjs';
|
|
11
11
|
import { cmdInstallHook } from '../commands/install-hook.mjs';
|
|
12
|
+
import { cmdLedger } from '../commands/ledger.mjs';
|
|
12
13
|
import { cmdLlmProxy } from '../commands/llm-proxy.mjs';
|
|
13
14
|
import { cmdMcp, cmdMcpGuard } from '../commands/mcp.mjs';
|
|
14
15
|
import { cmdMemoryScan } from '../commands/memory-scan.mjs';
|
|
@@ -66,6 +67,7 @@ export const COMMANDS = {
|
|
|
66
67
|
add: (f, p) => cmdAdd(f, p),
|
|
67
68
|
'install-hook': (f) => cmdInstallHook(f),
|
|
68
69
|
protect: (f) => cmdProtect(f),
|
|
70
|
+
ledger: (f) => cmdLedger(f),
|
|
69
71
|
doctor: (f) => cmdDoctor(f),
|
|
70
72
|
new: (f, p) => cmdNew(f, p),
|
|
71
73
|
mcp: (f, p) => cmdMcp(f, p),
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { agentHookInstalled } from '../agents/hook-files.mjs';
|
|
4
|
+
import { AGENT_KEYS } from '../agents/installers.mjs';
|
|
5
|
+
import { mcpConfigCandidates } from '../mcp/config-wrapping.mjs';
|
|
6
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
7
|
+
import { bold, cyan, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
8
|
+
import { VERSION } from '../core/version.mjs';
|
|
9
|
+
import { RUNG_LABEL, buildReport, isBeliefGap } from '../ledger/ladder.mjs';
|
|
10
|
+
|
|
11
|
+
const UNMEASURED = { discrimination: 'UNKNOWN', prevention: 'UNPROVEN', preventionBasis: null };
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
function mcpReach() {
|
|
23
|
+
let total = 0;
|
|
24
|
+
let mediated = 0;
|
|
25
|
+
let files = 0;
|
|
26
|
+
const selfHints = ['mcp-guard', 'shomra'];
|
|
27
|
+
|
|
28
|
+
for (const file of mcpConfigCandidates()) {
|
|
29
|
+
const config = readJson(file);
|
|
30
|
+
const servers = config?.mcpServers || config?.servers;
|
|
31
|
+
if (!servers || typeof servers !== 'object') continue;
|
|
32
|
+
files++;
|
|
33
|
+
for (const entry of Object.values(servers)) {
|
|
34
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
35
|
+
total++;
|
|
36
|
+
const argv = [entry.command, ...(entry.args ?? [])].map((x) => String(x ?? '')).join(' ');
|
|
37
|
+
const guarded = selfHints.some((h) => argv.includes(h));
|
|
38
|
+
const routed = typeof entry.url === 'string' && /\/mcp\//.test(entry.url);
|
|
39
|
+
if (guarded || routed) mediated++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { total, mediated, files };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function precommitInstalled() {
|
|
46
|
+
for (const dir of ['.git/hooks', path.join(process.cwd(), '.git', 'hooks')]) {
|
|
47
|
+
const file = path.join(dir, 'pre-commit');
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(file) && /shomra/i.test(fs.readFileSync(file, 'utf8'))) return true;
|
|
50
|
+
} catch {
|
|
51
|
+
/* unreadable is not installed */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function subjects() {
|
|
58
|
+
const hooked = AGENT_KEYS.filter((a) => agentHookInstalled(a).length);
|
|
59
|
+
const mcp = mcpReach();
|
|
60
|
+
const precommit = precommitInstalled();
|
|
61
|
+
const enrolled = !!resolveSettings(loadConfig()).apiKey;
|
|
62
|
+
|
|
63
|
+
const out = [];
|
|
64
|
+
|
|
65
|
+
out.push({
|
|
66
|
+
id: 'runtime-gate',
|
|
67
|
+
label: 'the runtime firewall',
|
|
68
|
+
plane: 'tool-call',
|
|
69
|
+
chokepoint: 'every tool call this machine’s agents make',
|
|
70
|
+
mode: hooked.length ? 'ENFORCE' : 'ABSENT',
|
|
71
|
+
axes: { presence: hooked.length ? 'DEPLOYED' : 'NOT_DEPLOYED', reach: null, ...UNMEASURED },
|
|
72
|
+
statement: hooked.length
|
|
73
|
+
? `The tool-call hook is installed for ${hooked.length} agent(s) on this machine. Nothing here has fired an attack at it, so what it does under one is untested.`
|
|
74
|
+
: 'No tool-call hook is installed on this machine, so nothing screens what its agents run.',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
out.push({
|
|
78
|
+
id: 'mcp-gateway',
|
|
79
|
+
label: 'MCP call mediation',
|
|
80
|
+
plane: 'mcp-call',
|
|
81
|
+
chokepoint: 'the tool-call path between an agent and the servers it uses',
|
|
82
|
+
mode: mcp.mediated ? 'ENFORCE' : 'ABSENT',
|
|
83
|
+
axes: {
|
|
84
|
+
presence: mcp.total ? (mcp.mediated ? 'DEPLOYED' : 'NOT_DEPLOYED') : 'UNKNOWN',
|
|
85
|
+
reach: mcp.total
|
|
86
|
+
? {
|
|
87
|
+
state: mcp.mediated === 0 ? 'BYPASSING' : mcp.mediated < mcp.total ? 'PARTIAL' : 'ENFORCED_UNQUANTIFIED',
|
|
88
|
+
observed: mcp.total,
|
|
89
|
+
controlled: mcp.mediated,
|
|
90
|
+
bypassRate: mcp.total ? (mcp.total - mcp.mediated) / mcp.total : null,
|
|
91
|
+
}
|
|
92
|
+
: null,
|
|
93
|
+
...UNMEASURED,
|
|
94
|
+
},
|
|
95
|
+
statement: mcp.total
|
|
96
|
+
? `${mcp.mediated} of ${mcp.total} MCP server(s) across ${mcp.files} local config(s) run through something that screens their calls.`
|
|
97
|
+
: 'No MCP server is configured in any config this machine can read, so nothing is claimed about call-time mediation here.',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
out.push({
|
|
101
|
+
id: 'install-gate',
|
|
102
|
+
label: 'the install-time gate',
|
|
103
|
+
plane: 'mcp-install',
|
|
104
|
+
chokepoint: 'the commit that brings an AI artifact into this repo',
|
|
105
|
+
mode: precommit ? 'ENFORCE' : 'ABSENT',
|
|
106
|
+
axes: { presence: precommit ? 'DEPLOYED' : 'NOT_DEPLOYED', reach: null, ...UNMEASURED },
|
|
107
|
+
statement: precommit
|
|
108
|
+
? 'A Shomra pre-commit hook is installed in this repository.'
|
|
109
|
+
: 'No install-time check runs in this repository, so an artifact reaches it without passing one.',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
out.push({
|
|
113
|
+
id: 'llm-guard',
|
|
114
|
+
label: 'the LLM guard',
|
|
115
|
+
plane: 'llm-egress',
|
|
116
|
+
chokepoint: 'the model proxy every prompt and completion passes through',
|
|
117
|
+
mode: 'ABSENT',
|
|
118
|
+
axes: { presence: 'UNKNOWN', reach: null, ...UNMEASURED },
|
|
119
|
+
statement:
|
|
120
|
+
'Nothing on this machine can see where its prompts actually go, so whether a guard is in that path has not been established here. This is a limit of a local read, not a finding.',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { rows: out, enrolled };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function cmdLedger(flags = {}) {
|
|
127
|
+
const { rows, enrolled } = subjects();
|
|
128
|
+
const report = buildReport(rows, VERSION);
|
|
129
|
+
|
|
130
|
+
if (flags.json) {
|
|
131
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(bold(cyan('\n Control ledger')) + dim(` · ${report.controls.length} controls · local read, no account`));
|
|
136
|
+
console.log(dim(' What has actually been shown about the controls on this machine.\n'));
|
|
137
|
+
|
|
138
|
+
for (const c of report.controls) {
|
|
139
|
+
const gap = isBeliefGap(c.rung);
|
|
140
|
+
const paint = gap ? red : c.rung === 'HOLDING' ? green : c.rung === 'ABSENT' ? yellow : gray;
|
|
141
|
+
console.log(` ${paint('●')} ${bold(c.label.padEnd(24))} ${paint(RUNG_LABEL[c.rung])}`);
|
|
142
|
+
console.log(` ${dim(c.statement)}`);
|
|
143
|
+
if (c.unmeasured.length) console.log(` ${dim('unmeasured: ' + c.unmeasured.join(', '))}`);
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(` ${dim(report.statement)}`);
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log(dim(' Discrimination and prevention need an attack. Nothing here fired one, so no row'));
|
|
150
|
+
console.log(dim(' can reach "held under test" - and this report says so rather than rounding up.'));
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(` ${dim('Publish it:')} shomra ledger --json > ledger.json`);
|
|
153
|
+
console.log(` ${dim('Check it: ')} npx controlledger-verify ledger.json`);
|
|
154
|
+
if (!enrolled) console.log(` ${dim('Prove it: ')} shomra init --key shm_… ${dim('- to attack these controls and fill the last two axes')}`);
|
|
155
|
+
console.log('');
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
@@ -11,7 +11,7 @@ export const INJECTION_PHRASES = [
|
|
|
11
11
|
];
|
|
12
12
|
|
|
13
13
|
export const INJECTION_REGEXES = [
|
|
14
|
-
{ label: 'Instruction-override phrasing', re: /\b(ignore|disregard|override|bypass|circumvent)\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|directive|directives|safety|safeguards?|guardrails?|system\s+prompt|content\s+polic\w+)\b/i },
|
|
14
|
+
{ label: 'Instruction-override phrasing', re: /\b(?:ignore|disregard|override|bypass|circumvent|discard|nullify|revoke|rescind|supersede[sd]?|abandon|set\s+aside|put\s+aside|cast\s+aside|pay\s+no\s+(?:attention|heed)\s+to|take\s+no\s+notice\s+of|stop\s+(?:following|obeying|adhering\s+to)|cease\s+(?:following|obeying)|no\s+longer\s+(?:follow|obey|adhere\s+to))\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|directive|directives|safety|safeguards?|guardrails?|guidelines?|safety\s+(?:rules?|filters?|checks?)|system\s+prompt|content\s+polic\w+)\b/i },
|
|
15
15
|
{ label: 'Instructs the agent to conceal an action from the user', re: /\b(?:do\s*n['o]?t|never|without)\s+(?:tell|telling|inform|informing|notify|notifying|alert|alerting|mention|mentioning|disclos\w+|reveal\w*)\s+(?:it\s+|this\s+|them\s+)?(?:to\s+)?(?:the\s+)?(?:user|users|human|operator|owner)\b(?!['']s)(?!\s+(?:to\b|how\s+to\b|when\s+to\b|that\s+they\b|about\b))/i },
|
|
16
16
|
{ label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|nuke|truncate)\b[\s\w,'"()-]{0,20}?\b(all|every|each|entire|whole)\b[\s\w,'"()-]{0,15}?\b(folder|folders|file|files|directory|directories|table|tables|database|databases|record|records|repo|repos|repositor\w*|account|accounts|user|users|row|rows|document|documents|data)\b/i },
|
|
17
17
|
{ label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema)\b/i },
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
|
|
2
|
+
export const RUNGS = ['DEFEATED', 'INDISCRIMINATE', 'POROUS', 'ABSENT', 'UNEXERCISED', 'REFUSING', 'HOLDING'];
|
|
3
|
+
export const RUNG_RANK = Object.fromEntries(RUNGS.map((r, i) => [r, i]));
|
|
4
|
+
|
|
5
|
+
export const RUNG_LABEL = {
|
|
6
|
+
DEFEATED: 'Defeated under test',
|
|
7
|
+
INDISCRIMINATE: 'Refuses everything',
|
|
8
|
+
POROUS: 'Traffic goes around it',
|
|
9
|
+
ABSENT: 'No enforcement here',
|
|
10
|
+
UNEXERCISED: 'Never met an attack',
|
|
11
|
+
REFUSING: 'Refused an attack',
|
|
12
|
+
HOLDING: 'Held under test',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const GAP_RUNGS = new Set(['DEFEATED', 'INDISCRIMINATE', 'POROUS', 'ABSENT']);
|
|
16
|
+
const BELIEF_GAPS = new Set(['DEFEATED', 'INDISCRIMINATE', 'POROUS']);
|
|
17
|
+
const POROUS_REACH = new Set(['PARTIAL', 'BYPASSING', 'UNSUPPORTED']);
|
|
18
|
+
|
|
19
|
+
export function isBeliefGap(rung) {
|
|
20
|
+
return BELIEF_GAPS.has(rung);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deriveRung(axes) {
|
|
24
|
+
const presence = axes?.presence;
|
|
25
|
+
const reach = axes?.reach ?? null;
|
|
26
|
+
const discrimination = axes?.discrimination;
|
|
27
|
+
const prevention = axes?.prevention;
|
|
28
|
+
const replayed = prevention === 'PREVENTED' && axes?.preventionBasis !== 'executed';
|
|
29
|
+
|
|
30
|
+
if (prevention === 'BYPASSED') return 'DEFEATED';
|
|
31
|
+
if (discrimination === 'INVERTED') return 'DEFEATED';
|
|
32
|
+
if (discrimination === 'INDISCRIMINATE') return 'INDISCRIMINATE';
|
|
33
|
+
if (reach && POROUS_REACH.has(reach.state)) return 'POROUS';
|
|
34
|
+
if (discrimination === 'UNGUARDED') return 'ABSENT';
|
|
35
|
+
if (presence === 'NOT_DEPLOYED') return 'ABSENT';
|
|
36
|
+
if (prevention !== 'PREVENTED' && (discrimination === 'UNEXERCISED' || discrimination === 'UNKNOWN')) return 'UNEXERCISED';
|
|
37
|
+
if (prevention === 'PREVENTED' && (replayed || discrimination !== 'DISCRIMINATING')) return 'REFUSING';
|
|
38
|
+
if (prevention !== 'PREVENTED' && discrimination === 'DISCRIMINATING') return 'REFUSING';
|
|
39
|
+
return 'HOLDING';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function deriveUnmeasured(axes) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const reach = axes?.reach ?? null;
|
|
45
|
+
if (!reach || reach.state === 'UNOBSERVED') out.push('reach');
|
|
46
|
+
if (axes?.discrimination === 'UNKNOWN' || axes?.discrimination === 'UNEXERCISED') out.push('discrimination');
|
|
47
|
+
if (axes?.prevention === 'UNPROVEN' || axes?.prevention === 'NOT_ATTEMPTABLE') out.push('prevention');
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function deriveBasis(axes, rung) {
|
|
52
|
+
if (GAP_RUNGS.has(rung)) return 'conclusive';
|
|
53
|
+
const replayed = axes?.prevention === 'PREVENTED' && axes?.preventionBasis !== 'executed';
|
|
54
|
+
const floor =
|
|
55
|
+
replayed ||
|
|
56
|
+
deriveUnmeasured(axes).length > 0 ||
|
|
57
|
+
!!axes?.truncated ||
|
|
58
|
+
Number(axes?.silentSubjects ?? 0) > 0 ||
|
|
59
|
+
axes?.reach?.state === 'ENFORCED_UNQUANTIFIED';
|
|
60
|
+
return floor ? 'floor' : 'conclusive';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function summarize(rows) {
|
|
64
|
+
if (!rows.length) {
|
|
65
|
+
return (
|
|
66
|
+
'No control on this machine has been measured. That is not a clean result - it is the absence of any result, ' +
|
|
67
|
+
'and it is the state every machine is in until something is fired at it.'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const totals = Object.fromEntries(RUNGS.map((r) => [r, rows.filter((x) => x.rung === r).length]));
|
|
71
|
+
const gaps = rows.filter((x) => isBeliefGap(x.rung)).length;
|
|
72
|
+
const parts = [`${rows.length} control(s) on the ledger`];
|
|
73
|
+
parts.push(
|
|
74
|
+
gaps
|
|
75
|
+
? `${gaps} measured weaker than they would be assumed to be (${totals.DEFEATED} defeated, ${totals.INDISCRIMINATE} refusing everything, ${totals.POROUS} with traffic going around them)`
|
|
76
|
+
: 'none measured weaker than assumed',
|
|
77
|
+
);
|
|
78
|
+
if (totals.ABSENT) parts.push(`${totals.ABSENT} path(s) with no enforcement on them`);
|
|
79
|
+
parts.push(
|
|
80
|
+
totals.HOLDING
|
|
81
|
+
? `${totals.HOLDING} held under a reproduced exploit with ordinary traffic still passing`
|
|
82
|
+
: 'none has been shown to hold under a reproduced exploit',
|
|
83
|
+
);
|
|
84
|
+
if (totals.UNEXERCISED) parts.push(`${totals.UNEXERCISED} never met an attack (neither a pass nor a failure)`);
|
|
85
|
+
return parts.join(' · ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildReport(subjects, version) {
|
|
89
|
+
const controls = subjects.map((s) => {
|
|
90
|
+
const rung = deriveRung(s.axes);
|
|
91
|
+
return {
|
|
92
|
+
id: s.id,
|
|
93
|
+
label: s.label,
|
|
94
|
+
plane: s.plane,
|
|
95
|
+
chokepoint: s.chokepoint,
|
|
96
|
+
mode: s.mode ?? 'ABSENT',
|
|
97
|
+
rung,
|
|
98
|
+
basis: deriveBasis(s.axes, rung),
|
|
99
|
+
axes: {
|
|
100
|
+
presence: s.axes.presence,
|
|
101
|
+
reach: s.axes.reach ?? null,
|
|
102
|
+
discrimination: s.axes.discrimination,
|
|
103
|
+
prevention: s.axes.prevention,
|
|
104
|
+
preventionBasis: s.axes.preventionBasis ?? null,
|
|
105
|
+
truncated: false,
|
|
106
|
+
silentSubjects: s.axes.silentSubjects ?? 0,
|
|
107
|
+
},
|
|
108
|
+
unmeasured: deriveUnmeasured(s.axes),
|
|
109
|
+
statement: s.statement,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const totals = Object.fromEntries(RUNGS.map((r) => [r, controls.filter((c) => c.rung === r).length]));
|
|
114
|
+
return {
|
|
115
|
+
format: 'controlledger/1',
|
|
116
|
+
specVersion: '1.0',
|
|
117
|
+
producer: { name: 'shomra-cli', version: String(version ?? '') },
|
|
118
|
+
generatedAt: new Date().toISOString(),
|
|
119
|
+
basis: controls.length && controls.every((c) => c.basis === 'conclusive') ? 'conclusive' : 'floor',
|
|
120
|
+
controls,
|
|
121
|
+
totals,
|
|
122
|
+
beliefGaps: controls.filter((c) => isBeliefGap(c.rung)).length,
|
|
123
|
+
unmeasured: controls.filter((c) => c.unmeasured.length > 0).length,
|
|
124
|
+
statement: summarize(controls),
|
|
125
|
+
};
|
|
126
|
+
}
|