@shomra/agent 0.3.29 → 0.3.30

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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
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": {
@@ -12,7 +12,7 @@ function quoteArg(argument) {
12
12
  export const PACKAGE_SPEC = `@shomra/agent@${VERSION}`;
13
13
 
14
14
  /**
15
- * ⚠⚠ AN ABSOLUTE PATH DOES NOT SURVIVE THE FILE IT IS WRITTEN INTO. A project
15
+ * AN ABSOLUTE PATH DOES NOT SURVIVE THE FILE IT IS WRITTEN INTO. A project
16
16
  * install lands in `.claude/settings.json`, which is COMMITTED - so the hook is
17
17
  * read on a colleague's laptop and inside the ephemeral cloud container Claude
18
18
  * Code on the web clones the repo into. Neither has this checkout, so the hook
@@ -1,9 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { PLUGIN_MANIFEST_RE, TOOL_MANIFEST_RE } from '../detect/signals/manifests.mjs';
3
4
 
4
5
  export const ARTIFACT_MATCHERS = [
6
+ { kind: 'plugin', re: PLUGIN_MANIFEST_RE },
7
+ { kind: 'tool-manifest', re: TOOL_MANIFEST_RE },
5
8
  { kind: 'mcp', re: /(^|\/)\.?mcp\.json$/i },
6
9
  { kind: 'mcp', re: /(^|\/)\.(vscode|cursor)\/mcp\.json$/i },
10
+ { kind: 'mcp', re: /(^|\/)\.(kiro\/settings|amazonq|roo|windsurf|vs|junie\/mcp)\/mcp\.json$/i },
11
+ { kind: 'mcp', re: /(^|\/)(opencode\.jsonc?|crush\.json|\.codex\/config\.toml|\.continue\/(config\.ya?ml|mcpServers\/[^/]+\.(ya?ml|json)))$/i },
12
+ { kind: 'model-config', re: /(^|\/)(tokenizer_config|generation_config|adapter_config|model_index|special_tokens_map|config_sentence_transformers|preprocessor_config|processor_config)\.json$/i },
13
+ { kind: 'model-config', re: /(^|\/)(chat_template\.(jinja2?|json)|(.*\.)?modelfile|MLmodel|config\.pbtxt|bentofile\.ya?ml)$/i },
7
14
  { kind: 'skill', re: /(^|\/)SKILL\.md$/i },
8
15
  { kind: 'command', re: /(^|\/)\.claude\/commands\/[^/]+\.md$/i },
9
16
  { kind: 'subagent', re: /(^|\/)\.claude\/agents\/[^/]+\.md$/i },
package/src/cli/flags.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  const BOOLEAN_FLAGS = new Set([
2
2
  'strict', 'json', 'sarif', 'fix', 'staged', 'changed', 'all', 'history', 'force',
3
3
  'apply', 'dry-run', 'global', 'local', 'trailer', 'evolve', 'report', 'init',
4
- 'no-suppress', 'no-baseline', 'no-policy', 'no-index', 'adaptive',
4
+ 'no-suppress', 'no-baseline', 'no-policy', 'no-index', 'adaptive', 'offline',
5
5
  'fail-on-regression', 'fail-on-blocked', 'write', 'yes', 'stdin', 'quiet', 'help',
6
- 'check', 'checklist', 'pre-receive', 'uninstall', 'save', 'list',
6
+ 'check', 'checklist', 'pre-receive', 'uninstall', 'save', 'list', 'machine',
7
7
  ]);
8
8
 
9
9
  const VALUE_FLAGS = new Set([
@@ -177,6 +177,13 @@ const MEMORY_SCAN = () => `${bold('MEMORY-SCAN')}
177
177
  each write (with provenance) so Shomra can track drift from an approved baseline
178
178
  and roll back a poisoned store. Once ${bold('install-hook')} is wired, the agent's own
179
179
  memory and rules-file writes are captured automatically. Analysis is static.
180
+
181
+ With no path it scans the current directory ${bold('and')} this machine's agent memory:
182
+ Claude Code (auto + subagent memory), Codex memories, Gemini/Qwen memory, Windsurf,
183
+ Goose, Serena, basic-memory, the VS Code Copilot memory tool, mem0/Letta stores.
184
+ ${bold('--machine')} scans only those. Binary stores (SQLite, protobuf, vector DBs) are
185
+ reported as found-but-unread, never as clean. A store whose content changed
186
+ outside any agent write the hook saw is reported as an out-of-band write.
180
187
  `;
181
188
 
182
189
  const REDTEAM = () => `${bold('REDTEAM')}
package/src/cli/help.mjs CHANGED
@@ -12,7 +12,7 @@ ${bold(cyan('shomra admin'))} ${dim('- governance & advanced security operations
12
12
  ${dim('Deep scans (backend + key)')}
13
13
  ${cyan('scan-zip')} Static-scan a workspace ZIP ${dim('<file.zip> [--project <id>] [--json]')}
14
14
  ${cyan('model-scan')} SAST-scan a public AI model ${dim('<hf-url | owner/model | github-url> [--project <id>] [--json]')}
15
- ${cyan('memory-scan')} Scan memory + rules files for poisoning ${dim('[path] [--scope …] [--writer …] [--json]')}
15
+ ${cyan('memory-scan')} Scan memory + rules files for poisoning ${dim('[path] [--machine] [--scope …] [--writer …] [--json]')}
16
16
 
17
17
  ${dim('Offense & runtime identity')}
18
18
  ${cyan('redteam')} Continuously red-team your guardrails ${dim('[--target llm-guard|model] [--evolve] [--min 80] [--fail-on-regression] [--json]')}
@@ -1,7 +1,7 @@
1
- import { execSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { ARTIFACT_MATCHERS, walkArtifacts } from '../artifacts/matchers.mjs';
4
+ import { git } from '../core/git-exec.mjs';
5
5
  import { loadConfig, resolveSettings } from '../core/config.mjs';
6
6
  import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
7
7
  import { VERSION } from '../core/version.mjs';
@@ -14,17 +14,9 @@ import { fixOneFile } from './fix.mjs';
14
14
  const GIT_TIMEOUT_MS = 3000;
15
15
 
16
16
  function gitChangedArtifacts(root, { staged }) {
17
- const run = (args) => {
18
- try {
19
- return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
20
- } catch {
21
- return null;
22
- }
23
- };
24
-
25
17
  const output = staged
26
- ? run('diff --cached --name-only --relative --diff-filter=ACM')
27
- : run('diff HEAD --name-only --relative --diff-filter=ACM');
18
+ ? git(['diff', '--cached', '--name-only', '--relative', '--diff-filter=ACM'], { cwd: root, timeout: GIT_TIMEOUT_MS })
19
+ : git(['diff', 'HEAD', '--name-only', '--relative', '--diff-filter=ACM'], { cwd: root, timeout: GIT_TIMEOUT_MS });
28
20
  if (output === null) return null;
29
21
 
30
22
  return output
@@ -11,8 +11,10 @@ import { GATE_KINDS, collectSiblings, detectEnv } from '../gate/environment.mjs'
11
11
  import { localAsGateResult, printGateResult } from '../gate/result.mjs';
12
12
  import { toSarif } from '../gate/sarif.mjs';
13
13
  import { collectLocalSast, mergeSastIntoResult } from '../gate/sast.mjs';
14
+ import { withMcpAdvisories } from '../gate/advisories.mjs';
15
+ import { readZipEntry } from '../core/zip-lite.mjs';
14
16
 
15
- const GATE_USAGE = 'shomra gate <file> [--kind mcp|skill|command|subagent|hook|rules|agent-card|memory] [--name x] [--strict] [--json]';
17
+ const GATE_USAGE = 'shomra gate <file> [--kind mcp|skill|command|subagent|hook|rules|agent-card|memory|plugin|tool-manifest|extension|workflow|guardrail|framework] [--name x] [--strict] [--json]';
16
18
 
17
19
  function resolveGateFile(file) {
18
20
  let target = path.resolve(String(file));
@@ -43,6 +45,20 @@ function readGateTarget(flags, positional) {
43
45
  process.exit(EXIT_USAGE);
44
46
  }
45
47
  const target = resolveGateFile(file);
48
+
49
+ if (/\.(?:dxt|mcpb)$/i.test(target)) {
50
+ const manifest = readZipEntry(fs.readFileSync(target), 'manifest.json');
51
+ if (manifest == null) {
52
+ console.error(`${red('✗')} ${file} is not a readable extension bundle (no manifest.json at its root).`);
53
+ process.exit(EXIT_USAGE);
54
+ }
55
+ return {
56
+ content: manifest,
57
+ relPath: `${path.relative(process.cwd(), target).split(path.sep).join('/')}!/manifest.json`,
58
+ fullTarget: null,
59
+ bundle: true,
60
+ };
61
+ }
46
62
  return {
47
63
  content: fs.readFileSync(target, 'utf8'),
48
64
  relPath: path.relative(process.cwd(), target).split(path.sep).join('/'),
@@ -79,8 +95,8 @@ export async function cmdGate(flags, positional) {
79
95
  const { apiKey, url } = resolveSettings(loadConfig());
80
96
  if (flags.all) return cmdGateAll(flags, positional, { apiKey, url });
81
97
 
82
- const { content, relPath, fullTarget } = readGateTarget(flags, positional);
83
- const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
98
+ const { content, relPath, fullTarget, bundle } = readGateTarget(flags, positional);
99
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : bundle ? 'mcp' : undefined;
84
100
  const name = flags.name ? String(flags.name) : (relPath ? relPath.split('/').pop() : 'artifact');
85
101
  const local = localGate(content, { kind, path: relPath });
86
102
 
@@ -97,7 +113,13 @@ export async function cmdGate(flags, positional) {
97
113
  console.error(` ${dim('Not enrolled - on-machine analysis only. Run')} ${bold('shomra init')} ${dim('to also apply org policy.')}`);
98
114
  }
99
115
 
100
- const base = serverVerdict || localAsGateResult(local, name, kind);
116
+ let shownLocal = local;
117
+ if (!serverVerdict) {
118
+ const { local: withAdv, advisories } = await withMcpAdvisories(local, { content, path: relPath, kind, flags });
119
+ shownLocal = withAdv;
120
+ if (advisories === 'unavailable' && !flags.json) console.error(` ${yellow('⚠')} ${dim('Advisory lookup (OSV) unreachable - pinned MCP packages were not checked for published CVEs.')}`);
121
+ }
122
+ const base = serverVerdict || localAsGateResult(shownLocal, name, kind);
101
123
  const result = mergeSastIntoResult(base, collectLocalSast({ fullPath: fullTarget, relPath, kind, content }));
102
124
  printGateResult(result, serverVerdict ? 'server' : 'local', flags);
103
125
 
@@ -1,7 +1,7 @@
1
- import { execSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { EXIT_USAGE } from '../core/exit-codes.mjs';
4
+ import { git } from '../core/git-exec.mjs';
5
5
  import { bold, dim, green, red, yellow } from '../core/terminal.mjs';
6
6
 
7
7
  export async function cmdInstallPrecommit(flags, positional) {
@@ -152,7 +152,7 @@ function installPreReceive(flags, positional) {
152
152
 
153
153
  function gitHooksDir(root) {
154
154
  try {
155
- const dir = execSync('git rev-parse --git-path hooks', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
155
+ const dir = git(['rev-parse', '--git-path', 'hooks'], { cwd: root, timeout: 3000 })?.trim();
156
156
  if (!dir) return null;
157
157
  const abs = path.isAbsolute(dir) ? dir : path.join(root, dir);
158
158
  fs.mkdirSync(abs, { recursive: true });
@@ -18,7 +18,6 @@ function readJson(file) {
18
18
  }
19
19
  }
20
20
 
21
-
22
21
  function mcpReach() {
23
22
  let total = 0;
24
23
  let mediated = 0;
@@ -6,6 +6,7 @@ import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal
6
6
  import { localGate } from '../detect/guard-signals.mjs';
7
7
  import { mcpIndexAlert, mcpLookup, mcpLookupId, parseEnvKV, worstMcpVerdict } from '../mcp/lookup.mjs';
8
8
  import { printAlternatives } from '../models/lookup.mjs';
9
+ import { withMcpAdvisories } from '../gate/advisories.mjs';
9
10
 
10
11
  const MAX_FINDINGS_SHOWN = 6;
11
12
  const RUNNER_NAMES = /^(npx|bunx|pnpx|uvx|uv|node|deno|bun|python3?|py|docker|podman|sh|bash|zsh|ruby|go|cargo|dotnet)$/i;
@@ -130,7 +131,7 @@ export async function cmdMcpAdd(flags, positional, configFile) {
130
131
  assertUsableArguments(name, server, tokens);
131
132
 
132
133
  const candidate = JSON.stringify({ mcpServers: { [name]: server } }, null, 2);
133
- const localVerdict = localGate(candidate, { kind: 'mcp', path: '.mcp.json' });
134
+ const { local: localVerdict } = await withMcpAdvisories(localGate(candidate, { kind: 'mcp', path: '.mcp.json' }), { content: candidate, path: '.mcp.json', kind: 'mcp', flags });
134
135
  const index = await lookupSecurityIndex(flags, server, name);
135
136
  const verdict = worstMcpVerdict(localVerdict.verdict, mcpIndexAlert(index));
136
137
 
@@ -4,36 +4,27 @@ import path from 'node:path';
4
4
  import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
5
5
  import { api } from '../core/api-client.mjs';
6
6
  import { guardTimeoutMs } from '../core/circuit-breaker.mjs';
7
- import { loadConfig, resolveSettings } from '../core/config.mjs';
7
+ import { getMachineId, loadConfig, resolveSettings } from '../core/config.mjs';
8
8
  import { EXIT_USAGE, exitNotConfigured } from '../core/exit-codes.mjs';
9
9
  import { SEV_COLOR, VERDICT_COLOR, bold, cyan, dim, gray, green, red, yellow } from '../core/terminal.mjs';
10
+ import { isInstructionPath } from '../detect/signals/instruction-paths.mjs';
11
+ import { MACHINE_MEMORY_ROOTS, SERVER_SIDE_MEMORY, classifyMemoryPath, sniffMemoryJsonl } from '../detect/signals/memory-locations.mjs';
12
+ import { outOfBandChange, recordLedger, sha256 } from '../guard/memory-write.mjs';
10
13
 
11
- const MEMORY_MATCHERS = [
12
- /(^|\/)MEMOR(Y|IES)\.(md|json|jsonl|txt)$/i,
13
- /(^|\/)memor(y|ies)\/[^/]+\.(md|mdx|json|jsonl|txt|ya?ml)$/i,
14
- /(^|\/)\.(mem0|letta|memgpt)\/[^/]+\.(md|json|jsonl|txt)$/i,
15
- ];
16
-
17
- const INSTRUCTION_MATCHERS = [
18
- /(^|\/)(CLAUDE|AGENTS?|GEMINI|CONVENTIONS)\.md$/i,
19
- /(^|\/)LLMS(-FULL)?\.txt$/i,
20
- /(^|\/)\.(cursorrules|windsurfrules|clinerules|aiderrules|continuerules|goosehints)$/i,
21
- /(^|\/)\.github\/copilot-instructions\.md$/i,
22
- /(^|\/)copilot-instructions\.md$/i,
23
- /(^|\/)\.cursor\/rules\/.+\.mdc$/i,
24
- /(^|\/)\.clinerules\/.+\.md$/i,
25
- ];
14
+ export function memoryLocation(p) {
15
+ return classifyMemoryPath(String(p || '').split(path.sep).join('/'));
16
+ }
26
17
 
27
18
  export function isMemoryPath(p) {
28
19
  const rel = String(p || '').split(path.sep).join('/');
29
- return MEMORY_MATCHERS.some((re) => re.test(rel)) || INSTRUCTION_MATCHERS.some((re) => re.test(rel));
20
+ return !!classifyMemoryPath(rel) || isInstructionPath(rel);
30
21
  }
31
22
 
32
- function walkMemoryFiles(root) {
23
+ function walkMemoryFiles(root, maxDepth = 12) {
33
24
  const found = [];
34
- const stack = [root];
25
+ const stack = [{ dir: root, depth: 0 }];
35
26
  while (stack.length) {
36
- const dir = stack.pop();
27
+ const { dir, depth } = stack.pop();
37
28
  let entries;
38
29
  try {
39
30
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -43,11 +34,77 @@ function walkMemoryFiles(root) {
43
34
  for (const ent of entries) {
44
35
  const full = path.join(dir, ent.name);
45
36
  if (ent.isDirectory()) {
46
- if (!SKIP_DIRS.has(ent.name)) stack.push(full);
37
+ if (!SKIP_DIRS.has(ent.name) && depth < maxDepth) stack.push({ dir: full, depth: depth + 1 });
47
38
  continue;
48
39
  }
40
+ if (!ent.isFile()) continue;
49
41
  const rel = path.relative(root, full).split(path.sep).join('/');
50
- if (isMemoryPath(rel) || isMemoryPath(full)) found.push({ full, rel });
42
+ if (isMemoryPath(rel) || isMemoryPath(full) || (/\.(jsonl|ndjson)$/i.test(ent.name) && sniffJsonlFile(full))) found.push({ full, rel });
43
+ }
44
+ }
45
+ return found;
46
+ }
47
+
48
+ function sniffJsonlFile(full) {
49
+ try {
50
+ const fd = fs.openSync(full, 'r');
51
+ const buf = Buffer.alloc(4096);
52
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
53
+ fs.closeSync(fd);
54
+ return sniffMemoryJsonl(buf.subarray(0, n).toString('utf8'));
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function vscodeUserDirs() {
61
+ const home = os.homedir();
62
+ const bases = process.platform === 'win32'
63
+ ? [process.env.APPDATA || path.join(home, 'AppData', 'Roaming')]
64
+ : process.platform === 'darwin'
65
+ ? [path.join(home, 'Library', 'Application Support')]
66
+ : [process.env.XDG_CONFIG_HOME || path.join(home, '.config')];
67
+ return bases.flatMap((b) => ['Code', 'Code - Insiders', 'Cursor', 'Windsurf', 'VSCodium'].map((app) => path.join(b, app, 'User')));
68
+ }
69
+
70
+ function expandRoot(base, dir) {
71
+ const parts = dir.split('/');
72
+ let paths = [base];
73
+ for (const part of parts) {
74
+ const next = [];
75
+ for (const p of paths) {
76
+ if (part === '*') {
77
+ try {
78
+ for (const ent of fs.readdirSync(p, { withFileTypes: true })) if (ent.isDirectory()) next.push(path.join(p, ent.name));
79
+ } catch {
80
+ continue;
81
+ }
82
+ } else {
83
+ next.push(path.join(p, part));
84
+ }
85
+ }
86
+ paths = next;
87
+ }
88
+ return paths.filter((p) => fs.existsSync(p));
89
+ }
90
+
91
+ export function machineMemoryFiles() {
92
+ const seen = new Set();
93
+ const found = [];
94
+ const home = os.homedir();
95
+ for (const root of MACHINE_MEMORY_ROOTS) {
96
+ const bases = root.base === 'vscode-user' ? vscodeUserDirs() : [home];
97
+ for (const base of bases) {
98
+ for (const dir of expandRoot(base, root.dir)) {
99
+ for (const f of walkMemoryFiles(dir, root.depth)) {
100
+ const key = path.resolve(f.full).toLowerCase();
101
+ if (seen.has(key)) continue;
102
+ seen.add(key);
103
+ const rel = path.relative(home, f.full).split(path.sep).join('/');
104
+ if (!isMemoryPath(rel) && !isMemoryPath(f.full)) continue;
105
+ found.push({ full: f.full, rel: `~/${rel}` });
106
+ }
107
+ }
51
108
  }
52
109
  }
53
110
  return found;
@@ -65,14 +122,22 @@ export async function reportMemoryWrite(url, apiKey, body) {
65
122
  });
66
123
  clearTimeout(timer);
67
124
  } catch {
68
-
125
+ return;
69
126
  }
70
127
  }
71
128
 
72
- const NO_STORES_HINT = '(Looked for MEMORY.md, memory/ dirs, .mem0/.letta stores, and rules files: CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions.md, …)';
129
+ const NO_STORES_HINT = '(Looked for Claude/Codex/Gemini/Qwen/Windsurf/Goose/Serena memory, memory-bank/, MCP memory.jsonl, mem0/Letta stores, and rules files: CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions.md, …)';
73
130
 
74
- function resolveMemoryTarget(positional) {
75
- const argument = positional[0] || '.';
131
+ function resolveMemoryTargets(positional, flags) {
132
+ if (flags.machine) return { target: '~', files: machineMemoryFiles() };
133
+ const argument = positional[0];
134
+ if (!argument) {
135
+ const cwd = process.cwd();
136
+ const local = walkMemoryFiles(cwd).map((f) => ({ ...f, full: f.full }));
137
+ const seen = new Set(local.map((f) => path.resolve(f.full).toLowerCase()));
138
+ const machine = machineMemoryFiles().filter((f) => !seen.has(path.resolve(f.full).toLowerCase()));
139
+ return { target: `${cwd} + this machine's agent memory`, files: [...local, ...machine] };
140
+ }
76
141
  const target = path.resolve(String(argument));
77
142
  if (!fs.existsSync(target)) {
78
143
  console.error(`${red('✗')} Not found: ${argument}`);
@@ -83,15 +148,15 @@ function resolveMemoryTarget(positional) {
83
148
  : { target, files: [{ full: target, rel: path.basename(target) }] };
84
149
  }
85
150
 
86
- function readStoreContent(file, flags) {
151
+ function readStore(file) {
152
+ const loc = memoryLocation(file.full) ?? memoryLocation(file.rel);
153
+ if (loc?.format === 'binary') return { content: '', unreadable: `${path.extname(file.full).slice(1) || 'binary'} store`, format: 'binary' };
87
154
  try {
88
- if (fs.statSync(file.full).size > MAX_ARTIFACT_BYTES) {
89
- if (!flags.json) console.log(` ${gray('')} ${dim(file.rel)} ${yellow('skipped (too large)')}`);
90
- return null;
91
- }
92
- return fs.readFileSync(file.full, 'utf8');
93
- } catch {
94
- return null;
155
+ const size = fs.statSync(file.full).size;
156
+ if (size > MAX_ARTIFACT_BYTES) return { content: '', unreadable: `larger than ${Math.round(MAX_ARTIFACT_BYTES / 1_000_000)} MB`, format: loc?.format ?? null };
157
+ return { content: fs.readFileSync(file.full, 'utf8'), unreadable: null, format: loc?.format ?? null };
158
+ } catch (error) {
159
+ return { content: '', unreadable: `could not be opened (${error.code || 'error'})`, format: loc?.format ?? null };
95
160
  }
96
161
  }
97
162
 
@@ -101,7 +166,7 @@ function worseVerdict(current, next) {
101
166
  return 'PASS';
102
167
  }
103
168
 
104
- function printStore(file, report) {
169
+ function printStore(file, report, note) {
105
170
  const verdict = report?.store?.verdict || 'PASS';
106
171
  const colour = VERDICT_COLOR[verdict] || gray;
107
172
  const poisonScore = report?.store?.poisonScore ?? 0;
@@ -109,7 +174,8 @@ function printStore(file, report) {
109
174
 
110
175
  const quarantineNote = report?.quarantined ? ` ${red('QUARANTINED')}` : '';
111
176
  const provenanceNote = anomalous ? ` ${red('OUT-OF-BAND WRITE')}` : '';
112
- console.log(`\n ${colour('●')} ${bold(path.basename(file.rel))} ${dim(file.rel)} ${colour(verdict)} ${dim(`poison ${poisonScore}/100`)}${quarantineNote}${provenanceNote}`);
177
+ const extra = note ? ` ${yellow(note)}` : '';
178
+ console.log(`\n ${colour('●')} ${bold(path.basename(file.rel))} ${dim(file.rel)} ${colour(verdict)} ${dim(`poison ${poisonScore}/100`)}${quarantineNote}${provenanceNote}${extra}`);
113
179
 
114
180
  for (const finding of (report?.analysis?.findings || []).filter((f) => f.severity !== 'INFO')) {
115
181
  console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}`);
@@ -123,56 +189,78 @@ function summaryLine(worst) {
123
189
  return green('✓ No memory poisoning found.');
124
190
  }
125
191
 
192
+ function writerFor(file, content, flags) {
193
+ if (flags.writer) return { writer: String(flags.writer).toUpperCase(), source: 'shomra memory-scan' };
194
+ if (outOfBandChange(file.full, content)) {
195
+ return { writer: 'UNKNOWN', source: 'changed outside any agent write the Shomra hook observed' };
196
+ }
197
+ return { writer: 'SCAN', source: 'shomra memory-scan' };
198
+ }
199
+
126
200
  export async function cmdMemoryScan(flags, positional) {
127
- const { apiKey, url } = resolveSettings(loadConfig());
201
+ const cfg = loadConfig();
202
+ const { apiKey, url } = resolveSettings(cfg);
128
203
  if (!apiKey) exitNotConfigured();
129
204
 
130
- const { target, files } = resolveMemoryTarget(positional);
205
+ const { target, files } = resolveMemoryTargets(positional, flags);
131
206
  if (!files.length) {
132
207
  if (flags.json) console.log(JSON.stringify({ scanned: 0, stores: [] }, null, 2));
133
208
  else console.log(dim(`\n No memory or rules files found under ${target}.\n ${NO_STORES_HINT}\n`));
134
209
  return;
135
210
  }
136
211
 
212
+ const machineId = getMachineId(cfg);
137
213
  const ingestFields = {
138
214
  scope: flags.scope ? String(flags.scope).toLowerCase() : undefined,
139
- writer: flags.writer ? String(flags.writer).toUpperCase() : 'AGENT',
140
215
  actor: `${os.hostname()}/${os.userInfo().username}`,
141
- source: 'shomra memory-scan',
216
+ machineId,
217
+ hostname: os.hostname(),
142
218
  ...(flags.project ? { projectId: String(flags.project) } : {}),
143
219
  };
144
220
 
145
221
  if (!flags.json) {
146
- console.log(bold(cyan('\n Shomra Memory Integrity')) + dim(` - scanning ${files.length} store${files.length > 1 ? 's' : ''}`));
222
+ console.log(bold(cyan('\n Shomra Memory Integrity')) + dim(` - scanning ${files.length} store${files.length > 1 ? 's' : ''} (${target})`));
147
223
  }
148
224
 
149
225
  let worst = 'PASS';
226
+ let unread = 0;
150
227
  const stores = [];
151
228
  for (const file of files) {
152
- const content = readStoreContent(file, flags);
153
- if (content == null) continue;
229
+ const { content, unreadable, format } = readStore(file);
230
+ if (unreadable) unread++;
231
+ const { writer, source } = writerFor(file, unreadable ? null : content, flags);
232
+ const loc = memoryLocation(file.full) ?? memoryLocation(file.rel);
233
+ const scope = ingestFields.scope ?? (file.rel.startsWith('~/') && !loc?.committed ? 'user' : undefined);
154
234
 
155
235
  let report;
156
236
  try {
157
237
  report = await api(url, apiKey, '/memory/ingest', {
158
238
  ...ingestFields,
159
- path: file.rel,
239
+ ...(scope ? { scope } : {}),
240
+ path: path.resolve(file.full).split(path.sep).join('/'),
160
241
  name: path.basename(file.rel),
161
242
  content,
243
+ writer,
244
+ source,
245
+ ...(unreadable ? { unreadable } : {}),
246
+ ...(format ? { format } : {}),
162
247
  });
163
248
  } catch (error) {
164
249
  console.error(` ${red('✗')} ${file.rel} ${red(`ingest error: ${error.message}`)}`);
165
250
  continue;
166
251
  }
252
+ if (!unreadable) recordLedger(file.full, [sha256(content)]);
167
253
 
168
254
  worst = worseVerdict(worst, report?.store?.verdict || 'PASS');
169
- stores.push({ path: file.rel, ...report });
170
- if (!flags.json) printStore(file, report);
255
+ stores.push({ path: file.rel, vendor: loc?.vendor ?? null, unreadable, ...report });
256
+ if (!flags.json) printStore(file, report, unreadable ? `not read: ${unreadable}` : null);
171
257
  }
172
258
 
173
259
  if (flags.json) {
174
- console.log(JSON.stringify({ scanned: stores.length, worst, stores }, null, 2));
260
+ console.log(JSON.stringify({ scanned: stores.length, unread, worst, stores, serverSide: SERVER_SIDE_MEMORY }, null, 2));
175
261
  } else {
262
+ if (unread) console.log(`\n ${yellow('⚠')} ${unread} store${unread > 1 ? 's were' : ' was'} found but not read - reported as not looked inside, never as clean.`);
263
+ console.log(dim(`\n Not scannable from this machine: ${SERVER_SIDE_MEMORY.map((s) => s.vendor).join(', ')} keep memory server-side.`));
176
264
  console.log(`\n ${summaryLine(worst)}${dim(' Full timeline + rollback in the Shomra dashboard → Memory.\n')}`);
177
265
  }
178
266
 
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { git, safeRef } from '../core/git-exec.mjs';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { ARTIFACT_MATCHERS, walkArtifacts } from '../artifacts/matchers.mjs';
@@ -44,13 +44,14 @@ async function githubApi(token, method, apiPath, body) {
44
44
  }
45
45
 
46
46
  function gitChangedVsBase(root, base) {
47
- const run = (args) => { try { return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString(); } catch { return null; } };
47
+ const run = (args) => git(args, { cwd: root });
48
48
  let out = null;
49
- for (const b of [`origin/${base}`, base]) {
50
- out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
49
+ const ref = safeRef(base);
50
+ for (const b of ref ? [`origin/${ref}`, ref] : []) {
51
+ out = run(['diff', '--name-only', '--relative', '--diff-filter=ACM', `${b}...HEAD`]);
51
52
  if (out !== null) break;
52
53
  }
53
- if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
54
+ if (out === null) out = run(['diff', 'HEAD~1', '--name-only', '--relative', '--diff-filter=ACM']);
54
55
  if (out === null) return null;
55
56
  const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
56
57
  return files.filter((rel) => ARTIFACT_MATCHERS.some((m) => m.re.test(rel)));
@@ -74,11 +75,7 @@ function writeWorkflow(flags) {
74
75
  }
75
76
 
76
77
  function headShaFromGit() {
77
- try {
78
- return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
79
- } catch {
80
- return null;
81
- }
78
+ return git(['rev-parse', 'HEAD'])?.trim() || null;
82
79
  }
83
80
 
84
81
  function resolvePrContext(flags) {
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { git, safeRef } from '../core/git-exec.mjs';
2
2
  import path from 'node:path';
3
3
  import { api } from '../core/api-client.mjs';
4
4
  import { loadConfig, resolveSettings } from '../core/config.mjs';
@@ -6,23 +6,18 @@ import { EXIT_USAGE } from '../core/exit-codes.mjs';
6
6
  import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
7
7
 
8
8
  function gitChangedPaths(root, { staged, base }) {
9
- const run = (args) => {
10
- try {
11
- return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
12
- } catch {
13
- return null;
14
- }
15
- };
9
+ const run = (args) => git(args, { cwd: root });
16
10
  let out = null;
17
11
  if (staged) {
18
- out = run('diff --cached --name-only --relative --diff-filter=ACM');
19
- } else if (base) {
20
- for (const b of [`origin/${base}`, base]) {
21
- out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
12
+ out = run(['diff', '--cached', '--name-only', '--relative', '--diff-filter=ACM']);
13
+ } else if (safeRef(base)) {
14
+ const ref = safeRef(base);
15
+ for (const b of [`origin/${ref}`, ref]) {
16
+ out = run(['diff', '--name-only', '--relative', '--diff-filter=ACM', `${b}...HEAD`]);
22
17
  if (out !== null) break;
23
18
  }
24
19
  }
25
- if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
20
+ if (out === null) out = run(['diff', 'HEAD~1', '--name-only', '--relative', '--diff-filter=ACM']);
26
21
  if (out === null) return null;
27
22
  return out.split('\n').map((s) => s.trim()).filter(Boolean);
28
23
  }
@@ -8,6 +8,7 @@ import { SEV_COLOR, VERDICT_COLOR, bold, cyan, dim, gray, green, red, yellow } f
8
8
  import { VERSION } from '../core/version.mjs';
9
9
  import { discoverAgentArtifacts } from '../inventory/agent-artifacts.mjs';
10
10
  import { discoverAll } from '../inventory/discovery.mjs';
11
+ import { unreadMcpStores } from '../inventory/discovery/mcp-servers.mjs';
11
12
 
12
13
  function discover(flags) {
13
14
 
@@ -24,12 +25,17 @@ export async function cmdScan(flags) {
24
25
  const cfg = loadConfig();
25
26
  const assets = discover(flags);
26
27
  const { artifacts, capped, available } = discoverArtifacts(flags);
28
+ const unreadStores = unreadMcpStores();
29
+
27
30
  if (flags.json && !flags.report) {
28
- console.log(JSON.stringify({ machine: machineInfo(cfg), assets, artifacts, capped, available }, null, 2));
31
+ console.log(JSON.stringify({ machine: machineInfo(cfg), assets, artifacts, capped, available, ...(unreadStores.length ? { unreadMcpStores: unreadStores } : {}) }, null, 2));
29
32
  return;
30
33
  }
31
34
  console.log(bold(cyan('\n Shomra')) + dim(` agent v${VERSION} - local scan`));
32
35
  printAssets(assets);
36
+ for (const s of unreadStores) {
37
+ console.log(` ${yellow('⚠')} ${dim(`MCP servers in ${s.file} were NOT read (${s.state === 'no-sqlite' ? 'this Node has no node:sqlite - use Node 22.5+' : s.state}) - not the same as none.`)}`);
38
+ }
33
39
  printArtifacts(artifacts, capped, available);
34
40
 
35
41
  if (flags.report) {
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { git, safeRef } from '../core/git-exec.mjs';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
@@ -12,8 +12,7 @@ function redactSecret(s) {
12
12
  }
13
13
 
14
14
  function isGitRepo(root) {
15
- try { execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); return true; }
16
- catch { return false; }
15
+ return git(['rev-parse', '--is-inside-work-tree'], { cwd: root }) !== null;
17
16
  }
18
17
 
19
18
  export function walkFiles(root, cap = 8000) {
@@ -34,8 +33,8 @@ export function walkFiles(root, cap = 8000) {
34
33
 
35
34
  function scanGitHistory(root, depth) {
36
35
  let out;
37
- try { out = execSync(`git log --all -p -n ${depth} --no-color --format="commit %H %an %ad"`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 * 1024 }).toString(); }
38
- catch { return null; }
36
+ out = git(['log', '--all', '-p', '-n', String(Math.floor(Number(depth)) || 300), '--no-color', '--format=commit %H %an %ad'], { cwd: root, timeout: 120000, maxBuffer: 128 * 1024 * 1024 });
37
+ if (out === null) return null;
39
38
  const hits = [];
40
39
  const seen = new Set();
41
40
  let commit = '', file = '';