@shomra/agent 0.3.24 → 0.3.25
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/README.md +1 -0
- package/package.json +1 -1
- package/src/agents/hook-command.mjs +19 -3
- package/src/agents/installers.mjs +35 -21
- package/src/cli/help-sections.mjs +2 -2
- package/src/cli/main.mjs +1 -1
- package/src/cli/registry.mjs +2 -0
- package/src/commands/protect.mjs +11 -1
- package/src/gate/environment.mjs +82 -3
- package/src/guard/session-guard.mjs +66 -0
package/README.md
CHANGED
|
@@ -513,6 +513,7 @@ suppressed file drops to ALLOW and never fails the build:
|
|
|
513
513
|
| `SHOMRA_AGENT` | Agent-identity handle presented to `llm-proxy` + firewall |
|
|
514
514
|
| `SHOMRA_GATE_CONCURRENCY` | Parallel backend calls in batch gate / model lookups (default 8, 1-32) |
|
|
515
515
|
| `SHOMRA_GH_TOKEN` | GitHub token for `shomra pr` (falls back to `GITHUB_TOKEN`) |
|
|
516
|
+
| `SHOMRA_ENVIRONMENT` | Declare where this runs: `LOCAL` \| `CI` \| `REMOTE`. Set `REMOTE` on a cloud agent runtime whose markers Shomra does not yet detect (Codex cloud, Jules, Cursor background agents, Devin…), so its sessions are not counted as developer machines. ⚠ It may only ever RAISE — it can never relabel a detected cloud container as a laptop. |
|
|
516
517
|
| `SHOMRA_GUARD_STRICT` | `1` = firewall fails closed on the server tier |
|
|
517
518
|
| `SHOMRA_GUARD_LOCAL` | `0` = disable the on-machine Tier-0 guard |
|
|
518
519
|
| `SHOMRA_GUARD_IGNORE` | Comma-separated file globs the runtime guard treats as known-safe (adds to `.shomraignore`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import { CLI_ENTRY_PATH } from '../core/package-root.mjs';
|
|
2
|
+
import { VERSION } from '../core/version.mjs';
|
|
2
3
|
|
|
3
4
|
export const LLM_PROXY_BASE = process.env.SHOMRA_LLM_PROXY_BASE || 'http://127.0.0.1:4141/openai/v1';
|
|
4
5
|
|
|
@@ -8,12 +9,27 @@ function quoteArg(argument) {
|
|
|
8
9
|
return /\s/.test(argument) ? `"${argument}"` : argument;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export const PACKAGE_SPEC = `@shomra/agent@${VERSION}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ⚠⚠ AN ABSOLUTE PATH DOES NOT SURVIVE THE FILE IT IS WRITTEN INTO. A project
|
|
16
|
+
* install lands in `.claude/settings.json`, which is COMMITTED - so the hook is
|
|
17
|
+
* read on a colleague's laptop and inside the ephemeral cloud container Claude
|
|
18
|
+
* Code on the web clones the repo into. Neither has this checkout, so the hook
|
|
19
|
+
* points at nothing, the agent logs a spawn error, and NOTHING IS SCREENED while
|
|
20
|
+
* the settings file says it is. Portable mode resolves through npm instead.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ THE VERSION IS PINNED, never `@latest`. A committed hook is code that runs
|
|
23
|
+
* on other people's machines; one that silently changes when we publish is not a
|
|
24
|
+
* control anybody reviewed.
|
|
25
|
+
*/
|
|
26
|
+
export function hookCommand(args, opts = {}) {
|
|
27
|
+
if (opts.portable) return `npx -y ${PACKAGE_SPEC} ${args}`;
|
|
12
28
|
return `${quoteArg(process.execPath)} ${quoteArg(SELF_PATH)} ${args}`;
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
export function shomraHookRe(verb) {
|
|
16
|
-
return new RegExp(`shomra(
|
|
32
|
+
return new RegExp(`(?:shomra(?:\\.mjs"?)?|@shomra/agent(?:@[\\w.\\-]+)?)\\s+${verb}`, 'i');
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
export const SHOMRA_ANY_HOOK_RE = /shomra(
|
|
35
|
+
export const SHOMRA_ANY_HOOK_RE = /(?:shomra(?:\.mjs"?)?|@shomra\/agent(?:@[\w.\-]+)?)\s+(tool-guard|result-guard|prompt-guard|plan-guard|session-guard)/i;
|
|
@@ -20,6 +20,7 @@ export const AGENT_KEYS = Object.keys(AGENT_LABELS);
|
|
|
20
20
|
|
|
21
21
|
export const AGENT_INSTALLERS = {
|
|
22
22
|
claude(global) {
|
|
23
|
+
const portable = !global;
|
|
23
24
|
const dir = global ? path.join(os.homedir(), '.claude') : path.join(process.cwd(), '.claude');
|
|
24
25
|
const file = path.join(dir, 'settings.json');
|
|
25
26
|
const settings = readJsonFile(file);
|
|
@@ -28,22 +29,28 @@ export const AGENT_INSTALLERS = {
|
|
|
28
29
|
const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
|
|
29
30
|
let changed = false;
|
|
30
31
|
if (!hasGroupedHook(pre, 'tool-guard')) {
|
|
31
|
-
pre.push({ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent claude') }] });
|
|
32
|
+
pre.push({ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent claude', { portable }) }] });
|
|
32
33
|
changed = true;
|
|
33
34
|
}
|
|
34
35
|
if (!hasGroupedHook(post, 'result-guard')) {
|
|
35
|
-
post.push({ matcher: 'WebFetch|WebSearch|Read|NotebookRead|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent claude') }] });
|
|
36
|
+
post.push({ matcher: 'WebFetch|WebSearch|Read|NotebookRead|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent claude', { portable }) }] });
|
|
36
37
|
changed = true;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const prompt = (settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit || []);
|
|
40
41
|
if (!hasGroupedHook(prompt, 'prompt-guard')) {
|
|
41
|
-
prompt.push({ hooks: [{ type: 'command', command: hookCommand('prompt-guard --agent claude') }] });
|
|
42
|
+
prompt.push({ hooks: [{ type: 'command', command: hookCommand('prompt-guard --agent claude', { portable }) }] });
|
|
42
43
|
changed = true;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
if (!hasGroupedHook(pre, 'plan-guard')) {
|
|
46
|
-
pre.push({ matcher: 'ExitPlanMode', hooks: [{ type: 'command', command: hookCommand('plan-guard --agent claude') }] });
|
|
47
|
+
pre.push({ matcher: 'ExitPlanMode', hooks: [{ type: 'command', command: hookCommand('plan-guard --agent claude', { portable }) }] });
|
|
48
|
+
changed = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const start = (settings.hooks.SessionStart = settings.hooks.SessionStart || []);
|
|
52
|
+
if (!hasGroupedHook(start, 'session-guard')) {
|
|
53
|
+
start.push({ hooks: [{ type: 'command', command: hookCommand('session-guard --agent claude', { portable }) }] });
|
|
47
54
|
changed = true;
|
|
48
55
|
}
|
|
49
56
|
if (changed) {
|
|
@@ -54,6 +61,7 @@ export const AGENT_INSTALLERS = {
|
|
|
54
61
|
},
|
|
55
62
|
|
|
56
63
|
codex(global) {
|
|
64
|
+
const portable = !global;
|
|
57
65
|
const dir = global ? path.join(os.homedir(), '.codex') : path.join(process.cwd(), '.codex');
|
|
58
66
|
const file = path.join(dir, 'hooks.json');
|
|
59
67
|
const settings = readJsonFile(file);
|
|
@@ -61,11 +69,11 @@ export const AGENT_INSTALLERS = {
|
|
|
61
69
|
const post = (settings.PostToolUse = settings.PostToolUse || []);
|
|
62
70
|
let changed = false;
|
|
63
71
|
if (!hasGroupedHook(pre, 'tool-guard')) {
|
|
64
|
-
pre.push({ matcher: 'Bash|Write|Edit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent codex') }] });
|
|
72
|
+
pre.push({ matcher: 'Bash|Write|Edit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent codex', { portable }) }] });
|
|
65
73
|
changed = true;
|
|
66
74
|
}
|
|
67
75
|
if (!hasGroupedHook(post, 'result-guard')) {
|
|
68
|
-
post.push({ matcher: 'WebFetch|WebSearch|Read|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent codex') }] });
|
|
76
|
+
post.push({ matcher: 'WebFetch|WebSearch|Read|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent codex', { portable }) }] });
|
|
69
77
|
changed = true;
|
|
70
78
|
}
|
|
71
79
|
if (changed) {
|
|
@@ -76,6 +84,7 @@ export const AGENT_INSTALLERS = {
|
|
|
76
84
|
},
|
|
77
85
|
|
|
78
86
|
gemini(global) {
|
|
87
|
+
const portable = !global;
|
|
79
88
|
const dir = global ? path.join(os.homedir(), '.gemini') : path.join(process.cwd(), '.gemini');
|
|
80
89
|
const file = path.join(dir, 'settings.json');
|
|
81
90
|
const settings = readJsonFile(file);
|
|
@@ -84,11 +93,11 @@ export const AGENT_INSTALLERS = {
|
|
|
84
93
|
const after = (settings.hooks.AfterTool = settings.hooks.AfterTool || []);
|
|
85
94
|
let changed = false;
|
|
86
95
|
if (!hasGroupedHook(before, 'tool-guard')) {
|
|
87
|
-
before.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent gemini') }] });
|
|
96
|
+
before.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent gemini', { portable }) }] });
|
|
88
97
|
changed = true;
|
|
89
98
|
}
|
|
90
99
|
if (!hasGroupedHook(after, 'result-guard')) {
|
|
91
|
-
after.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent gemini') }] });
|
|
100
|
+
after.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent gemini', { portable }) }] });
|
|
92
101
|
changed = true;
|
|
93
102
|
}
|
|
94
103
|
if (changed) {
|
|
@@ -99,6 +108,7 @@ export const AGENT_INSTALLERS = {
|
|
|
99
108
|
},
|
|
100
109
|
|
|
101
110
|
cursor(global) {
|
|
111
|
+
const portable = !global;
|
|
102
112
|
const dir = global ? path.join(os.homedir(), '.cursor') : path.join(process.cwd(), '.cursor');
|
|
103
113
|
const file = path.join(dir, 'hooks.json');
|
|
104
114
|
const cfg = readJsonFile(file);
|
|
@@ -112,12 +122,12 @@ export const AGENT_INSTALLERS = {
|
|
|
112
122
|
changed = true;
|
|
113
123
|
}
|
|
114
124
|
};
|
|
115
|
-
wire('beforeShellExecution', hookCommand('tool-guard --agent cursor'));
|
|
116
|
-
wire('beforeMCPExecution', hookCommand('tool-guard --agent cursor'));
|
|
117
|
-
wire('afterFileEdit', hookCommand('result-guard --agent cursor'));
|
|
118
|
-
wire('afterMCPExecution', hookCommand('result-guard --agent cursor'));
|
|
125
|
+
wire('beforeShellExecution', hookCommand('tool-guard --agent cursor', { portable }));
|
|
126
|
+
wire('beforeMCPExecution', hookCommand('tool-guard --agent cursor', { portable }));
|
|
127
|
+
wire('afterFileEdit', hookCommand('result-guard --agent cursor', { portable }));
|
|
128
|
+
wire('afterMCPExecution', hookCommand('result-guard --agent cursor', { portable }));
|
|
119
129
|
|
|
120
|
-
wire('beforeSubmitPrompt', hookCommand('prompt-guard --agent cursor'));
|
|
130
|
+
wire('beforeSubmitPrompt', hookCommand('prompt-guard --agent cursor', { portable }));
|
|
121
131
|
if (changed) {
|
|
122
132
|
fs.mkdirSync(dir, { recursive: true });
|
|
123
133
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
|
|
@@ -126,6 +136,7 @@ export const AGENT_INSTALLERS = {
|
|
|
126
136
|
},
|
|
127
137
|
|
|
128
138
|
windsurf(global) {
|
|
139
|
+
const portable = !global;
|
|
129
140
|
const dir = global ? path.join(os.homedir(), '.codeium', 'windsurf') : path.join(process.cwd(), '.windsurf');
|
|
130
141
|
const file = path.join(dir, 'hooks.json');
|
|
131
142
|
const cfg = readJsonFile(file);
|
|
@@ -138,10 +149,10 @@ export const AGENT_INSTALLERS = {
|
|
|
138
149
|
changed = true;
|
|
139
150
|
}
|
|
140
151
|
};
|
|
141
|
-
wire('pre_run_command', hookCommand('tool-guard --agent windsurf'));
|
|
142
|
-
wire('pre_write_code', hookCommand('tool-guard --agent windsurf'));
|
|
143
|
-
wire('pre_mcp_tool_use', hookCommand('tool-guard --agent windsurf'));
|
|
144
|
-
wire('post_mcp_tool_use', hookCommand('result-guard --agent windsurf'));
|
|
152
|
+
wire('pre_run_command', hookCommand('tool-guard --agent windsurf', { portable }));
|
|
153
|
+
wire('pre_write_code', hookCommand('tool-guard --agent windsurf', { portable }));
|
|
154
|
+
wire('pre_mcp_tool_use', hookCommand('tool-guard --agent windsurf', { portable }));
|
|
155
|
+
wire('post_mcp_tool_use', hookCommand('result-guard --agent windsurf', { portable }));
|
|
145
156
|
if (changed) {
|
|
146
157
|
fs.mkdirSync(dir, { recursive: true });
|
|
147
158
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
|
|
@@ -150,12 +161,13 @@ export const AGENT_INSTALLERS = {
|
|
|
150
161
|
},
|
|
151
162
|
|
|
152
163
|
copilot(global) {
|
|
164
|
+
const portable = !global;
|
|
153
165
|
const dir = global ? path.join(os.homedir(), '.copilot', 'hooks') : path.join(process.cwd(), '.github', 'hooks');
|
|
154
166
|
const file = path.join(dir, 'shomra.json');
|
|
155
167
|
if (fs.existsSync(file)) return { file, changed: false };
|
|
156
168
|
const cfg = {
|
|
157
|
-
preToolUse: [{ command: hookCommand('tool-guard --agent copilot') }],
|
|
158
|
-
postToolUse: [{ command: hookCommand('result-guard --agent copilot') }],
|
|
169
|
+
preToolUse: [{ command: hookCommand('tool-guard --agent copilot', { portable }) }],
|
|
170
|
+
postToolUse: [{ command: hookCommand('result-guard --agent copilot', { portable }) }],
|
|
159
171
|
};
|
|
160
172
|
fs.mkdirSync(dir, { recursive: true });
|
|
161
173
|
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
|
|
@@ -163,6 +175,7 @@ export const AGENT_INSTALLERS = {
|
|
|
163
175
|
},
|
|
164
176
|
|
|
165
177
|
cline(global) {
|
|
178
|
+
const portable = !global;
|
|
166
179
|
const dir = global ? path.join(os.homedir(), '.cline') : path.join(process.cwd(), '.cline');
|
|
167
180
|
const file = path.join(dir, 'hooks.json');
|
|
168
181
|
const settings = readJsonFile(file);
|
|
@@ -171,11 +184,11 @@ export const AGENT_INSTALLERS = {
|
|
|
171
184
|
const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
|
|
172
185
|
let changed = false;
|
|
173
186
|
if (!hasGroupedHook(pre, 'tool-guard')) {
|
|
174
|
-
pre.push({ matcher: 'execute_command|write_to_file|replace_in_file|new_rule|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent cline') }] });
|
|
187
|
+
pre.push({ matcher: 'execute_command|write_to_file|replace_in_file|new_rule|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent cline', { portable }) }] });
|
|
175
188
|
changed = true;
|
|
176
189
|
}
|
|
177
190
|
if (!hasGroupedHook(post, 'result-guard')) {
|
|
178
|
-
post.push({ matcher: 'read_file|web_fetch|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('result-guard --agent cline') }] });
|
|
191
|
+
post.push({ matcher: 'read_file|web_fetch|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('result-guard --agent cline', { portable }) }] });
|
|
179
192
|
changed = true;
|
|
180
193
|
}
|
|
181
194
|
if (changed) {
|
|
@@ -186,6 +199,7 @@ export const AGENT_INSTALLERS = {
|
|
|
186
199
|
},
|
|
187
200
|
|
|
188
201
|
aider(global) {
|
|
202
|
+
const portable = !global;
|
|
189
203
|
const file = global ? path.join(os.homedir(), '.aider.conf.yml') : path.join(process.cwd(), '.aider.conf.yml');
|
|
190
204
|
let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
191
205
|
if (/#\s*shomra llm guard/i.test(text) || text.includes(LLM_PROXY_BASE)) {
|
|
@@ -33,7 +33,7 @@ const COMMANDS = () => `${bold('COMMANDS')}
|
|
|
33
33
|
|
|
34
34
|
${dim('Setup - run once per machine / repo')}
|
|
35
35
|
${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
|
|
36
|
-
${cyan('protect')} Wire the runtime firewall for every coding agent ${dim('[--
|
|
36
|
+
${cyan('protect')} Wire the runtime firewall for every coding agent ${dim('[--project] [--force]')}
|
|
37
37
|
${cyan('install-hook')} Wire the runtime firewall into ONE agent ${dim('[--agent claude|cursor|windsurf|gemini|codex|copilot|cline|aider|all] [--global]')}
|
|
38
38
|
${cyan('provenance')} Which changed files an AI agent wrote ${dim('[--staged | --base main] [--trailer] [--fail-on-blocked] [--json]')}
|
|
39
39
|
${cyan('install-precommit')} Gate staged AI artifacts on git commit ${dim('[dir] [--force] · --pre-receive for the un-skippable server-side hook')}
|
|
@@ -76,7 +76,7 @@ const COMMANDS = () => `${bold('COMMANDS')}
|
|
|
76
76
|
${cyan('admin')} Deep scans, red-team, hardening, agent identity, LLM proxy
|
|
77
77
|
${dim('scan-zip · model-scan · memory-scan · redteam · campaign · harden · agent-identity · llm-proxy')}
|
|
78
78
|
|
|
79
|
-
${dim('(internal hook handlers, invoked by install-hook - not run by hand: tool-guard, result-guard, prompt-guard, plan-guard)')}
|
|
79
|
+
${dim('(internal hook handlers, invoked by install-hook - not run by hand: tool-guard, result-guard, prompt-guard, plan-guard, session-guard)')}
|
|
80
80
|
`;
|
|
81
81
|
|
|
82
82
|
const GATE = () => `${bold('GATE')}
|
package/src/cli/main.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export async function main() {
|
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const guardCmd = command === 'tool-guard' || command === 'result-guard' || command === 'prompt-guard' || command === 'plan-guard';
|
|
24
|
+
const guardCmd = command === 'tool-guard' || command === 'result-guard' || command === 'prompt-guard' || command === 'plan-guard' || command === 'session-guard';
|
|
25
25
|
if (unknown.length && !guardCmd) {
|
|
26
26
|
for (const u of unknown) {
|
|
27
27
|
const near = didYouMean(u, [...KNOWN_FLAGS]);
|
package/src/cli/registry.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { cmdSecrets } from '../commands/secrets.mjs';
|
|
|
29
29
|
import { cmdStatus } from '../commands/status.mjs';
|
|
30
30
|
import { cmdWhy } from '../commands/why.mjs';
|
|
31
31
|
import { cmdPromptGuard } from '../guard/prompt-guard.mjs';
|
|
32
|
+
import { cmdSessionGuard } from '../guard/session-guard.mjs';
|
|
32
33
|
import { cmdResultGuard } from '../guard/result-guard.mjs';
|
|
33
34
|
import { cmdToolGuard } from '../guard/tool-guard.mjs';
|
|
34
35
|
|
|
@@ -60,6 +61,7 @@ export const COMMANDS = {
|
|
|
60
61
|
'result-guard': (f) => cmdResultGuard(f),
|
|
61
62
|
'prompt-guard': (f) => cmdPromptGuard(f),
|
|
62
63
|
'plan-guard': (f) => cmdPlanGuard(f),
|
|
64
|
+
'session-guard': (f) => cmdSessionGuard(f),
|
|
63
65
|
plan: (f, p) => cmdPlan(f, p),
|
|
64
66
|
corpus: (f, p) => cmdCorpus(f, p),
|
|
65
67
|
rules: (f, p) => cmdRules(f, p),
|
package/src/commands/protect.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function cmdProtect(flags) {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const global = !flags.local;
|
|
19
|
+
const global = !(flags.local || flags.project);
|
|
20
20
|
console.log(bold(cyan('\n Shomra protect')) + dim(` - wiring the runtime firewall for ${detected.length} coding agent${detected.length > 1 ? 's' : ''} (${global ? 'machine-wide' : 'this repo'})`));
|
|
21
21
|
let wired = 0, already = 0;
|
|
22
22
|
for (const a of detected) {
|
|
@@ -32,6 +32,16 @@ export function cmdProtect(flags) {
|
|
|
32
32
|
}
|
|
33
33
|
console.log(`\n ${wired ? green(`✓ ${wired} newly protected`) : green('✓ Already protected')}${already ? dim(` · ${already} already wired`) : ''}${dim(' - tool calls, results and prompts now screened on-machine.')}`);
|
|
34
34
|
|
|
35
|
+
if (global) {
|
|
36
|
+
console.log(dim('\n ⚠ Machine-wide only. A CLOUD session (Claude Code on the web) runs in a fresh'));
|
|
37
|
+
console.log(dim(' container with none of this, and so does a colleague\'s checkout. Run ') + bold('shomra protect --project'));
|
|
38
|
+
console.log(dim(' and COMMIT ') + bold('.claude/settings.json') + dim(' to cover both - the hooks resolve through npm there.'));
|
|
39
|
+
} else {
|
|
40
|
+
console.log(dim('\n ✓ Written into this repo. ') + bold('Commit .claude/settings.json') + dim(' - it is what carries the firewall'));
|
|
41
|
+
console.log(dim(' into cloud sessions and onto every checkout. These hooks resolve through npm, so'));
|
|
42
|
+
console.log(dim(' they work on a machine that has never installed Shomra.'));
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
console.log(dim('\n Get in front of the model too - both write into this repo, so run them where you mean to:'));
|
|
36
46
|
console.log(` ${bold('shomra rules --write')} ${dim('teach the agent what gets blocked, so it never writes it')}`);
|
|
37
47
|
console.log(` ${bold('shomra mcp install')} ${dim('let the agent gate its own proposed content before writing')}\n`);
|
package/src/gate/environment.mjs
CHANGED
|
@@ -4,8 +4,79 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'auto'];
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* ⚠ A CLOUD AGENT SESSION IS NOT A DEVELOPER MACHINE, and until this existed it
|
|
9
|
+
* reported as one. Claude Code on the web runs in an EPHEMERAL container: fresh
|
|
10
|
+
* $HOME, no `shomra` config, a hostname nobody will ever see again. It carries
|
|
11
|
+
* no CI variables, so it fell through to LOCAL - and an operator counting
|
|
12
|
+
* "screened laptops" was counting containers that no longer exist.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ CI IS CHECKED FIRST and stays first. A cloud session driven by a GitHub
|
|
15
|
+
* Action is CI: that branch carries repo, ref and commit, which is the stronger
|
|
16
|
+
* attribution. REMOTE is what is left when nothing else names where this ran.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* ⚠ ONE ENTRY PER RUNTIME, and only where the variable has been SEEN. A marker
|
|
20
|
+
* invented from a vendor's docs either never fires - useless - or fires on a
|
|
21
|
+
* name something else uses, which labels a real laptop as an ephemeral
|
|
22
|
+
* container and puts a `floor` on a machine the org actually owns. Add a row
|
|
23
|
+
* here once somebody has read the variable out of a live session of that
|
|
24
|
+
* runtime; until then that runtime uses SHOMRA_ENVIRONMENT below, which is the
|
|
25
|
+
* whole reason the override exists.
|
|
26
|
+
*
|
|
27
|
+
* `verified` records who has actually seen it, so the next person can tell a
|
|
28
|
+
* confirmed marker from an optimistic one.
|
|
29
|
+
*/
|
|
30
|
+
export const REMOTE_RUNTIMES = [
|
|
31
|
+
{
|
|
32
|
+
runner: 'claude-code-cloud',
|
|
33
|
+
label: 'Claude Code on the web',
|
|
34
|
+
verified: true,
|
|
35
|
+
vars: ['CLAUDE_CODE_CONTAINER_ID', 'CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION'],
|
|
36
|
+
prefixed: { CLAUDE_CODE_ENTRYPOINT: /^remote/i },
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⚠ CODESPACES AND DEVCONTAINERS ARE DELIBERATELY ABSENT. They are containers,
|
|
42
|
+
* but they PERSIST between sessions, can be enrolled, and their guard survives
|
|
43
|
+
* to report a window it spent blind - which is the whole distinction REMOTE
|
|
44
|
+
* draws. Filing them here would put "we cannot vouch for its silence" on
|
|
45
|
+
* machines that can in fact vouch for it.
|
|
46
|
+
*/
|
|
47
|
+
export function remoteRunner(e = process.env) {
|
|
48
|
+
for (const rt of REMOTE_RUNTIMES) {
|
|
49
|
+
for (const key of rt.vars) if (String(e?.[key] ?? '').trim()) return rt.runner;
|
|
50
|
+
for (const [key, re] of Object.entries(rt.prefixed ?? {})) {
|
|
51
|
+
if (re.test(String(e?.[key] ?? '').trim())) return rt.runner;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ENV_RANK = { LOCAL: 0, CI: 1, REMOTE: 2 };
|
|
58
|
+
|
|
59
|
+
export function declaredEnvironment(e = process.env) {
|
|
60
|
+
const v = String(e?.SHOMRA_ENVIRONMENT ?? '').trim().toUpperCase();
|
|
61
|
+
return v in ENV_RANK ? v : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ⚠⚠ THE OVERRIDE MAY ONLY EVER RAISE. `SHOMRA_ENVIRONMENT` exists so an
|
|
66
|
+
* operator can declare a runtime we do not yet detect - a cloud agent from a
|
|
67
|
+
* vendor whose markers nobody has read. Letting it go the other way would make
|
|
68
|
+
* it a switch that relabels a detected ephemeral container as a trusted laptop,
|
|
69
|
+
* clearing the `floor` its unreportable silence earns. That is the same
|
|
70
|
+
* privilege-reduction shape `mayRaiseOnly` and `foldTimeout` refuse on the
|
|
71
|
+
* server, and it would be reachable by anything that can set an env var.
|
|
72
|
+
*/
|
|
73
|
+
export function mergeEnvironment(detected, declared) {
|
|
74
|
+
if (!declared) return detected;
|
|
75
|
+
return ENV_RANK[declared] > ENV_RANK[detected] ? declared : detected;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function detectEnv(env) {
|
|
79
|
+
const e = env ?? process.env;
|
|
9
80
|
const pick = (...keys) => {
|
|
10
81
|
for (const k of keys) if (e[k]?.trim()) return e[k].trim();
|
|
11
82
|
return undefined;
|
|
@@ -54,6 +125,10 @@ export function detectEnv() {
|
|
|
54
125
|
if (ci) {
|
|
55
126
|
|
|
56
127
|
const git = gitContext();
|
|
128
|
+
const declared = declaredEnvironment(e);
|
|
129
|
+
if (mergeEnvironment('CI', declared) === 'REMOTE') {
|
|
130
|
+
return { environment: 'REMOTE', runner: remoteRunner(e) ?? 'declared', ...git };
|
|
131
|
+
}
|
|
57
132
|
return {
|
|
58
133
|
environment: 'CI',
|
|
59
134
|
ciProvider: ci.ciProvider,
|
|
@@ -64,7 +139,11 @@ export function detectEnv() {
|
|
|
64
139
|
};
|
|
65
140
|
}
|
|
66
141
|
|
|
67
|
-
|
|
142
|
+
const runner = remoteRunner(e);
|
|
143
|
+
const environment = mergeEnvironment(runner ? 'REMOTE' : 'LOCAL', declaredEnvironment(e));
|
|
144
|
+
if (environment === 'REMOTE') return { environment, runner: runner ?? 'declared', ...gitContext() };
|
|
145
|
+
|
|
146
|
+
return { environment, ...gitContext() };
|
|
68
147
|
}
|
|
69
148
|
|
|
70
149
|
function gitContext() {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
2
|
+
import { detectEnv, remoteRunner } from '../gate/environment.mjs';
|
|
3
|
+
import { envFlag, resolveAgentFlag } from './options.mjs';
|
|
4
|
+
|
|
5
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
6
|
+
|
|
7
|
+
export function sessionPosture(env, settings, reachable) {
|
|
8
|
+
const remote = env.environment === 'REMOTE';
|
|
9
|
+
if (!settings.apiKey) {
|
|
10
|
+
return {
|
|
11
|
+
enforcing: 'local-only',
|
|
12
|
+
remote,
|
|
13
|
+
message: remote
|
|
14
|
+
? 'This is an EPHEMERAL CLOUD SESSION and no Shomra key reached it. Nothing you installed on your laptop is here. Only the offline Tier-0 screen is running; no policy, no server-side flow taint, and nothing about this session will appear in Shomra.'
|
|
15
|
+
: 'Shomra is not configured on this machine. Only the offline Tier-0 screen is running.',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (reachable === false) {
|
|
19
|
+
return {
|
|
20
|
+
enforcing: 'degraded',
|
|
21
|
+
remote,
|
|
22
|
+
message: remote
|
|
23
|
+
? 'This CLOUD SESSION cannot reach the Shomra backend - its network policy is blocking egress. Tool calls run screened by the offline tier only, and ⚠ THIS CONTAINER IS EPHEMERAL: the gap ledger dies with it, so these calls may never be reported as unscreened.'
|
|
24
|
+
: 'The Shomra backend is unreachable. The offline tier still screens; server-side policy does not.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { enforcing: 'full', remote, message: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function reachable(url) {
|
|
31
|
+
if (!url) return false;
|
|
32
|
+
const ctrl = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
|
34
|
+
try {
|
|
35
|
+
await fetch(`${url.replace(/\/+$/, '')}/healthz`, { signal: ctrl.signal, headers: { Connection: 'close' } });
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ⚠ THE ONE THING A CLOUD SESSION CAN TELL YOU IS THAT IT STARTED. Everything
|
|
46
|
+
* downstream - the tool guard, the gap ledger, the enforcement record - needs
|
|
47
|
+
* egress the container may not have, and the container is reclaimed either way.
|
|
48
|
+
* So this runs first, says out loud what is and is not enforcing, and never
|
|
49
|
+
* blocks: a session that refuses to start is a control nobody keeps installed.
|
|
50
|
+
*/
|
|
51
|
+
export async function cmdSessionGuard(flags) {
|
|
52
|
+
resolveAgentFlag(flags);
|
|
53
|
+
if (envFlag('SHOMRA_SESSION_GUARD_OFF')) return process.exit(0);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const env = detectEnv();
|
|
57
|
+
const settings = resolveSettings(loadConfig());
|
|
58
|
+
const posture = sessionPosture(env, settings, settings.apiKey ? await reachable(settings.url) : null);
|
|
59
|
+
if (posture.message) process.stderr.write(`[shomra] ${posture.message}\n`);
|
|
60
|
+
} catch {
|
|
61
|
+
|
|
62
|
+
}
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { remoteRunner };
|