@shomra/agent 0.3.24 → 0.3.26

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 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.24",
3
+ "version": "0.3.26",
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 function hookCommand(args) {
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(\\.mjs"?)?\\s+${verb}`, 'i');
32
+ return new RegExp(`(?:shomra(?:\\.mjs"?)?|@shomra/agent(?:@[\\w.\\-]+)?)\\s+${verb}`, 'i');
17
33
  }
18
34
 
19
- export const SHOMRA_ANY_HOOK_RE = /shomra(\.mjs"?)?\s+(tool-guard|result-guard|prompt-guard|plan-guard)/i;
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('[--local] [--force]')}
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]);
@@ -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),
@@ -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`);
@@ -9,7 +9,7 @@ export { localGate } from './signals/gate.mjs';
9
9
  export { INJECTION_PHRASES, INJECTION_REGEXES, INVISIBLE_CHARS_RE } from './signals/injection.mjs';
10
10
  export { localMemory } from './signals/memory.mjs';
11
11
  export { AGENT_ROOT_RE, isAgentAdjacentPath, localPropagation } from './signals/propagation.mjs';
12
- export { citationGoverns, isDocumentationLine, prohibitsAt } from './signals/prose-context.mjs';
12
+ export { citationGoverns, describesAt, isDocumentationLine, prohibitsAt } from './signals/prose-context.mjs';
13
13
  export { downrankCodeContext, localScan } from './signals/scan.mjs';
14
14
  export { PII_PATTERNS, SECRET_PATTERNS } from './signals/secrets.mjs';
15
15
  export { NETWORK_VERBS, SENSITIVE_READ } from './signals/sensitive.mjs';
@@ -1,4 +1,4 @@
1
- import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
1
+ import { describesAt, isDocumentationLine, prohibitsAt } from './prose-context.mjs';
2
2
 
3
3
  const AUTONOMY_RULES = [
4
4
  { family: 'confirmation', label: 'Acts without asking', re: /\b(?:without (?:asking|confirming|prompting|waiting for|seeking)(?:\s+(?:the\s+)?(?:user|me|anyone|permission|approval|confirmation))?|do(?:es)? not (?:ask|prompt|wait|check|confirm)[^.\n]{0,30}\b(?:for|before|first|permission|approval|confirmation)|no need to (?:ask|confirm|check with))\b/i },
@@ -36,7 +36,7 @@ export function localAutonomy(text) {
36
36
  const m = rule.re.exec(line);
37
37
  if (!m) continue;
38
38
  if (isDocumentationLine(line)) continue;
39
- if (prohibitsAt(line, m.index)) continue;
39
+ if (prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
40
40
  if (insideQuotedSpan(line, m.index)) continue;
41
41
  seen.add(rule.label);
42
42
  out.push({ family: rule.family, label: rule.label, line: i + 1 });
@@ -1,4 +1,4 @@
1
- import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
1
+ import { describesAt, isDocumentationLine, prohibitsAt } from './prose-context.mjs';
2
2
 
3
3
  const HARVEST_PROMPTS = [
4
4
  { re: /\bosascript\b[\s\S]{0,200}?\bdisplay\s+dialog\b[\s\S]{0,200}?\bhidden\s+answer\b/i, label: 'osascript password dialog (hidden answer)' },
@@ -45,14 +45,14 @@ export function detectCredentialHarvest(text) {
45
45
  for (const p of HARVEST_PROMPTS) {
46
46
  const m = p.re.exec(line);
47
47
  if (!m) continue;
48
- if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
48
+ if (isDocumentationLine(line) || prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
49
49
  push('interactive-prompt', p.label, 'CRITICAL', i, line);
50
50
  }
51
51
  for (const s of HARVEST_STORES) {
52
52
  const m = s.re.exec(line);
53
53
  if (!m) continue;
54
54
  if (!HARVEST_READ_VERB.test(line)) continue;
55
- if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
55
+ if (isDocumentationLine(line) || prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
56
56
  push(s.family, s.label, HARVEST_EXFIL_VERB.test(line) ? 'CRITICAL' : 'HIGH', i, line);
57
57
  }
58
58
  const t = HARVEST_TOKEN_PATH.exec(line);
@@ -1,7 +1,7 @@
1
1
  import { CLAIM_LABEL, claimSeverity, detectDurableClaims } from './durable-claims.mjs';
2
2
  import { LOCAL_URL_RE, egressHost } from './egress.mjs';
3
3
  import { lineOf, lineTextAt } from './lines.mjs';
4
- import { citationGoverns, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
4
+ import { citationGoverns, describesAt, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
5
5
  import { localScan } from './scan.mjs';
6
6
  import { NETWORK_VERBS, SENSITIVE_READ } from './sensitive.mjs';
7
7
  import { DANGEROUS_SHELL } from './shell.mjs';
@@ -44,6 +44,7 @@ export function offendingLine(sig, text) {
44
44
  if (sig.refine && !sig.refine(line)) continue;
45
45
  if (isDocumentationLine(line)) continue;
46
46
  if (prohibitsAt(line, line.indexOf(m[0]))) continue;
47
+ if (describesAt(line, line.indexOf(m[0]))) continue;
47
48
  if (isRiskTableRow(line)) continue;
48
49
  return line;
49
50
  }
@@ -71,6 +71,59 @@ export function prohibitsAt(line, offset) {
71
71
  return !DOUBLE_NEGATIVE_RE.test(before);
72
72
  }
73
73
 
74
+ /**
75
+ * ⚠ AN OFFSET ON THE OPENING BACKTICK IS INSIDE THE SPAN. Several rules anchor
76
+ * on the backtick itself (a markdown code span and a shell command substitution
77
+ * are the same character), so counting only what precedes the offset put every
78
+ * code-span guard one character outside the span it was meant to be inside.
79
+ */
80
+ function insideCodeSpan(line, offset) {
81
+ let ticks = 0;
82
+ for (let i = 0; i < offset && i < line.length; i++) if (line[i] === '`') ticks++;
83
+ if (ticks % 2 === 1) return true;
84
+ return line[offset] === '`' && line.indexOf('`', offset + 1) !== -1;
85
+ }
86
+
87
+ /**
88
+ * ⚠ A LINE THAT NAMES A COMMAND AS ITS SUBJECT IS DESCRIBING IT — the second
89
+ * carve-out `carriesHardEvidence` needs, for the same reason `prohibitsAt` was
90
+ * the first. Security documentation is written in exactly this mood -
91
+ * *"`curl … | sh` is a pipe-to-shell installer"*, *"`chmod 777` means the file
92
+ * is world-writable"* - so offline, where no server verdict ever arrives to
93
+ * correct it, every threat model and runbook in a careful repo blocked.
94
+ *
95
+ * ⚠ THE LEAD-IN IS NOT THE SIGNAL, THE PREDICATE IS. "For example, run
96
+ * `curl … | sh`" is an instruction wearing a documentation opener, and
97
+ * "example" is one token an attacker adds for free. What cannot be faked
98
+ * cheaply is the command sitting in SUBJECT position with a copular or
99
+ * reporting verb after it: an instruction puts the command in OBJECT position
100
+ * after an imperative.
101
+ *
102
+ * ⚠ THE MATCH MUST BE INSIDE A CODE SPAN, and ⚠ AN IMPERATIVE BEFORE IT WINS.
103
+ *
104
+ * Mirrors `describesAt` in the backend's prose-context.ts — `local-mirror-bench`
105
+ * compares the two on every mood, in both directions.
106
+ */
107
+ const DESCRIPTIVE_PREDICATE_RE =
108
+ /^\s*(?:,\s*)?(?:is|are|was|were|means?|meant|shows?|showed|demonstrates?|indicates?|signals?|denotes?|describes?|represents?|counts?\s+as|reads?\s+as|matches?|fires?|triggers?|flags?|catches?|becomes?|remains?|stays?|looks?\s+like|would\s+\w+|will\s+\w+|has|have|had)\b/i;
109
+
110
+ const IMPERATIVE_LEAD_RE =
111
+ /\b(?:run|execute|exec|invoke|call|use|paste|copy|type|enter|apply|install|download|fetch|curl|wget|pipe|add|append|write|put|send|post)\b[^.:;\n]{0,40}$/i;
112
+
113
+ export function describesAt(line, offset) {
114
+ if (!line || offset == null || offset < 0 || offset >= line.length) return false;
115
+ if (!insideCodeSpan(line, offset)) return false;
116
+
117
+ const onTick = line[offset] === '`';
118
+ const close = line.indexOf('`', onTick ? offset + 1 : offset);
119
+ if (close === -1) return false;
120
+ if (!DESCRIPTIVE_PREDICATE_RE.test(line.slice(close + 1, close + 60))) return false;
121
+
122
+ const open = onTick ? offset : line.lastIndexOf('`', offset);
123
+ const before = line.slice(Math.max(0, open - 60), open);
124
+ return !IMPERATIVE_LEAD_RE.test(before) && !IMPERATIVE.test(before);
125
+ }
126
+
74
127
  const RISK_CELL_RE = /\b(?:critical|high|medium|low|severity|risk|danger\w*|forbidden|blocked|denied|prohibited|never|do not|example|attack|threat|mitigation|why|impact)\b/i;
75
128
 
76
129
  export function isRiskTableRow(line) {
@@ -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
- export function detectEnv() {
8
- const e = process.env;
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
- return { environment: 'LOCAL', ...gitContext() };
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 };